结论:test_runner 的分组只认「类自身」定义的 fixture。make_batches() 检查类的 __dict__,只有类自己写了 setUpClass/tearDownClass 才按类合并;仅仅继承 setUpClass 的测试会被切到多个批次、甚至不同 worker,于是类级 fixture 分别运行。这不是「测试失败」,而是分组契约的静态结果。退出码由批次与收集决定:收集错误或失败返回 1,无记录返回 5,中断返回 2。
调用与数据流
main() 先切换到仓库根目录、设置警告策略,再在主进程收集测试。多进程模式把 test ID 字符串而不是测试对象交给 worker,每个批次在 worker 内重新导入/加载。默认 -j 为 CPU 数、-W error 将警告升级为异常,只有 workers >= 2 才创建进程池。
| 环节 | 关键函数/组件 | 静态契约与观察 |
|---|---|---|
| 路径转模块名 | module_name() |
目标必须位于 ROOT 下;将相对 .py 路径转为 dotted 模块名。 |
| 测试收集 | collect()、flatten() |
默认递归 openpilot/test_*.py;仅无显式目标时应用 IGNORED;导入错误进入 errors,过滤后按完整 ID 去重。 |
| 批次规划 | make_batches() |
模块有 setUpModule/tearDownModule 则按模块分组;否则仅类自身 __dict__ 含 setUpClass/tearDownClass 才按类分组;其他 ID 按 ceil(tests/(jobs*4)) 分块。 |
| 单批执行 | run_batch() |
每批重载 suite;批次外层捕获 fixture/导入输出。 |
| 多进程调度 | run_parallel() |
future 完成顺序决定进度输出顺序;批次抛异常时,仅生成该批首个 ID 的 error 记录。 |
| 输出捕获 | Capture |
dup2 在进程级替换 stdout/stderr 文件描述符;仅失败、错误、意外通过保留测试输出。 |
| 结果聚合 | Result |
六种状态:passed、skipped、xfailed、failed、error、xpassed;子测试失败/错误合并在父测试记录中。 |
| 汇总退出 | report() |
收集错误或失败状态返回 1;有记录且无失败返回 0;无记录且无收集错误返回 5。 |
fixture 与失败路径
unittest.TestSuite 负责调用模块/类 fixture,runner 只决定哪些 test ID 同批。因为 make_batches() 检查的是类自身字典,它不会把仅继承 setUpClass/tearDownClass 的测试自动按类合并。例如 TestParams 继承 OpenpilotTestCase.setUpClass();若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。这是静态分组结论,不代表该测试已出现并发故障。
反向地,显式定义类级 fixture 的测试按类同批,有模块级 fixture 的按模块同批。而 TestSensord 和 TestOnroad 只定义小写 setup_class,会被基类调用,但同样不满足 runner 的按类分组条件;对其硬件副作用应使用串行/独占设备执行。
OpenpilotTestCase.run() 在每个测试外包住环境变量快照、OpenpilotPrefix、manager 清理与 GC 恢复;setUpClass() 另有类级环境快照。它们是测试辅助类自己的隔离语义,不是 runner 对任意测试的全局保证。带 COMMA_HARDWARE_TEST 的用例还会在 setUp() 初始化硬件、关闭省电并终止 athena 进程。
失败记录也有讲究:Result.mark() 在 fixture 早于 startTest 失败时生成独立的 fixture ID 记录,严重状态 error 优先于 failed;跳过和预期失败的详细原因/堆栈未写入报告。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底,不能期待得到统一汇总。
因此对带硬件副作用的测试,应显式串行并声明独占资源,而不是假设 runner 会自动隔离;显式目标也不会因此变得无副作用。
参数与副作用的边界
| 现象 | 源码事实 | 含义 |
|---|---|---|
-j 0 |
没有参数校验,会在批次大小计算中除零 | 负数同样不构成可靠 worker 配置。 |
-k 过滤 |
过滤发生在加载 suite 之后 | 不能阻止模块导入副作用。 |
IGNORED |
只约束默认无目标扫描 | 它不是安全沙箱,显式目录仍会递归发现 test_*.py。 |
| worker 异常 | 仅首个批次 ID 被标为 error | 不能把未列出的同批测试当作通过。 |
另外两点会影响结论的可信度:其一,收集会导入测试模块,即使只用 -k 过滤,过滤也发生在加载 suite 之后,不能阻止模块导入副作用;IGNORED 只约束默认无目标扫描。其二,CLI 的退出码固定为 0/1/2/5:main() 捕获 KeyboardInterrupt 返回 2,空结果返回 5,收集错误或失败返回 1。假 loader 探针验证了重复目标只加载一次、过滤发生在加载后,假 future 验证批次异常时仅首个 ID 被记为 error;这些均不是实进程池或真实 fixture 的执行证据。
对硬件与插件化重构的意义
- 权限分层:纯算法/协议解析测试可以进入默认并行池;任何枚举真实 panda、串口、CAN、相机、刷写、Params 全局路径或终止系统进程的测试都应有显式硬件标签与独立执行环境。
- 资源声明:硬件插件测试应声明设备实例、总线、固件模式和独占锁;不要仅依赖类级 fixture 或
-j 1防止其他进程争用。 - 可复现证据:记录源码提交、子模块版本、插件版本、硬件序列号、
-W/-j/目标 ID、日志来源与结果;批次异常须标记整批为「未完成/未知」。 - 分级门禁:单元测试、离线回放、SIL、隔离台架 HIL、实车验证逐级放行;车端控制或固件修改不可由开发机 runner 的绿色结果单独授权。
常见问题
为什么继承来的 setUpClass 不会被按类分组?
因为 make_batches() 检查的是类自身 __dict__,仅继承 setUpClass/tearDownClass 不满足条件。例如 TestParams 继承 OpenpilotTestCase.setUpClass(),若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。
-j 0 会怎样?
没有参数校验,会在批次大小计算中除零;负数同样不构成可靠 worker 配置。该行为由隔离探针验证,不是实进程池执行证据。
跳过和预期失败的原因会写进报告吗?
不会。源码中跳过和预期失败的详细原因/堆栈未写入报告,只有测试级耗时与六种状态。因此不能仅凭报告里的 skipped 数量判断原因。
单 worker 抛异常会得到统一汇总吗?
不一定。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底;多 worker 模式则仅首个批次 ID 被标为 error,不能把未列出的同批测试当作通过。
延伸阅读
- process replay 校验:为什么「能回放」不等于「行为等价」 —— 承接本文对测试证据等级的讨论。
- process replay 的 schema 迁移与输出有效性规则 —— 承接回放输出的有效性门。
- 离线与实时两条数据管线:日志回放和上车运行的差别 —— 承接「开发机测试入口不等于车端」的边界。
- 21 块板卡的去留:A/B/C 分级、三个产品族与四道阶段门 —— 承接分级门禁与硬件测试标签的思路。
- AGNOS 平台更新:系统层升级的边界与回滚 —— 承接发布与回归测试的构建链衔接。
参考资料
- 项目 02 ——
openpilot-research/TEST_RUNNER_PARALLEL_FIXTURE_CONTRACT_DEEP_DIVE.md(取用了收集/分组/执行契约、fixture 与失败路径、结果与退出码,以及硬件插件重构意义)。 - 项目 02 ——
openpilot-research/NATIVE_TEST_DISCOVERY_BUILD_SKIP_CONTRACT_DEEP_DIVE.md(仅用于确认原生测试缺失即跳过的发现契约)。
说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。
更新记录
首次发布 2026-10-11 最近核对:2026-10-11
发表回复