Python进阶(1) | 单元测试
2024.01.28
VSCode: 1.85.1
Linux(ubuntu 22.04)
文章目录
1. 目的
使用 Python 实现一些小工具、库的时候,增加单元测试来保证正确性。
重读 VSCode 的 Python 官方文档, 更新个人的 Python 开发效率。
2. Python Profile
VSCode 提供了定制 profile 的功能, 个人目前理解为类似于 vim/emacs 里的模式的升级版。以前我只是配置VSCode的全局配置和当前工程配置, 而 Profile 则是建立了不同的配置,每个打开的VSCode工程都可以在不同的 profile 之间切换。
举例: 分别设置 C++ Profile 和 Python profile, 在 Python profile 和 C++ profile 中使用不同的快捷键、不同的UI布局等。
关于 profile 的完整文档在 https://code.visualstudio.com/docs/editor/profiles
官方提供了 Python 的profile,可以根据这个预定义的 profile, 继承它,创建一个自己的 Python profile:
https://code.visualstudio.com/docs/editor/profiles#_python-profile-template
3. 单元测试框架
3.1 什么是单元测试
所谓单元,指的是一段特定的要被测试的代码,比如说一个函数、一个类。
所谓测试,指的是被测试代码A之外的代码B, 也就是说B这部分代码存在的意义,就是测试A这部分代码。
测试代码通常需要包含各种不同的输入,包括边界情况。
单元测试仅仅关注输入 和 输出, 不关注代码实现的细节。
因此,所谓单元测试,首先需要划分出单元,然后针对每个单元(或者仅对于关注的单元),编写测试代码。
对于被测试的代码的每一种输入,你需要定义它的预期结果。
然后调用被测试的代码A: 给它传入输入, 获得它的输出结果, 并且和你预设的结果进行比对,结果一样则成功,不一样则报告失败。
https://code.visualstudio.com/docs/python/testing
3.2 选一个单元测试框架
Python 最常用的单元测试框架: unittest 和 pytest.
unittest 是 Python 标准库的模块, 也就是 Python 安装后自带的。 pytest 则需要自行安装: pip install pytest
.
3.3 编写 Python 单元测试代码
首先,是被测试的单元的代码, inc_dec.py
:
def increment(x: int):
return x + 1
def decrement(x: int):
return x - 1
然后, 是编写测试代码. 先用 unittest 写一遍:test_unittest.py
import inc_dec
import unittest
class Test_TestIncrementDecrement(unittest.TestCase):
def test_increment(self):
self.assertEqual(inc_dec.increment(3), 4)
# 这个测试用例一定会失败,是刻意做的
def test_decrement(self):
self.assertEqual(inc_dec.decrement(3), 4)
if __name__ == '__main__':
unittest.main()
再用 pytest 写一遍, 写法更简单:
import inc_dec
def test_increment():
assert inc_dec.increment(3) == 4
# 这个测试用例一定会失败,是刻意做的
def test_decrement():
assert inc_dec.decrement(3) == 4
3.4 在 VSCode 里发现单元测试
首先在 VSCode 里点击左侧的 Testing 按钮, 创建测试相关的配置:
它对应到 .vscode/setting.json 里的内容:
{
"python.testing.pytestArgs": [
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
然后点击 Testing 视图中的测试用例中最上方的按钮, 会自动发现和执行所有的测试用例:
在 Testing 界面中点击到 “失败” (红色) 的case, 会看到失败的具体测试代码。我们发现是测试代码本身写错, 于是改掉, 然后重新在 Testing 界面中执行测试:
最终,我们看到 Testing 界面中的每一项都是绿色, 表示都成功了:
3.5 再写一个单元和测试: IoU 的计算
前面给出的 inc_dec.py 的代码太简单, 测试代码也不太符合预期解决的问题。
单元测试的预期目的,是发现单元中的bug。这次写一个经典的计算两个Box的IoU的函数,并且故意缺少处理非法box长度的情况。
bbox.py:
# define Box class
class Box(object):
def __init__(self, x, y, w, h, score):
self.x = x
self.y = y
self.w = w
self.h = h
self.score = score
def __repr__(self):
return 'Box(x=%f, y=%f, w=%f, h=%f, score=%f)' % (self.x, self.y, self.w, self.h, self.score)
# calculate IoU of two boxes
def box_iou(box1, box2):
# get coordinates of intersecting rectangle
x_left = max(box1.x, box2.x)
y_top = max(box1.y, box2.y)
x_right = min(box1.x + box1.w, box2.x + box2.w)
y_bottom = min(box1.y + box1.h, box2.y + box2.h)
# if x_right < x_left or y_bottom < y_top:
# return 0.0
# intersection area
intersection_area = (x_right - x_left) * (y_bottom - y_top)
# union area
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
return intersection_area / union_area
test_bbox.py:
import bbox
def test_box_iou():
box1 = bbox.Box(0, 0, 1, 1, 0.9)
box2 = bbox.Box(0, 0, 1, 1, 0.9)
assert bbox.box_iou(box1, box2) == 1.0
def test_box_iou2():
box1 = bbox.Box(0, 0, 1, 1, 0.9)
box2 = bbox.Box(1, 1, 2, 2, 0.9)
assert bbox.box_iou(box1, box2) == 0
def test_box_iou3(): # 这个例子是边界case,很容易失败
box1 = bbox.Box(0, 0, 0, 0, 0.9)
box2 = bbox.Box(1, 1, 1, 1, 0.9)
assert bbox.box_iou(box1, box2) == 0
上述代码在 test_box_iou3() 时失败了, 错误类型是出现了除0错误。显然,除非两个 box 大小都是0,否则不会出现除以0的情况。于是很偷懒的改了一下:
# calculate IoU of two boxes
def box_iou(box1, box2):
# get coordinates of intersecting rectangle
x_left = max(box1.x, box2.x)
y_top = max(box1.y, box2.y)
x_right = min(box1.x + box1.w, box2.x + box2.w)
y_bottom = min(box1.y + box1.h, box2.y + box2.h)
# if x_right < x_left or y_bottom < y_top:
# return 0.0
# intersection area
intersection_area = (x_right - x_left) * (y_bottom - y_top)
# union area
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
if union_area == 0:
return 0
return intersection_area / union_area
再增加一个侧测试用例:当box本身的宽度或高度为负值时,预期结果我们设置为0. 测试代码是:
def test_box_iou4():
box1 = bbox.Box(0, 0, -1, -1, 0.9)
box2 = bbox.Box(0, 0, 2, 2, 0.9)
iou = bbox.box_iou(box1, box2)
assert iou == 0
IoU的实现代码,仍然是用很偷懒的修改:
# calculate IoU of two boxes
def box_iou(box1, box2):
# get coordinates of intersecting rectangle
x_left = max(box1.x, box2.x)
y_top = max(box1.y, box2.y)
x_right = min(box1.x + box1.w, box2.x + box2.w)
y_bottom = min(box1.y + box1.h, box2.y + box2.h)
# if x_right < x_left or y_bottom < y_top:
# return 0.0
# intersection area
intersection_area = (x_right - x_left) * (y_bottom - y_top)
# union area
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
# if union_area == 0:
# return 0
# if box1.w < 0 or box1.h < 0 or box2.w < 0 or box2.h < 0:
# return 0
return intersection_area / union_area
此时的测试仍然不够完备。再补充一个:
def test_box_iou5():
box1 = bbox.Box(0, 0, 0, 0, 0.9)
box2 = bbox.Box(0, 0, 0, 0, 0.9)
iou = bbox.box_iou(box1, box2)
assert iou == 0
现在,把包含了补丁的 box_iou() 重构一番,得到:
# calculate IoU of two boxes
def box_iou(box1, box2):
# get coordinates of intersecting rectangle
x_left = max(box1.x, box2.x)
y_top = max(box1.y, box2.y)
x_right = min(box1.x + box1.w, box2.x + box2.w)
y_bottom = min(box1.y + box1.h, box2.y + box2.h)
if x_right <= x_left or y_bottom <= y_top:
return 0.0
# intersection area
intersection_area = (x_right - x_left) * (y_bottom - y_top)
# union area
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
return intersection_area / union_area
4. 总结
VSCode 的 Testing 视图,改善了运行单元测试的交互界面。传统的 C/C++ 中, gtest 框架通过传入 --gtest_filter=xxx 来过滤测试, 在 VSCode 面前仍然落后。
至于单元测试代码是否够好, 一个标准是覆盖率的高低, 就像 IoU 的例子, 第一次用 ChatGPT 生成代码时,虽然看似正确, 但其实 test_box_iou5() 这个测试用例(两个box的大小都是0,并且重合)是无法通过的。
因此, VSCode 的 Testing 界面仅仅是锦上添花, 单元测试的编写仍然需要考虑周全。
5. References
- https://code.visualstudio.com/docs/editor/profiles#_python-profile-template
- https://code.visualstudio.com/docs/python/testing