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)

你看,两个测试函数覆盖了七种情况。如果以后要加新的版本号格式,只需要往列表里加一行数据就行。

我的习惯:参数化数据如果超过 5 组,我会单独定义成一个列表变量,放在测试函数外面。这样数据量和测试逻辑分离开,维护起来更清爽。

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() 从未被调用
核心原则:mock 只用来隔离外部依赖,不要 mock 你正在测试的业务逻辑本身。如果你发现自己 mock 了业务代码里的内部函数,那说明测试的粒度可能有问题。

4.4 本章知识结构图

下面这张图把参数化测试、测试套件和 mock 的关系梳理了一下。你可以把它当作本章的「地图」:

Python单元测试进阶知识结构 单元测试进阶 参数化测试 测试套件 Mock对象 @parametrize 组合fixture 按目录/标记 pytest.ini Mock() @patch 核心目标:减少重复代码 + 隔离外部依赖 + 灵活组织测试 三者结合使用,才能写出可维护的嵌入式单元测试

4.5 实战建议

学完这些工具,我建议你从明天开始做三件事:

  1. 重构现有测试:把重复的测试函数改成参数化形式。你会发现测试文件能缩短一半。
  2. 建立测试套件分层:把测试分为 unit、integration、slow 三类,用 pytest 标记区分。日常开发只跑 unit 测试,CI 里跑全部。
  3. 给外部依赖加 mock:凡是涉及硬件寄存器、文件系统、网络通信的代码,都加上 mock 测试。这样你不需要硬件也能跑测试。
一个小技巧:写 mock 测试时,先写断言,再写 mock 的配置。也就是「先想好我要验证什么」,然后「再让 mock 返回我需要的数据」。这样思路更清晰,不容易漏掉验证点。