做Pelco KBD300A模拟器做到第13个阶段我的第一个念头不是加功能而是把测试捋清楚。前12个阶段靠手工验证还能撑住无非是摇杆推一推、按键按一按、串口抓包看一眼。但功能越堆越多之后一个字节的改动可能影响协议帧、摇杆映射、串口状态机三块逻辑手工回归一遍要十几分钟而且人的眼睛真不一定盯得住校验和差一位的问题。所以到了“项目pytest自动化测试方案规划”这一步我其实是把自动化测试当作继续开发的必要基础设施而不是锦上添花。这篇文章就把我在这套模拟器项目里做的pytest测试规划、分层思路、Mock串口实现以及踩过的坑完整记录下来给同样做串口设备模拟器或嵌入式上位机的朋友一个参考。1. 为什么一个模拟器项目要单独做pytest测试方案1.1 模拟器项目做到第13步测试问题浮出水面KBD300A这个设备做监控的老工程师都熟它是Pelco当年很经典的一款控制键盘。正面一个大摇杆旁边密密麻麻的按键通过RS-422/RS-485串口把控制指令发给球机云台。模拟器要做的事情就是把这个键盘的操作逻辑搬到电脑上让没有实体键盘的调试场景也能通过软件发指令、控制实际设备或者进入虚拟仿真链路。项目走到第13个阶段模拟器的功能面已经铺得很开摇杆位移要映射成云台水平和垂直速度方向组合要转成Pelco-D或Pelco-P协议帧预置位设置、调用、清除这些按键指令要分别组装报文串口层还要处理粘包、半包、校验错误这些真实通信里一定会遇到的问题。这时候每次改动都有“牵一发动全身”的感觉。前12阶段我积累的手工测试点大概有60多条全过一遍至少20分钟遇到协议边界问题还要抓包分析效率确实扛不住了。更重要的是手工测试很难覆盖边界值。摇杆推到底和推到一半速度档位差多少设备地址是1和是255帧校验怎么变化这些用眼睛看很难测全。pytest自动化测试可以把这些组合全部参数化跑一遍每次改动代码后一条命令全量回归。这也是我决定在第13阶段把测试方案正式落地的根本原因功能越多越需要一套能确定性地证明“协议帧没破坏、映射逻辑没退化”的自动化防线。1.2 测试对象到底长什么样KBD300A的核心功能边界做测试方案之前必须先明确被测试对象的功能边界。KBD300A模拟器不是一个普通的“发串口数据的工具”它的核心功能可以拆成四个模块摇杆输入模块把摇杆在二维平面上的位置x, y坐标转换成水平和垂直方向的速度等级。摇杆推拉是模拟量要映射成协议里的0x00-0x3F速度档位这里还有死区、线性区和非线性区。按键事件模块处理预置位设置、调用、清除以及镜头变焦、聚焦、光圈等辅助按键。每个按键对应一组确定的协议命令。协议编解码模块构造和解析Pelco-D/Pelco-P帧。这个模块是测试的核心因为模拟器存在的基础就是能产出完全符合协议的字节流。串口通信模块跟真实的串口硬件打交道负责把帧写出去、收回来处理分包、粘包、校验错误、串口占用等异常。这四个模块的依赖关系是清晰的协议层不依赖串口映射层依赖协议层串口层依赖协议层。所以测试方案可以按依赖边界分层来做每一层都有明确、可自动化验证的输入输出。这也是后面所有pytest用例设计的出发点。1.3 pytest在Python测试框架里的生态优势和取舍选pytest而不是unittest这在我这里没有犹豫。unittest是标准库自带基本功能没问题但用例组织要靠类继承setUp/tearDown分散断言也只有assertEqual、assertTrue这一套写起来又啰嗦又难读。pytest把用例收敛成普通函数断言直接用Python的assertfixture机制比setUp/tearDown灵活得多参数化支持也强一个装饰器就能把命令矩阵铺开。还有一层考虑是生态。pytest-cov直接出覆盖率报告pytest-html能生成可分享的测试页面allure-pytest能出更美观的HTML报告pytest-xdist支持多进程跑用例。这些插件在项目后期对接CI、做测试看板时都是现成的。而且pytest对错误信息的展示非常细断言失败时会直接显示两个对象的实际值和期望值对排查协议帧这类二进制对比问题特别友好。当然pytest也不是没有代价。fixture的隐式查找规则新手第一次看到conftest.py里定义的fixture在别的文件里直接当参数用会觉得有点“魔法”。然后参数化用的ids如果设置不当用例名会非常难看。这些我都在这篇文章后面踩坑部分里写了算是给新人的提醒。2. 测试方案的总体架构与分层设计2.1 先分层再写用例四层测试结构我在设计测试方案的时候第一条原则就是“不能让测试依赖真实硬件”。模拟器项目要长期维护如果测试必须插一个真实的KBD300A或球机才能跑那CI根本没法落地开发机上换个环境可能就是红的。所以我把测试拆成四层每一层都可以在纯软件环境里跑测试层测试对象输入/输出是否需要真实串口L1 协议层Pelco-D/Pelco-P帧构造、解析、校验命令参数 - 字节帧不需要L2 映射层摇杆坐标/按键 - 命令参数摇杆位移、键值 - 速度、指令不需要L3 通信层串口收发逻辑、粘包半包处理Mock串口读写 - 状态变化不需要用Mock串口L4 端到端层模拟器整体收发循环虚拟串口对 - 收发帧校验需要虚拟串口可跳过这个分层的核心逻辑是每一层只验证自己负责的变换是否正确底层模块的错误不要等到最外层才暴露。协议帧构造错了在L1就能抓住没必要等到L3把帧写进串口才发现。L1、L2、L3是纯Python单测pytest在CI里直接跑L4用虚拟串口对Linux下可以用socat配ptyWindows下可以用com0com打上skipif标记允许无虚拟串口环境下跳过。L4其实不是每天都在CI里跑它主要用于发布前回归。我在项目里的做法是L1到L3一共跑完不到40秒L4因为涉及系统虚拟串口、多线程收发要慢得多。所以日常开发用前三层L4作为发布检查项。这样既保住了确定性验证又不会让每次改代码都陷入慢测试的泥潭。2.2 测试目录结构tests、data、fixtures怎么摆目录结构也是测试方案的一部分摆得清晰后面维护才不费劲。我采用的是标准的“项目根下放源码和tests平级”的布局kbd300a-emulator/ ├── kbd300a/ │ ├── __init__.py │ ├── protocol.py # Pelco-D/Pelco-P 编解码 │ ├── joystick.py # 摇杆坐标映射 │ ├── key_events.py # 按键事件处理 │ ├── serial_io.py # 串口通信 │ └── app.py # 模拟器主程序/状态机 ├── tests/ │ ├── conftest.py # fixture 和路径处理 │ ├── test_protocol_pelco_d.py │ ├── test_protocol_pelco_p.py │ ├── test_joystick.py │ ├── test_key_events.py │ ├── test_serial_io.py │ ├── test_app_state_machine.py │ └── data/ │ ├── pelco_d_cases.yaml │ └── pelco_p_cases.yaml ├── pytest.ini └── requirements-dev.txt为什么不用src布局这个项目规模不算大src布局的多一层路径配置对测试导入反而不友好尤其新手在conftest里改sys.path时很容易写错。项目根布局加一行sys.path.insert就能让tests直接导入kbd300a包。至于data目录单独放一份YAML用例数据是为了让测试数据和测试代码分离。协议帧要覆盖多少组合、哪个地址是合法的这些信息更适合放在数据文件里不会跟代码逻辑纠缠在一起想改测试点也不用动Python文件。pytest.ini里我做了三个关键配置testpaths限制收集范围避免pytest误收集到项目里其他目录addopts配置了-q、--tbshort、--disable-warnings让日常输出保持干净另外设置了junit_familyxunit2方便后面CI平台读取XML结果。这里提醒一句不要什么都往addopts里塞allure、cov这种插件有自己单独的参数混在一起会把命令行的可读性搞掉。2.3 fixture作用域设计的取舍fixture作用域是我在测试架构里花了比较多心思的地方因为模拟器里有状态的对象特别多。协议编码器是无状态的输入参数返回字节帧这种fixture可以放心用session作用域整个测试session只建一次。但串口Mock是有“内部状态”的它记录了写出的字节列表也维护着读缓冲区这个对象如果用session级那前面用例里残留的读写数据就会污染后面的用例。我一开始在这里吃过亏症状非常经典单独跑某个用例是绿的全量跑就会随机红掉一查全是共享状态的锅。所以我的fixture作用域策略很明确。无状态工具类用session比如协议编码器、校验和计算辅助函数。有状态但重置成本低的用function比如每个用例都新建一个串口Mock实例。重量级对象比如虚拟串口对、日志收集器用module作用域一个测试文件共享一次文件之间不共用。每个fixture的作用域选择其实是在回答一个问题“这个fixture的状态会不会影响另一个用例的判断”。会就function不会就往上提。这样还有一个额外的好处定好了作用域写用例的时候就不用再猜。看到用例签名里带mock_serial就知道它是新鲜的、干净的里面只有当前用例写入的数据。这种确定性对于协议类测试非常重要字节流比较本来就严格任何一点状态残留都会让你花大量时间去排查是代码bug还是测试污染。3. 协议测试用例的设计与实现3.1 先把Pelco-D/Pelco-P报文规范落实成编码器协议层是整个模拟器的地基协议层的测试用例也是最严谨的。Pelco-D是最常用的协议之一帧结构是7个字节同步字节0xFF、设备地址、命令1、命令2、数据1、数据2、校验和。校验和的规则是把前面6个字节相加取低8位也就是和值对256取模。注意这个“.取低8位”是很多人容易算错的地方如果手算用十六进制加法忘记截断校验和就对不上。Pelco-P则是9字节帧0xA0同步、地址高字节、地址低字节、4个数据字节、校验字节、0xAF结束。Pelco-P的校验规则略特殊是把中间6个字节求和后高4位和低4位分别合成一个校验字节。不同版本的设备在数据字节含义上有差异我的做法是先按项目固定的协议文档实现一个编码器把帧构造逻辑收敛到protocol.py里测试全部通过编码器生成期望帧再抽查几条手算验证。def build_pelco_d(address, cmd1, cmd2, data1, data2): frame bytes([0xFF, address, cmd1, cmd2, data1, data2]) checksum sum(frame[1:]) 0xFF return frame bytes([checksum])这个编码器看起来简单但它把“如何组帧”从业务代码里抽出来了后面摇杆模块、按键模块都调用它协议格式的改动只影响protocol.py一个文件。测试也一样期望帧通过编码器生成测试代码里不散落着一堆手写的硬编码字节。3.2 用参数化把命令矩阵跑干净协议测试的核心是把命令矩阵完整铺开。Pelco-D里云台控制命令通常包括停止、上仰、下俯、左转、右转以及左上、右上、左下、右下八方向组合预置位相关命令包括设置、调用、清除。速度档位0x00到0x3F常见档位是63档。地址范围一般从1到255。把这些维度组合起来用pytest参数化可以非常干净地生成用例import pytest from kbd300a.protocol import build_pelco_d pytest.mark.parametrize( address, cmd1, cmd2, data1, data2, [ (1, 0x00, 0x00, 0x00, 0x00), # stop (1, 0x00, 0x04, 0x3F, 0x00), # pan left full speed (1, 0x00, 0x08, 0x00, 0x3F), # tilt up full speed (0xFF, 0x00, 0x0C, 0x3F, 0x3F), # up-left both max (128, 0x00, 0x07, 0x05, 0x00), # call preset 5 ], ) def test_pelco_d_frame_build_and_checksum(address, cmd1, cmd2, data1, data2): frame build_pelco_d(address, cmd1, cmd2, data1, data2) assert frame[0] 0xFF assert frame[1] address assert sum(frame[1:6]) 0xFF frame[6]这里每一组参数生成的用例名都带上了具体的值。pytest默认的用例名是参数值的repr。用整数还好如果参数是bytes默认显示很糟糕所以我习惯给parametrize加ids参数比如“addr_1_pan_left_full”“addr_255_up_left_max”。测试报告里一眼能看出来哪一个组合挂了。这个习惯在几十上百条协议用例时特别值钱。边界值也别老盯着最大最小死区和临界值要单列用例。摇杆不可能完全在正中必然有轻微的漂移所以映射层要定义死区。协议测试里要覆盖速度值刚好在死区边界附近的情况比如0x01这类最小值以及0x00和0x01之间切换时帧内容是否稳定。地址255、地址1、地址0广播或保留都是协议层应有的测试点。3.3 异常输入与容错用例规划协议层的另一半测试是异常输入这部分经常被人忽略。真实串口环境里你不可能总是收到干干净净完整的一帧。模拟器要做到即使收到半包、粘包、错误校验和的帧也不能崩溃该等就等该丢就丢该报告就报告。这部分的用例规划我列了一个协议异常矩阵半包只收到一帧的前4个字节解析器不能误判为完整帧要继续等待剩余字节。粘包两个帧的字节连在一起收到解析器要能正确切出两帧。坏校验和故意篡改最后一个校验字节模拟器要识别出来并丢弃同时记录错误计数。未知命令码命令2字节不在已知命令集合里不能抛异常导致整个串口线程挂掉。非法地址地址为0或者在配置范围之外时可以选择忽略或记录但绝不能把无效帧发到总线上。这些用例的断言往往是“状态机最终停在预期的状态”而不是“返回了某个值”。我会构造一个真实的字节序列feeds到解析器里然后断言输出的帧列表长度、错误计数、状态。比如粘包用例的输入是两帧拼接bytes断言最终解析出两帧、且两帧内容分别等于期望帧。半包用例则是先feed前一半断言输出为空再feed后一半断言完整帧被解析出来。这一层用例写起来比正常路径要绕但它恰恰体现了模拟器的可靠性。做硬件协议模拟器不能只看“能不能发出正确的帧”还要看“收到了垃圾数据会不会保持优雅”。我后面的实际测试里好几个版本都是在异常帧处理这里被用例拦下来的手工测试很难覆盖到这么细。4. 实操conftest.py与Mock串口层落地4.1 一个能用的MockSerial要怎么写串口层的测试不能依赖真实串口所以我写了一个极简但够用的MockSerial。它和pyserial的接口保持兼容至少实现write、read、timeout和in_waiting这几个我们代码里用到的属性和方法。内部核心是一个bytearray作为读缓冲区write调用只是把字节追加到write_log列表里方便测试断言写出的内容class MockSerial: def __init__(self, timeout0.1): self._read_buffer bytearray() self.write_log [] self.timeout timeout def write(self, data: bytes) - int: self.write_log.append(bytes(data)) return len(data) def read(self, size: int 1) - bytes: if not self._read_buffer: return b n min(size, len(self._read_buffer)) data bytes(self._read_buffer[:n]) del self._read_buffer[:n] return data def feed(self, data: bytes): self._read_buffer.extend(data) def read_bytes(self) - bytes: return b.join(self.write_log)这里有个细节我要特别说明真实pyserial的read有个行为就是指定size可能返回不足size的字节数取决于缓冲区里当时有多少数据。所以我让Mock的read也按“能拿多少拿多少”的逻辑实现而不是非要凑满size。这样模拟器串口层代码在Mock和真实设备上行为才一致。如果Mock做得太理想化测试全绿但一接真实串口就慌那Mock就失去了意义。在模拟器代码里串口接收线程通常是循环调用serial.read(1)拼帧或者配合in_waiting做批量读取。MockSerial把这个流程完整支持起来feed一段数据进去再调用一下接收处理函数就能模拟一次完整的串口接收。断言的时候用read_bytes()取回所有由模拟器write出来的内容然后和期望帧bytes作严格比较。4.2 conftest.py里如何组织fixtureconftest.py是pytest里最核心的基础文件。我的conftest里主要有三类内容第一是导入路径处理第二是MockSerial和协议编码器这些公共fixture第三是控制端到端测试的全局配置。路径处理很简单项目根在conftest.py的父目录上两级的场景很常见用os.path和sys.path.append把源码目录加进sys.path这样测试文件里就能直接import kbd300a.protocol了。公共fixture我按作用域分开写import pytest from kbd300a.protocol import build_pelco_d, build_pelco_p from tests.mock_serial import MockSerial pytest.fixture(scopesession) def pelco_d_builder(): return build_pelco_d pytest.fixture def mock_serial(): return MockSerial() pytest.fixture def emulator_with_serial(mock_serial): from kbd300a.app import EmulatorApp app EmulatorApp(serial_iomock_serial) app.start() yield app app.stop()这里emulator_with_serial用的是function作用域每个用例都启动一个全新的EmulatorApp实例用最新的MockSerial用例结束就stop。这个设计让串口层的用例彼此完全隔离不存在残留状态。它带来的代价是每个用例都要做一次start/stop但串口层用例数量还好几十个用例跑完也就几秒钟完全可接受。还要写一个跳过L4端到端的标记。在conftest.py里定义一个pytest_collection_modifyitems钩子检查环境变量或虚拟串口是否存在如果不可用就给标记了e2e的用例加skip。我用的方案是环境变量KBD300A_E2E1才跑端到端用例默认跳过。这样CI配置里想跑就直接加环境变量不想跑默认也不会红。4.3 测试数据管理YAML与pytest参数化的联动协议用例的参数组合多了以后代码里的parametrize列表会变得很臃肿。我把测试数据挪到YAML文件里用pytest的parametrize间接参数化读取。以Pelco-D为例data/pelco_d_cases.yaml长这样- name: pan_left_full_speed address: 1 cmd1: 0x00 cmd2: 0x04 data1: 0x3F data2: 0x00 - name: up_left_both_max address: 255 cmd1: 0x00 cmd2: 0x0C data1: 0x3F data2: 0x3F然后在test_protocol_pelco_d.py里用一个helper读取所有YAML用例再通过pytest.mark.parametrize生成用例。这里有一个好用的技巧用yaml.safe_load读文件时0x3F这种字符串是作为字符串读出来的注意YAML 1.1的sexagesimal问题所以我读取后统一做一次int转换。这个坑如果不注意跑用例的时候会全是类型错误而且YAML文件里有BOM编码时key可能带着\uufeff查起来很隐蔽。我把读取函数放conftest.py里做成了fixture保证每个测试文件都用同一套数据解析逻辑。把测试数据外置到YAML还有个好处就是非开发人员也能看明白测了哪些协议组合。预置位命令矩阵、异常输入组合都可以让做设备对接的同事帮忙review不用打开Python代码。4.4 覆盖率与Allure报告接入测试方案不能只看绿不绿我还给它接了覆盖率和报告。覆盖率用pytest-cov目标是协议层至少95%、映射层90%、串口层85%整体80%以上。运行命令pytest --covkbd300a --cov-reportterm-missing --cov-reporthtml:coverage_htmlterm-missing会直接显示哪些行没覆盖到开发时候我主要看这个。HTML报告是给整个团队看的里面能看到每个文件的覆盖百分比。报告这块日常开发用pytest-html生成的独立报告就够发布前我会用allure-pytest出一份带测试分类、严重程度和失败截图的漂亮报告。allure的接入其实不难安装allure-pytest和allure命令行工具然后pytest --alluredirallure-results跑完再allure generate allure-results -o allure_report清理输出。有一说一allure报告对协议测试这种用例多的项目很友好它能把“协议命令矩阵”这种同类型的用例归类展示看板上非常直观。5. 常见问题与排查技巧实录5.1 串口数据读不完整timeout与read size的博弈我遇到最多的一个问题是模拟器接真实串口时read永远读不到期望的完整帧。调试到最后发现串口层代码里用了serial.read(7)想一次读7字节的Pelco-D帧但真实串口可能只返回了3个字节剩下的字节要等下一轮。而MockSerial如果按“读不到就返回空bytes”实现模拟器代码可能就直接提前返回了。这个问题的根因在于很多人写串口读取时假设“一次read能拿满期望长度”这在真实串口里不成立。我的解决方案有两条腿模拟器代码改成“循环read直到凑够帧长或超时”MockSerial的read故意模拟“每次最多只给一部分”的行为在测试里通过feed半包再feed半包来验证。测试用例不是要去掩盖系统的不确定性而是要确保代码在不确定性下仍然正确。所以我特意写了半包测试来暴露这类时序问题。5.2 校验和错位的经典翻车校验和是我自己踩过最多次的坑。Pelco-D的校验和是前6字节求和后取低8位这个“取低8位”意味着你直接以整数格式打印帧的时候校验和永远在0x00到0xFF之间。我第一次手写期望帧的时候把校验和按完整和值写了结果自然对不上。后来果断改成“所有期望帧都由编码器生成”再抽三条手算验证彻底解决了这个问题。还有个细节是Python里bytes和整数列表的对比。bytes([0xFF, 0x01])遇到list断言会失败即便内容看起来一样。我做协议测试时统一用bytes类型存期望帧避免int列表和bytes在assert时出现让人迷惑的类型错误。打印调试时用hexlify输出而不是直接print bytes否则控制台显示的是ASCII字符二进制内容根本看不清。5.3 pytest fixture污染与用例隔离问题fixture污染是pytest项目从小变大后最容易出现的问题。典型症状单独跑某个测试文件全绿整个测试目录跑一遍就随机红几个用例而且报错位置跟代码改动点毫无关系。我在串口层遇到过一次原因是某个fixture返回了有状态的对象但作用域设成了session多个用例向同一个对象里写数据后一个用例断言时把之前所有残留数据都算进去了。排查这类问题的方法有两个。一个是运行pytest --lf只跑上次失败的用例如果单独跑是绿的基本可以坐实“状态污染”。另一个是在fixture里加一个计数器或者reset方法断言前先打印当前状态看是否带着旧数据。从根本上说这类问题最好的解法就是回到第一节说的作用域策略有状态对象一律function作用域。宁可每个用例多花几毫秒去重建也不要省这点时间换来诡异的偶发失败。5.4 其他容易踩的坑还有一些零散的坑值得记录。pytest收集用例时会把plugins目录、tools目录下的同名测试函数也收进去我在pytest.ini里配置了testpathstests从根上避免误收集。Windows下如果用了串口COM10以上pyserial要求路径写成“\\.\COM10”否则打不开串口模拟器测试用例在Windows CI上要特别注意。再有一个是mock.patch的路径问题。模拟器代码里如果写的是from serial import Serial你patch的时候要patch kbd300a.serial_io.Serial而不是patch serial.Serial。这个问题不熟悉Python导入机制的人会卡很久本质是因为import的符号已经绑定到kbd300a.serial_io这个模块的命名空间里了。最后YAML文件编码问题我也提过UTF-8带BOM时yaml.safe_load读出来的key会带\uufeff字符测试用例配置再匹配也都匹配不上直接用UTF-8无BOM保存文件。我现在的做法是在项目根目录放一个脚本scripts/run_tests.sh把L1到L3的日常测试、覆盖率、报告生成串成一条命令。开发机上跑一次只要不到一分钟改完代码顺手执行红了能立刻定位绿了就可以安心提交。真正常态化使用之后你会发现自动化测试的价值不只是“防止回归”它更像一个协议设计审阅器每次新增命令、调整速度映射、改动校验逻辑测试矩阵都会告诉你有没有把其他组合搞坏。对我这个KBD300A模拟器项目来说pytest测试方案不是第13阶段的附属品而是让模拟器真正可以持续演进的地基。这套分层加Mock的做法也完全可以移植到其他串口协议模拟器项目上值得一试。
阅读完成 · 觉得有帮助?