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 按钮, 创建测试相关的配置:
Python进阶(1) | 使用VScode写单元测试-LMLPHP

它对应到 .vscode/setting.json 里的内容:

{
    "python.testing.pytestArgs": [
        "."
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.pytestEnabled": true
}

然后点击 Testing 视图中的测试用例中最上方的按钮, 会自动发现和执行所有的测试用例:
Python进阶(1) | 使用VScode写单元测试-LMLPHP

在 Testing 界面中点击到 “失败” (红色) 的case, 会看到失败的具体测试代码。我们发现是测试代码本身写错, 于是改掉, 然后重新在 Testing 界面中执行测试:
Python进阶(1) | 使用VScode写单元测试-LMLPHP

最终,我们看到 Testing 界面中的每一项都是绿色, 表示都成功了:
Python进阶(1) | 使用VScode写单元测试-LMLPHP

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
01-29 12:15