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()

这里有个细节:setUptearDown 是每个测试方法都会执行一次。如果你有 100 个测试,串口就要开关 100 次。我在项目中遇到过这种情况,结果测试跑了半小时,大部分时间都浪费在开关串口上。

注意: 嵌入式设备往往有硬件状态依赖。如果每个测试都重新初始化,可能会丢失某些状态。比如你测完「进入低功耗模式」后,下一个测试需要先唤醒设备。这时候 setUp 里就不能简单粗暴地重新初始化了。

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 级别的,每个用例都重新创建并复位传感器。

核心原则: 尽量把「耗时且不改变状态」的操作放到高层级 Fixture 里,把「保证独立性」的操作放到低层级 Fixture 里。这样既快又稳。

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 设计,说白了就是「把脏活累活封装好,让测试用例干干净净」。下一章咱们聊聊更高级的测试数据驱动和报告生成,到时候你会发现,有了这些基础,后面的东西学起来会特别顺。