4. Python单元测试进阶:参数化测试、测试套件、mock对象基础
单元测试写多了,你会发现一个规律:很多测试逻辑其实长得差不多,只是输入输出不一样。如果每个用例都写一个独立的测试函数,代码会变得又臭又长。这一章我们就来解决这个问题。
我个人习惯把参数化测试、测试套件和 mock 称为「单元测试三件套」。它们能帮你把测试代码写得干净、高效,还能处理那些棘手的依赖问题。咱们一个一个来看。
4.1 参数化测试:一个函数测一百种情况
先看个场景。你写了一个函数 parse_firmware_version,需要测试各种合法的和不合法的版本号字符串。如果每个用例都写一个 test_xxx,那测试文件会膨胀到没法看。
参数化测试就是干这个的。它让你用一套测试逻辑,跑多组数据。
4.1.1 使用 pytest.mark.parametrize
pytest 的参数化装饰器是最常用的方式。写法很直接:
import pytest
def parse_firmware_version(version_str):
parts = version_str.split('.')
if len(parts) != 3:
raise ValueError("格式错误")
return tuple(int(p) for p in parts)
@pytest.mark.parametrize("input_str, expected", [
("1.2.3", (1, 2, 3)),
("0.0.1", (0, 0, 1)),
("10.20.30", (10, 20, 30)),
])
def test_parse_valid(input_str, expected):
assert parse_firmware_version(input_str) == expected
@pytest.mark.parametrize("input_str", [
"1.2",
"1.2.3.4",
"abc.def.ghi",
"",
])
def test_parse_invalid(input_str):
with pytest.raises(ValueError):
parse_firmware_version(input_str)
你看,两个测试函数覆盖了七种情况。如果以后要加新的版本号格式,只需要往列表里加一行数据就行。
4.1.2 参数化与 fixture 组合
有时候你需要 fixture 提供环境,参数化提供数据。两者可以叠加使用:
@pytest.fixture
def temp_dir(tmp_path):
d = tmp_path / "firmware"
d.mkdir()
return d
@pytest.mark.parametrize("filename, expected_size", [
("blinky.bin", 1024),
("sensor.bin", 2048),
])
def test_firmware_size(temp_dir, filename, expected_size):
# 使用 temp_dir 和参数化数据
pass
这里要注意顺序:fixture 参数放在前面,参数化参数放在后面。pytest 会自动匹配。
4.2 测试套件:把测试组织起来
项目大了以后,测试文件会越来越多。你可能想只跑某一部分测试,或者按特定顺序执行。这时候就需要测试套件了。
4.2.1 pytest 的自动收集机制
pytest 默认会递归查找当前目录下所有 test_*.py 或 *_test.py 文件,然后执行里面所有 test_* 开头的函数。这本身就是一种隐式的测试套件。
但如果你需要更精细的控制,可以用以下方式:
| 方式 | 命令/写法 | 适用场景 |
|---|---|---|
| 按目录运行 | pytest tests/unit/ |
只跑单元测试 |
| 按关键字选择 | pytest -k "i2c or spi" |
只跑与 I2C 或 SPI 相关的测试 |
| 按标记选择 | pytest -m "slow" |
只跑标记为 slow 的测试 |
| 自定义 pytest_collection_modifyitems | conftest.py 中写 hook | 需要自定义排序或过滤 |
4.2.2 使用 pytest.ini 配置默认套件
我建议在项目根目录放一个 pytest.ini 文件,把常用的配置写进去:
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
slow: 运行时间较长的测试
integration: 集成测试
mock_external: 需要 mock 外部设备的测试
这样团队成员执行 pytest 时,行为是一致的。不会出现「我本地能跑,你那边怎么不行」的情况。
test_xxx.py,有的叫 xxx_test.py,还有的叫 check_xxx.py。结果 pytest 漏掉了一半测试。后来我统一用 test_ 前缀,并在 pytest.ini 里明确指定 python_files,再也没出过问题。
4.3 Mock 对象基础:隔离外部依赖
嵌入式开发中,你的代码经常要操作硬件寄存器、读取传感器、发送网络包。这些依赖在单元测试环境里根本不存在。怎么办?用 mock。
mock 说白了就是「假对象」。它模拟真实对象的行为,让你能控制返回值、记录调用情况。
4.3.1 unittest.mock 基础用法
Python 标准库自带 unittest.mock,pytest 也推荐用它。看个例子:
from unittest.mock import Mock, patch
# 假设这是你的业务代码
def read_temperature(sensor):
raw = sensor.read_register(0x10)
return (raw * 0.01) - 40.0
def test_read_temperature():
# 创建一个 mock 传感器
mock_sensor = Mock()
mock_sensor.read_register.return_value = 2500
result = read_temperature(mock_sensor)
assert result == (2500 * 0.01) - 40.0
# 验证 read_register 被调用了一次,参数是 0x10
mock_sensor.read_register.assert_called_once_with(0x10)
这里的关键点:
return_value控制 mock 对象被调用时返回什么assert_called_once_with验证调用参数是否正确- mock 对象可以模拟任何接口,不需要真实硬件
4.3.2 使用 patch 上下文管理器
有时候你不想手动创建 mock 对象,而是想直接替换某个模块里的函数或类。这时候用 patch:
# 业务代码:firmware_loader.py
import serial
def flash_device(port, firmware_data):
ser = serial.Serial(port, 115200)
ser.write(firmware_data)
ser.close()
# 测试代码
from unittest.mock import patch
@patch('firmware_loader.serial.Serial')
def test_flash_device(mock_serial_class):
mock_ser_instance = mock_serial_class.return_value
firmware_data = b'\x00\x01\x02\x03'
flash_device('/dev/ttyUSB0', firmware_data)
# 验证 serial.Serial 被正确初始化
mock_serial_class.assert_called_once_with('/dev/ttyUSB0', 115200)
# 验证 write 被调用
mock_ser_instance.write.assert_called_once_with(firmware_data)
# 验证 close 被调用
mock_ser_instance.close.assert_called_once()
@patch 装饰器会临时把 firmware_loader.serial.Serial 替换成一个 mock 类。测试结束后自动恢复原样。你想想看,如果没有 mock,你要怎么测试这个 flash_device 函数?难道真的插一块开发板?
4.3.3 Mock 的常用断言方法
| 方法 | 作用 |
|---|---|
assert_called() |
至少被调用一次 |
assert_called_once() |
恰好被调用一次 |
assert_called_with(*args, **kwargs) |
最近一次调用使用了指定参数 |
assert_called_once_with(*args, **kwargs) |
恰好调用一次且参数匹配 |
assert_any_call(*args, **kwargs) |
至少有一次调用使用了指定参数 |
assert_not_called() |
从未被调用 |
4.4 本章知识结构图
下面这张图把参数化测试、测试套件和 mock 的关系梳理了一下。你可以把它当作本章的「地图」:
4.5 实战建议
学完这些工具,我建议你从明天开始做三件事:
- 重构现有测试:把重复的测试函数改成参数化形式。你会发现测试文件能缩短一半。
- 建立测试套件分层:把测试分为
unit、integration、slow三类,用 pytest 标记区分。日常开发只跑 unit 测试,CI 里跑全部。 - 给外部依赖加 mock:凡是涉及硬件寄存器、文件系统、网络通信的代码,都加上 mock 测试。这样你不需要硬件也能跑测试。