3、基础测试框架:unittest框架深度应用、pytest在嵌入式中的适配、测试夹具(Fixture)设计
说到嵌入式测试,很多人第一反应是「拿个示波器点两下」。但说实话,真正高效的测试,一定离不开自动化脚本。而脚本测试的根基,就是测试框架。
我个人习惯把测试框架比作「脚手架」。没有它,你也能测,但每次都要重新搭台子,累不累?有了它,你只管往上堆测试用例就行。今天咱们就聊聊 Python 生态里最常用的两个框架——unittest 和 pytest,以及它们怎么在嵌入式场景下落地。
3.1 unittest 框架深度应用
unittest 是 Python 自带的测试框架,说白了就是「正统军」。它结构严谨,但用起来有点啰嗦。不过,在嵌入式测试里,它的严谨反而是优势。
3.1.1 核心组件与执行流程
unittest 有四个核心概念:TestCase、TestSuite、TestRunner 和 Fixture。我简单解释一下:
- TestCase:最小的测试单元,你写的每个测试方法都在这里。
- TestSuite:把多个 TestCase 打包,方便批量执行。
- TestRunner:负责跑测试,并输出结果。
- Fixture:测试前后的准备和清理工作。
来看一个典型的嵌入式串口测试例子:
import unittest
import serial
class TestUARTCommunication(unittest.TestCase):
def setUp(self):
# 每次测试前打开串口
self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1)
self.ser.flushInput()
def tearDown(self):
# 测试完关闭串口
self.ser.close()
def test_send_and_receive(self):
test_data = b'Hello, MCU!'
self.ser.write(test_data)
response = self.ser.read(len(test_data))
self.assertEqual(response, test_data)
def test_baud_rate_switch(self):
# 测试切换波特率后能否正常通信
self.ser.baudrate = 9600
self.ser.write(b'ping')
response = self.ser.read(4)
self.assertEqual(response, b'pong')
if __name__ == '__main__':
unittest.main()
这里有个细节:setUp 和 tearDown 是每个测试方法都会执行一次。如果你有 100 个测试,串口就要开关 100 次。我在项目中遇到过这种情况,结果测试跑了半小时,大部分时间都浪费在开关串口上。
3.1.2 测试套件与批量执行
当测试用例多了,你需要组织它们。unittest 提供了 TestSuite 来干这事:
def suite():
suite = unittest.TestSuite()
# 添加单个测试
suite.addTest(TestUARTCommunication('test_send_and_receive'))
# 添加整个测试类
suite.addTest(unittest.makeSuite(TestUARTCommunication))
return suite
if __name__ == '__main__':
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite())
我个人习惯把不同功能模块的测试分到不同的 suite 里。比如串口一个 suite,I2C 一个 suite,GPIO 一个 suite。这样跑的时候可以灵活选择,不用每次都跑全量。
3.2 pytest 在嵌入式中的适配
pytest 比 unittest 灵活得多。它不需要继承任何类,直接用函数就能写测试。而且它的 fixture 机制比 unittest 强大太多。
但问题来了——pytest 默认是给纯软件项目用的。嵌入式测试往往需要跟硬件打交道,比如烧录固件、复位设备、等待启动。这些操作在 pytest 里怎么适配?
3.2.1 自定义 pytest 插件
我建议你写一个简单的 pytest 插件,把硬件操作封装进去。举个例子:
# conftest.py
import pytest
import serial
import time
def pytest_addoption(parser):
parser.addoption("--port", action="store", default="/dev/ttyUSB0",
help="串口设备路径")
parser.addoption("--baud", action="store", default=115200,
help="波特率")
@pytest.fixture(scope="session")
def serial_port(request):
port = request.config.getoption("--port")
baud = request.config.getoption("--baud")
ser = serial.Serial(port, baud, timeout=1)
yield ser
ser.close()
@pytest.fixture(scope="function")
def reset_device(serial_port):
# 通过 DTR 引脚复位设备
serial_port.dtr = False
time.sleep(0.1)
serial_port.dtr = True
time.sleep(2) # 等待设备启动
yield
你看,serial_port 这个 fixture 是 session 级别的,整个测试会话只打开一次串口。而 reset_device 是 function 级别的,每个测试前都复位一次设备。这样既节省了时间,又保证了测试的独立性。
scope="module" 可以做到每个测试模块只初始化一次。比如你一个文件里测了 10 个 I2C 读写函数,它们共用一个 I2C 总线对象,用 module 级别就很合适。
3.2.2 参数化测试
嵌入式测试里,经常要测不同参数组合。比如测不同波特率下的通信稳定性。pytest 的参数化功能简直是神器:
import pytest
@pytest.mark.parametrize("baudrate", [9600, 19200, 38400, 115200])
@pytest.mark.parametrize("data_len", [1, 64, 256, 1024])
def test_uart_stress(serial_port, baudrate, data_len):
serial_port.baudrate = baudrate
test_data = b'A' * data_len
serial_port.write(test_data)
response = serial_port.read(data_len)
assert response == test_data
这段代码会生成 4×4=16 个测试用例。你想想看,如果用手动写,得复制粘贴多少遍?而且 pytest 会为每个组合生成独立的测试报告,哪个组合失败了,一目了然。
我曾经用这个功能测过一个 WiFi 模块,参数组合有 200 多种。跑了一整夜,第二天早上看报告,发现某个特定信道+特定速率组合下丢包率异常。要是没有参数化,这种边界问题根本发现不了。
3.3 测试夹具(Fixture)设计
Fixture 是测试框架的灵魂。说白了,它就是「测试前后的那些事」。在嵌入式测试里,Fixture 设计得好不好,直接决定了测试的稳定性和可维护性。
3.3.1 Fixture 的层次结构
我一般把 Fixture 分成三个层次:
| 层次 | 作用域 | 典型操作 |
|---|---|---|
| 全局 Fixture | Session | 上电、烧录固件、建立网络连接 |
| 模块 Fixture | Module | 打开总线(I2C/SPI/UART)、初始化外设 |
| 用例 Fixture | Function | 复位设备、清除缓冲区、设置初始状态 |
举个例子,假设你要测试一个温湿度传感器:
@pytest.fixture(scope="session")
def power_supply():
"""全局:给设备上电"""
ps = PowerSupply()
ps.turn_on(voltage=3.3)
yield ps
ps.turn_off()
@pytest.fixture(scope="module")
def i2c_bus(power_supply):
"""模块:初始化 I2C 总线"""
bus = I2C('/dev/i2c-1')
yield bus
bus.close()
@pytest.fixture(scope="function")
def sensor(i2c_bus):
"""用例:创建传感器对象并复位"""
sht = SHT30(i2c_bus, address=0x44)
sht.reset()
yield sht
这里有个关键点:power_supply 是 session 级别的,整个测试只开关一次电源。i2c_bus 是 module 级别的,一个测试文件里所有用例共用一条总线。sensor 是 function 级别的,每个用例都重新创建并复位传感器。
3.3.2 避坑指南:Fixture 的依赖管理
嗯,这里要注意。Fixture 之间可以有依赖关系,但千万别搞成循环依赖。我曾经在一个项目里,A fixture 依赖 B,B 又依赖 A,结果 pytest 直接报错,排查了半天才发现。
另外,Fixture 的 teardown 部分一定要健壮。比如你打开了一个串口,但测试过程中设备掉线了,teardown 里关闭串口时可能会抛异常。我建议这样处理:
@pytest.fixture
def serial_port():
ser = None
try:
ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1)
yield ser
finally:
if ser and ser.is_open:
try:
ser.close()
except Exception:
pass # 设备可能已经断开,忽略错误
用 try...finally 保证无论测试是否通过,串口都会被尝试关闭。这是我在一次「测试跑完后发现串口被占用了三天」的惨痛教训中学到的。
3.3.3 动态 Fixture 与条件跳过
有时候,你需要根据硬件状态动态决定是否执行某些测试。pytest 的 skipif 配合 Fixture 可以做到:
@pytest.fixture
def firmware_version(serial_port):
serial_port.write(b'version\n')
version = serial_port.readline().decode().strip()
return version
def test_new_feature(firmware_version):
if firmware_version < "2.0.0":
pytest.skip("该特性需要固件版本 >= 2.0.0")
# 执行新特性的测试...
这样,你的测试脚本就能自动适配不同版本的固件。不用手动去改代码,也不用担心跑出莫名其妙的失败。
好了,关于基础测试框架,咱们就聊这么多。unittest 适合「规矩多、要求稳」的场景,pytest 适合「灵活多变、追求效率」的场景。而 Fixture 设计,说白了就是「把脏活累活封装好,让测试用例干干净净」。下一章咱们聊聊更高级的测试数据驱动和报告生成,到时候你会发现,有了这些基础,后面的东西学起来会特别顺。