第10章 质量保障:pytest与自动化测试流水线#
10.1 场景与痛点:改了通讯解析逻辑,结果把老协议改挂了怎么办?#
一个真实的噩梦#
周五下午五点半,你刚优化完串口协议解析函数——新版本协议增加了扩展字段,你把 parse_frame() 函数改了几行。本地跑了一下,新协议解析正常,于是你信心满满地提交了代码。
周一早上,产线打来电话:“测试工装全挂了。”
排查了两个小时,你发现问题出在你改的那几行代码上——为了兼容新协议的扩展字段,你无意中破坏了对老协议 V1.0 帧尾校验的处理逻辑。老协议的设备还在批量生产,产线用的正是 V1.0。
这就是没有自动化回归测试的代价。 (citation:5)
在嵌入式开发中,这种问题尤其致命:
- 协议版本共存:同一套代码可能需要同时支持 V1.0、V1.5、V2.0 多个版本的硬件设备
- 硬件依赖强:测试往往依赖真实的串口、DLL、网络设备,手动测试成本极高
- 改动波及面广:一个底层解析函数被上层十几个模块调用,改一处可能影响全局
- 调试困难:嵌入式 bug 往往是”时有时无”的,没有好的测试覆盖就很难复现
自动化测试能解决什么?#
想象一下,如果每次你修改代码后,只需要敲一个命令:
pytest tests/bash几秒钟后,屏幕上出现一行行绿色的 PASSED——这意味着你的代码通过了所有版本协议的解析验证、边界条件检查、异常帧处理测试。如果有任何一个用例失败了,它会精确告诉你:哪组数据出了问题、期望值是什么、实际值是什么。
这就是自动化测试的核心价值:用机器的确定性,对抗人工验证的不确定性。
本章将带你从零开始,用 pytest 搭建一套专为嵌入式场景设计的自动化测试体系。不需要你有任何测试框架的使用经验——我们从”为什么要用 pytest”开始讲起,手把手带你写出每一个测试用例。
10.2 技术选型:为什么 pytest 是 Python 测试的绝对王者#
pytest vs unittest:一眼看出差距#
Python 标准库自带 unittest,但几乎所有现代 Python 项目都选择了 pytest。为什么?我们直接看代码对比。
用 unittest 测试一个协议解析函数:
import unittest
class TestProtocolParser(unittest.TestCase):
def setUp(self):
self.parser = ProtocolParser()
def test_normal_frame_v1(self):
frame = b'\xAA\x01\x02\x03\x55'
result = self.parser.parse(frame)
self.assertEqual(result['version'], 1)
self.assertEqual(result['data'], b'\x02\x03')
def test_normal_frame_v2(self):
frame = b'\xAA\x01\x02\x03\x04\x55'
result = self.parser.parse(frame)
self.assertEqual(result['version'], 2)
self.assertEqual(result['data'], b'\x02\x03\x04')
if __name__ == '__main__':
unittest.main()python用 pytest 测试同一个函数:
def test_normal_frame_v1():
frame = b'\xAA\x01\x02\x03\x55'
result = ProtocolParser().parse(frame)
assert result['version'] == 1
assert result['data'] == b'\x02\x03'
def test_normal_frame_v2():
frame = b'\xAA\x01\x02\x03\x04\x55'
result = ProtocolParser().parse(frame)
assert result['version'] == 2
assert result['data'] == b'\x02\x03\x04'python差距一目了然(citation:5):
| 对比维度 | unittest | pytest |
|---|---|---|
| 代码风格 | 必须写类,必须继承 TestCase | 普通函数即可,零样板代码 |
| 断言方式 | assertEqual、assertTrue 等一堆方法 | 原生 assert,失败时自动展开详细信息 |
| 测试发现 | 需要 if __name__ 或命令行指定 | 自动发现所有 test_ 开头的函数 |
| 参数化 | 需要第三方库或手写循环 | 内置 @pytest.mark.parametrize,一行搞定 |
| 插件生态 | 几乎没有 | 超过 800 个插件(HTML报告、覆盖率、并行执行……) |
| 学习成本 | 需要记大量 assertXxx 方法名 | 只需要记住 assert |
选型结论:pytest 是不二之选。 (citation:2)
安装 pytest#
pip install pytest
# 如果需要 HTML 测试报告,再装一个插件
pip install pytest-htmlbash小贴士:如果你用的是 uv(第2章推荐的包管理工具),命令是
uv add pytest pytest-html,安装速度更快。
第一个测试:验证你装好了#
创建一个文件 test_demo.py:
def test_hello():
assert 1 + 1 == 2python在终端运行:
pytest test_demo.py -vbash看到 1 passed 就说明一切就绪。-v 参数表示 verbose(详细模式),会显示每个测试用例的名称和结果。
10.3 核心方法论:数据驱动与 Fixture 复用#
这一节是本章的重中之重。我们将学习 pytest 最强大的两个武器:参数化(数据驱动测试) 和 Fixture(测试夹具)。掌握它们,你就能用最少的代码覆盖最多的测试场景(citation:2)。
10.3.1 极简断言:pytest 的杀手锏#
pytest 最让人舒服的一点是:你只需要会写 assert。
def test_parse_version():
parser = ProtocolParser()
frame = b'\xAA\x02\x10\x55'
result = parser.parse(frame)
assert result['version'] == 2 # 版本号
assert result['cmd'] == 0x10 # 命令字
assert result['error_code'] == 0 # 无错误python如果断言失败,pytest 会自动给你一份详细的”事故报告”:
> assert result['version'] == 2
E assert 1 == 2
E +1
E -2plaintext它会告诉你:期望值是 2,实际值是 1。不需要你记任何特殊方法名,assert 就是全部。
对比 unittest,你需要记住 assertEqual、assertIn、assertRaises、assertIsNone 等几十个方法名。而 pytest 用原生 Python 语法就能完成所有断言(citation:2)。
10.3.2 数据驱动测试:@pytest.mark.parametrize#
什么是数据驱动?#
回到我们开头的噩梦场景——你需要测试一个协议解析函数,它要兼容 V1.0、V1.5、V2.0 三个版本。每个版本有不同的帧结构、不同的校验方式。
最笨的办法是每个版本写一个测试函数:
def test_parse_v1():
...
def test_parse_v1_5():
...
def test_parse_v2():
...python这不仅代码冗余,而且维护困难——如果解析逻辑变了,你得改三个地方(citation:2)。
数据驱动测试的核心思想是:让相同的业务逻辑脚本在不同的数据输入前提下完成用例测试,实现测试数据与测试行为的完全分离(citation:2)。
@pytest.mark.parametrize 基础用法#
import pytest
@pytest.mark.parametrize("input, expected", [
(2, 4), # 用例1: 2的平方是4
(3, 9), # 用例2: 3的平方是9
(4, 16), # 用例3: 4的平方是16
])
def test_square(input, expected):
assert input ** 2 == expectedpython这段代码会生成 3 个独立的测试用例,分别验证每组输入输出(citation:2)。运行结果:
test_demo.py::test_square[2-4] PASSED
test_demo.py::test_square[3-9] PASSED
test_demo.py::test_square[4-16] PASSEDplaintext参数详解 (citation:2):
| 参数 | 说明 |
|---|---|
argnames | 参数名称,字符串或字符串列表,多个参数用逗号分隔 |
argvalues | 参数值列表,可迭代对象,每个元素代表一组参数 |
ids | 测试用例标识符列表,用于报告中区分不同参数集 |
scope | 参数化作用域(function / class / module / session) |
实战:用参数化覆盖协议多版本兼容性测试#
这是嵌入式场景中最实用的模式——同一套解析函数,一次性传入 V1.0 / V2.0 / V3.0 的样本数据及预期结果,全面覆盖回归(citation:5):
import pytest
from my_protocol import parse_frame
# 定义各版本协议的测试数据
PROTOCOL_TEST_CASES = [
# (用例描述, 输入帧, 期望结果字典)
(
"V1.0 正常帧",
b'\xAA\x01\x02\x03\x55', # V1.0: 帧头AA + 版本01 + 数据 + 帧尾55
{"version": 1, "data": b'\x02\x03', "valid": True}
),
(
"V1.0 空数据帧",
b'\xAA\x01\x55', # V1.0: 只有帧头、版本、帧尾
{"version": 1, "data": b'', "valid": True}
),
(
"V2.0 带校验和的正常帧",
b'\xAA\x02\x02\x03\x05\x55', # V2.0: 增加了校验和字段
{"version": 2, "data": b'\x02\x03', "valid": True}
),
(
"V2.0 校验和错误帧",
b'\xAA\x02\x02\x03\xFF\x55', # 校验和不对
{"version": 2, "data": None, "valid": False}
),
(
"V3.0 带扩展头的帧",
b'\xAA\x03\x00\x04\x02\x03\x04\x05\x09\x55', # V3.0: 增加了长度字段
{"version": 3, "data": b'\x02\x03\x04\x05', "valid": True}
),
]
@pytest.mark.parametrize(
"description, frame, expected",
PROTOCOL_TEST_CASES,
ids=[case[0] for case in PROTOCOL_TEST_CASES] # 用描述作为用例名称
)
def test_protocol_multi_version(description, frame, expected):
"""协议多版本兼容性测试 - 一套逻辑覆盖所有版本"""
result = parse_frame(frame)
assert result['version'] == expected['version']
assert result['valid'] == expected['valid']
if expected['valid']:
assert result['data'] == expected['data']python运行效果:
test_protocol.py::test_protocol_multi_version[V1.0 正常帧] PASSED
test_protocol.py::test_protocol_multi_version[V1.0 空数据帧] PASSED
test_protocol.py::test_protocol_multi_version[V2.0 带校验和的正常帧] PASSED
test_protocol.py::test_protocol_multi_version[V2.0 校验和错误帧] PASSED
test_protocol.py::test_protocol_multi_version[V3.0 带扩展头的帧] PASSEDplaintext这就是参数化对协议多版本兼容性测试的核心价值:你只需要维护一个测试数据列表,新增版本时只需在列表里加一行数据,测试逻辑完全不用动(citation:2)。
使用 ids 提升测试报告可读性#
注意上面代码中的 ids 参数——它让你的测试报告从难以理解的 [b'\\xAA\\x01\\x02\\x03\\x55'-dict] 变成人人都能看懂的 [V1.0 正常帧](citation:2):
@pytest.mark.parametrize(
"input_value, expected",
[
("hello", 5),
("", 0),
(" ", 3),
],
ids=[
"普通字符串长度测试",
"空字符串长度测试",
"空格字符串长度测试",
]
)
def test_string_length(input_value, expected):
actual_length = len(input_value)
assert actual_length == expectedpython建议:所有参数化用例都加上 ids,尤其是给团队其他成员看的时候,这个小细节能大幅降低沟通成本。
多参数组合:嵌套参数化#
有时候你需要测试”所有参数的排列组合”。比如加密算法测试——每种算法 × 每种密钥长度 × 每种模式,都要验证一遍(citation:1):
@pytest.mark.parametrize("algorithm", ["aes", "des", "3des"])
@pytest.mark.parametrize("key_size", [128, 256])
@pytest.mark.parametrize("mode", ["cbc", "ecb"])
def test_encryption(algorithm, key_size, mode):
assert encrypt(algorithm, key_size, mode) is not Nonepython这会生成 3 × 2 × 2 = 12 个测试用例(citation:1)。嵌入式场景中,这在测试不同波特率 × 不同数据位 × 不同停止位的串口配置时非常有用。
⚠️ 注意:参数组合数量呈指数增长时,测试执行时间会显著上升。10个参数各取5种值将生成 5¹⁰ ≈ 976万个测试实例(citation:1)。建议在组合爆炸时,使用
pytest.mark.parametrize的ids参数筛选关键组合,或使用pytest-xdist插件并行执行。
从外部文件加载测试数据#
实际项目中,测试数据往往存在外部文件里(JSON、YAML、Excel),而不是硬编码在代码中(citation:3)(citation:5)。这样做的好处是:
- 非技术人员也能维护:测试工程师可以直接编辑 YAML 文件添加测试用例
- 数据与代码分离:修改数据不需要改动测试脚本(citation:5)
- 便于版本管理:数据文件和代码文件可以独立演进
从 YAML 文件加载测试数据 (citation:5):
# test_data/protocol_cases.yaml
- case_id: PROTO_V1_001
description: "V1.0 正常帧"
frame_hex: "AA01020355"
expected:
version: 1
data_hex: "0203"
valid: true
- case_id: PROTO_V2_001
description: "V2.0 带校验和正常帧"
frame_hex: "AA0202030555"
expected:
version: 2
data_hex: "0203"
valid: trueyamlimport yaml
import pytest
import os
def load_yaml_test_data(file_name):
"""从 YAML 文件加载测试数据"""
file_path = os.path.join(os.path.dirname(__file__), 'test_data', file_name)
with open(file_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
# 加载测试数据
PROTOCOL_CASES = load_yaml_test_data('protocol_cases.yaml')
@pytest.mark.parametrize(
"case",
PROTOCOL_CASES,
ids=[case['description'] for case in PROTOCOL_CASES]
)
def test_protocol_from_yaml(case):
"""从 YAML 文件驱动的协议测试"""
frame = bytes.fromhex(case['frame_hex'])
expected = case['expected']
result = parse_frame(frame)
assert result['version'] == expected['version']
assert result['valid'] == expected['valid']python注意:YAML 文件对缩进非常敏感,务必使用空格而非 Tab 键(citation:5)。建议在编辑器中安装 YAML 插件进行语法校验。
10.3.3 Fixture 复用:把硬件依赖变成可替换的零件#
什么是 Fixture?#
在嵌入式测试中,你经常需要准备一些”前置条件”:
- 打开一个串口连接
- 初始化一个 DLL 句柄
- 建立一个 TCP 连接
- Mock 一个硬件设备的响应
这些准备工作在每个测试用例里都写一遍?太冗余了。而且更关键的是——你在跑单元测试的时候,手边可能根本没有硬件设备。
Fixture(测试夹具)就是 pytest 提供的”测试准备与清理”机制。 它可以:
- 在测试前自动准备好资源(setup)
- 在测试后自动清理资源(teardown)
- 在多个测试用例间共享和复用
基础 Fixture 用法#
import pytest
@pytest.fixture
def protocol_parser():
"""创建一个协议解析器实例"""
parser = ProtocolParser()
return parser
def test_parse_v1(protocol_parser): # 参数名与 fixture 名一致,pytest 自动注入
frame = b'\xAA\x01\x02\x03\x55'
result = protocol_parser.parse(frame)
assert result['version'] == 1
def test_parse_v2(protocol_parser):
frame = b'\xAA\x02\x02\x03\x05\x55'
result = protocol_parser.parse(frame)
assert result['version'] == 2python看到没有?test_parse_v1 和 test_parse_v2 的参数里写了 protocol_parser,pytest 就会自动调用对应的 fixture 函数,把返回值传进来。你不需要手动调用 ProtocolParser()——Fixture 帮你做了(citation:1)。
Fixture 的作用域(scope)#
Fixture 有一个重要概念叫”作用域”——它决定了一个 fixture 实例会在多大范围内被复用(citation:1):
| 作用域 | 说明 | 适用场景 |
|---|---|---|
function(默认) | 每个测试函数创建一个新实例 | 需要隔离状态的测试 |
class | 同一个测试类中共享 | 类级别的测试共享资源 |
module | 同一个模块(.py 文件)中共享 | 数据库连接、串口连接 |
session | 整个测试会话只创建一次 | DLL 句柄、全局配置 |
对于嵌入式场景,最常用的是 module 和 session 级别(citation:1):
@pytest.fixture(scope="module")
def serial_connection():
"""模块级别的串口连接 - 整个模块只打开一次"""
import serial
conn = serial.Serial('/dev/ttyUSB0', 115200, timeout=1)
yield conn # yield 前是 setup,yield 后是 teardown
conn.close() # 测试结束后自动关闭
@pytest.fixture(scope="session")
def dll_handle():
"""会话级别的 DLL 句柄 - 整个测试过程只加载一次"""
import ctypes
dll = ctypes.CDLL("MyDevice.dll")
yield dll
# DLL 通常不需要显式释放,但可以在这里做清理pythonyield 的妙用:yield 之前的代码在测试前执行(setup),yield 的值会传给测试函数,yield 之后的代码在测试结束后执行(teardown)。这比 unittest 的 setUp/tearDown 更直观(citation:1)。
用 Fixture 模拟硬件:Mock 的力量#
这是嵌入式测试中最关键的技巧——你不需要真实的硬件设备也能测试通讯逻辑。
假设你有一个通过串口发送命令并解析响应的函数:
# my_device.py
import serial
class MyDevice:
def __init__(self, port):
self.ser = serial.Serial(port, 115200, timeout=1)
def get_temperature(self):
"""发送读取温度命令,返回解析后的温度值"""
self.ser.write(b'\xAA\x01\x55') # 发送读温度命令
response = self.ser.read(7) # 读取7字节响应
# 解析:帧头(1) + 命令(1) + 温度数据(2) + 校验(1) + 帧尾(2)
if response[0] != 0xAA or response[-1] != 0x55:
raise ValueError("帧格式错误")
temp_raw = (response[2] << 8) | response[3]
return temp_raw / 10.0 # 返回实际温度python要测试 get_temperature(),你需要一个”假的串口”。用 Fixture + Mock 实现:
# test_device.py
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def mock_device():
"""创建一个使用 Mock 串口的设备实例"""
device = MyDevice.__new__(MyDevice) # 不调用 __init__,避免真正打开串口
device.ser = MagicMock() # 用 Mock 替代真实的 serial.Serial
return device
def test_get_temperature_normal(mock_device):
"""测试正常温度读取"""
# 设定 Mock 串口的返回值:模拟硬件响应
mock_device.ser.read.return_value = b'\xAA\x01\x00\xFA\xXX\xYY\x55' # 250 = 25.0°C
# 注:上面的 XX YY 是校验字节,根据实际协议计算
temp = mock_device.get_temperature()
assert temp == 25.0
mock_device.ser.write.assert_called_once_with(b'\xAA\x01\x55') # 验证确实发送了正确命令
def test_get_temperature_frame_error(mock_device):
"""测试帧格式错误的情况"""
mock_device.ser.read.return_value = b'\xFF\x00\x00\x00\x00\x00\x00' # 错误的帧头
with pytest.raises(ValueError, match="帧格式错误"):
device.get_temperature()python核心思路:通过 Mock,你把”串口硬件”变成了一个可以随意控制的”假对象”。想让它返回什么数据就返回什么数据,想让它超时就超时,想让它报错就报错(citation:5)。这样你的测试:
- 不需要真实硬件:CI/CD 流水线也能跑
- 速度快:没有真实的 I/O 等待
- 可控性强:可以精确模拟各种异常场景
参数化 Fixture:一个 Fixture 多种配置#
Fixture 本身也可以参数化,这让它变得更加强大(citation:1):
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db_engine(request):
"""参数化数据库连接配置"""
return f"{request.param}_engine"python在嵌入式场景中,这个特性特别适合测试”同一个接口在不同硬件配置下的行为”:
@pytest.fixture(params=[9600, 115200, 921600])
def baud_rate(request):
"""参数化串口波特率"""
return request.param
@pytest.fixture(params=[8, 7])
def data_bits(request):
"""参数化数据位"""
return request.param
def test_serial_config(baud_rate, data_bits):
"""测试不同串口配置下的通讯"""
config = SerialConfig(baud_rate=baud_rate, data_bits=data_bits)
assert config.validate() is Truepython这会自动测试 3 × 2 = 6 种串口配置组合(citation:1)。
Fixture 的依赖链#
Fixture 可以依赖其他 Fixture,形成清晰的调用链(citation:1):
@pytest.fixture(scope="session")
def dll_handle():
"""第一层:加载 DLL"""
import ctypes
return ctypes.CDLL("MyDevice.dll")
@pytest.fixture(scope="module")
def device_connection(dll_handle):
"""第二层:通过 DLL 建立设备连接(依赖 dll_handle)"""
handle = dll_handle.OpenDevice(0)
yield handle
dll_handle.CloseDevice(handle)
@pytest.fixture
def device_with_config(device_connection):
"""第三层:配置设备参数(依赖 device_connection)"""
device_connection.set_baudrate(115200)
device_connection.set_timeout(1000)
return device_connection
def test_read_firmware_version(device_with_config):
"""测试读取固件版本(自动获得配置好的设备)"""
version = device_with_config.read_firmware_version()
assert version is not Nonepythonpytest 会自动按照依赖顺序初始化和清理资源(citation:1)。你不需要手动管理”先加载 DLL → 再打开设备 → 再配置参数”的顺序——Fixture 链帮你搞定了。
解决 Fixture 循环依赖#
当项目变复杂时,Fixture 之间可能形成循环依赖(A 依赖 B,B 又依赖 A)。推荐的解决方案是”依赖注入 + 懒加载”——通过外部容器管理 Fixture 生命周期,避免直接嵌套调用(citation:1)。
实用建议:如果遇到循环依赖,先审视你的测试架构设计。大多数情况下,循环依赖意味着你的职责划分有问题。把公共部分提取到一个独立的 Fixture 中,通常就能打破循环。
10.4 核心实战:为串口协议解析函数编写完整测试#
现在我们把前面学到的知识整合起来,为第4章的”串口协议解析函数”编写一套完整的自动化测试(citation:5)。
10.4.1 项目结构#
project/
├── src/
│ └── protocol_parser.py # 被测代码
├── tests/
│ ├── conftest.py # 公共 Fixture
│ ├── test_data/
│ │ └── protocol_cases.yaml # 测试数据
│ ├── test_parse_normal.py # 正常帧测试
│ ├── test_parse_error.py # 异常帧测试
│ └── test_parse_boundary.py # 边界条件测试
├── pytest.ini # pytest 配置
└── requirements.txtplaintext10.4.2 pytest 配置文件#
# pytest.ini
[pytest]
testpaths = tests
addopts = -v --tb=short --html=reports/test_report.html --self-contained-html
markers =
slow: 标记慢速测试(可能需要真实硬件)
v1: V1.0协议相关测试
v2: V2.0协议相关测试ini10.4.3 公共 Fixture(conftest.py)#
conftest.py 是 pytest 的特殊文件,放在里面的所有 Fixture 会自动被同目录及子目录下的测试文件共享——你不需要手动 import(citation:5):
# tests/conftest.py
import pytest
import struct
from unittest.mock import MagicMock
# ========== 协议帧构造工具 ==========
@pytest.fixture
def frame_builder():
"""协议帧构造器 - 方便生成各种测试帧"""
class FrameBuilder:
@staticmethod
def v1_frame(cmd, data=b''):
"""构造 V1.0 协议帧:帧头(0xAA) + 版本(0x01) + 命令 + 数据 + 帧尾(0x55)"""
return b'\xAA\x01' + bytes([cmd]) + data + b'\x55'
@staticmethod
def v2_frame(cmd, data=b''):
"""构造 V2.0 协议帧:增加校验和"""
raw = b'\xAA\x02' + bytes([cmd]) + data
checksum = sum(raw) & 0xFF
return raw + bytes([checksum]) + b'\x55'
@staticmethod
def v3_frame(cmd, data=b''):
"""构造 V3.0 协议帧:增加长度字段"""
length = len(data)
raw = b'\xAA\x03' + struct.pack('>H', length) + bytes([cmd]) + data
checksum = sum(raw) & 0xFF
return raw + bytes([checksum]) + b'\x55'
@staticmethod
def corrupt_frame(original, bit_pos=0):
"""损坏帧的某一位"""
frame = bytearray(original)
frame[bit_pos] ^= 0xFF # 翻转所有位
return bytes(frame)
return FrameBuilder()
# ========== Mock 硬件设备 ==========
@pytest.fixture
def mock_serial():
"""Mock 串口对象"""
ser = MagicMock()
ser.is_open = True
ser.timeout = 1
return ser
@pytest.fixture
def mock_device(mock_serial):
"""Mock 设备实例"""
from src.protocol_parser import DeviceProtocol
device = DeviceProtocol.__new__(DeviceProtocol)
device.serial = mock_serial
return devicepython10.4.4 测试用例:正常帧解析#
# tests/test_parse_normal.py
import pytest
from src.protocol_parser import parse_frame
class TestNormalFrameParsing:
"""正常帧解析测试"""
def test_v1_basic_frame(self, frame_builder):
"""V1.0 基本帧解析"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\x02\x03')
result = parse_frame(frame)
assert result['version'] == 1
assert result['cmd'] == 0x01
assert result['data'] == b'\x02\x03'
assert result['valid'] is True
def test_v1_empty_data_frame(self, frame_builder):
"""V1.0 空数据帧"""
frame = frame_builder.v1_frame(cmd=0x00)
result = parse_frame(frame)
assert result['data'] == b''
assert result['valid'] is True
@pytest.mark.v2
def test_v2_with_checksum(self, frame_builder):
"""V2.0 带校验和帧"""
frame = frame_builder.v2_frame(cmd=0x10, data=b'\x01\x02\x03')
result = parse_frame(frame)
assert result['version'] == 2
assert result['valid'] is True
@pytest.mark.v2
def test_v2_max_data_length(self, frame_builder):
"""V2.0 最大长度数据帧"""
data = b'\x00' * 255 # 255字节数据
frame = frame_builder.v2_frame(cmd=0xFF, data=data)
result = parse_frame(frame)
assert result['valid'] is True
assert len(result['data']) == 255python10.4.5 测试用例:异常帧处理#
# tests/test_parse_error.py
import pytest
from src.protocol_parser import parse_frame
class TestErrorFrameHandling:
"""异常帧处理测试"""
def test_corrupted_header(self, frame_builder):
"""帧头损坏"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\x02')
corrupted = frame_builder.corrupt_frame(frame, bit_pos=0) # 破坏帧头
result = parse_frame(corrupted)
assert result['valid'] is False
def test_corrupted_tail(self, frame_builder):
"""帧尾损坏"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\x02')
corrupted = frame_builder.corrupt_frame(frame, bit_pos=-1) # 破坏帧尾
result = parse_frame(corrupted)
assert result['valid'] is False
def test_v2_checksum_mismatch(self):
"""V2.0 校验和不匹配"""
# 手动构造一个校验和错误的帧
bad_frame = b'\xAA\x02\x01\x02\xFF\x55' # 校验和故意设错
result = parse_frame(bad_frame)
assert result['valid'] is False
def test_too_short_frame(self):
"""过短帧(小于最小帧长度)"""
result = parse_frame(b'\xAA\x55')
assert result['valid'] is False
def test_completely_random_data(self):
"""完全随机数据"""
result = parse_frame(b'\x12\x34\x56\x78\x9A\xBC')
assert result['valid'] is False
def test_empty_input(self):
"""空输入"""
result = parse_frame(b'')
assert result['valid'] is Falsepython10.4.6 测试用例:边界条件#
# tests/test_parse_boundary.py
import pytest
from src.protocol_parser import parse_frame
class TestBoundaryConditions:
"""边界条件测试"""
def test_maximum_frame_size(self, frame_builder):
"""最大帧长度"""
data = b'\x00' * 1024 # 假设最大帧数据长度为1024
frame = frame_builder.v3_frame(cmd=0x01, data=data)
result = parse_frame(frame)
assert result['valid'] is True
def test_single_byte_data(self, frame_builder):
"""单字节数据"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\x00')
result = parse_frame(frame)
assert result['valid'] is True
assert result['data'] == b'\x00'
def test_all_zeros_data(self, frame_builder):
"""全零数据"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\x00\x00\x00\x00')
result = parse_frame(frame)
assert result['valid'] is True
def test_all_ff_data(self, frame_builder):
"""全0xFF数据"""
frame = frame_builder.v1_frame(cmd=0x01, data=b'\xFF\xFF\xFF\xFF')
result = parse_frame(frame)
assert result['valid'] is Truepython10.4.7 参数化驱动的多版本回归测试#
把所有版本的测试数据整合到一个参数化测试中(citation:2)(citation:3):
# tests/test_regression.py
import pytest
from src.protocol_parser import parse_frame
# 所有版本的关键测试用例汇聚于此
REGRESSION_CASES = [
# V1.0 用例
("V1.0 基本帧", b'\xAA\x01\x01\x02\x55', {"version": 1, "valid": True}),
("V1.0 空数据帧", b'\xAA\x01\x00\x55', {"version": 1, "valid": True}),
("V1.0 帧头错误", b'\xBB\x01\x01\x02\x55', {"valid": False}),
# V2.0 用例
("V2.0 正常帧", b'\xAA\x02\x01\x02\x03\x55', {"version": 2, "valid": True}),
("V2.0 校验错", b'\xAA\x02\x01\x02\xFF\x55', {"valid": False}),
# V3.0 用例
("V3.0 正常帧", b'\xAA\x03\x00\x02\x01\x02\x03\x08\x55', {"version": 3, "valid": True}),
]
@pytest.mark.parametrize(
"description, frame, expected",
REGRESSION_CASES,
ids=[case[0] for case in REGRESSION_CASES]
)
def test_protocol_regression(description, frame, expected):
"""协议多版本回归测试 - 每次代码变更后必跑"""
result = parse_frame(frame)
assert result['valid'] == expected['valid']
if 'version' in expected:
assert result['version'] == expected['version']python这就是参数化对协议多版本兼容性测试的核心价值:新增协议版本时,你只需要在 REGRESSION_CASES 里追加几行数据,所有版本的回归验证一次跑完(citation:2)。
10.4.8 运行测试并生成 HTML 报告#
# 运行所有测试
pytest tests/ -v
# 只运行 V2.0 相关测试
pytest tests/ -m v2 -v
# 生成 HTML 测试报告
pytest tests/ --html=reports/report.html --self-contained-html
# 只运行上次失败的用例
pytest tests/ --lf -v
# 显示测试覆盖情况(需要 pip install pytest-cov)
pytest tests/ --cov=src --cov-report=htmlbashHTML 报告会生成一个自包含的 .html 文件,可以直接用浏览器打开,发给团队成员查看。报告中会详细列出每个用例的名称、状态(通过/失败/跳过)、执行时间、失败原因等信息(citation:4)。
10.4.9 有组织地运行测试:标记(Marker)#
当测试用例越来越多时,你需要分类管理:
import pytest
@pytest.mark.slow
def test_with_real_hardware():
"""需要真实硬件的慢速测试"""
...
@pytest.mark.v1
def test_v1_specific():
"""V1.0 协议特有功能"""
...python# 跳过慢速测试(CI 流水线常用)
pytest tests/ -m "not slow"
# 只运行 V1.0 相关测试
pytest tests/ -m v1
# 使用关键字过滤
pytest tests/ -k "温度 or temperature"bash10.5 AI 协作指南:让 AI 成为你的测试工程师#
10.5.1 把业务函数喂给 AI,让它”穷举边界条件”#
这是最实用的 AI 协作场景——你写好了一个函数,让 AI 帮你想”还有什么边界条件没想到”。
Prompt 模板:
我有一个嵌入式串口协议解析函数,函数签名和核心逻辑如下:
[粘贴你的函数代码]
请帮我:
1. 分析这个函数的所有边界条件和异常场景
2. 为每个场景生成 pytest 测试用例
3. 使用 @pytest.mark.parametrize 进行参数化
4. 包含以下类别:正常帧、校验错误、长度异常、版本不支持、数据越界
5. 所有测试用例使用中文 ids 描述plaintextAI 可能给出的边界条件清单(你需要审查):
- 空输入(
b'') - 只有帧头没有后续数据
- 帧长度字段与实际数据长度不匹配
- 版本号为 0 或超出支持范围
- 数据字段包含帧头/帧尾的特殊字节(转义处理)
- 最大长度和最小长度的极端情况
- 校验和为 0x00 和 0xFF 的边界值
- 连续快速发送的多帧数据粘包场景
审查重点清单:
| 审查项 | 关注点 |
|---|---|
| 断言完整性 | 是否验证了所有返回字段,而不只是 valid |
| 数据真实性 | 测试数据是否符合实际协议规范 |
| 异常处理 | 是否覆盖了 raise 的异常类型和消息 |
| Mock 合理性 | Mock 的行为是否模拟了真实硬件 |
10.5.2 进阶技巧:测试资产的智能迁移#
这是一个高级但极其有价值的场景——让 AI 分析旧版本协议的已有测试用例,自动推导并生成新版本协议的测试用例(citation:3)。
假设你正在把协议从 V2.0 升级到 V3.0,V3.0 增加了长度字段和扩展命令。你已经有了一套完整的 V2.0 测试用例:
Prompt 模板:
我有一套 V2.0 协议的 pytest 测试用例:
[粘贴现有 V2.0 测试用例代码]
V3.0 协议相对于 V2.0 的变化如下:
1. 帧头后新增 2 字节长度字段(大端序)
2. 新增命令 0x20(读取设备信息)和 0x21(重启设备)
3. 校验和算法从"单字节求和取低8位"改为"CRC16-CCITT"
请基于现有 V2.0 测试用例:
1. 自动推导生成 V3.0 的对应测试用例
2. 为新增命令设计测试用例(正常/异常/边界)
3. 更新帧构造方式以适配 V3.0 帧格式
4. 保留 V2.0 用例不删除(需要向后兼容测试)
5. 使用 pytest.mark 分别标记 v2 和 v3 用例plaintextAI 生成后你需要做的审查:
- 帧格式正确性:检查 AI 生成的帧是否符合 V3.0 协议文档
- 校验和计算:CRC16 的实现是否正确
- 新增用例覆盖:新增命令的边界条件是否充分
- 回归完整性:V2.0 的用例是否被保留且未被修改
这个技巧的核心价值:你不需要从零开始写新版本的测试——AI 帮你把”测试资产”从旧版本迁移到新版本,你只需要审查和修正。对于有几十上百个测试用例的项目,这能节省大量重复劳动。
10.5.3 让 AI 生成 Mock 对象#
嵌入式测试中,Mock 硬件是最费时间的部分。你可以让 AI 帮忙:
我有一个通过 DLL 控制硬件设备的 Python 类:
[粘贴你的代码]
请帮我编写 pytest 测试代码,要求:
1. 使用 unittest.mock 模拟 DLL 的所有调用
2. 模拟正常响应和各种错误响应(超时、错误码、数据异常)
3. 使用 @pytest.fixture 组织 Mock 对象
4. 使用 @pytest.mark.parametrize 覆盖不同场景
5. 验证每个测试用例都正确检查了 DLL 函数的调用参数plaintext本章小结#
让我们回顾一下本章学到的关键内容:
| 知识点 | 解决的工程痛点 |
|---|---|
| pytest 基础 | 比 unittest 更简洁的测试写法,零样板代码 |
@pytest.mark.parametrize | 数据驱动测试,一套逻辑覆盖多组数据(citation:2) |
参数化 + ids | 协议多版本兼容性测试,报告可读性(citation:1) |
| Fixture | 测试资源的准备与清理自动化(citation:1) |
| Fixture 作用域 | 控制资源创建频率,平衡隔离性与性能(citation:1) |
| Mock + Fixture | 无硬件条件下的单元测试(citation:5) |
| conftest.py | 跨文件共享 Fixture 和配置 |
| Marker | 测试用例分类与选择性执行 |
| HTML 报告 | 生成可视化的测试结果,便于团队协作 |
| AI 协作 | 穷举边界条件、测试资产迁移、Mock 生成 |
一句话总结:参数化让你用最少的代码覆盖最多的场景(citation:2),Fixture 让你把硬件依赖变成可替换的零件(citation:1),而 AI 帮你想”还有什么没想到”。
下一章,我们将讨论如何让你的脚本具备”自我报告”能力——崩溃时自动发钉钉报警、日志自动归档不撑爆硬盘、文档随代码同步更新。
动手练习:
- 为你正在开发的项目中任意一个函数,用
@pytest.mark.parametrize编写至少 5 组测试数据- 创建一个
conftest.py,把重复使用的初始化代码提取为 Fixture- 尝试用 Mock 替代一个真实的硬件依赖,验证你的代码在无硬件环境下也能跑通
- 运行
pytest --html=report.html,把报告发给你的同事看