嵌入式单元测试(基于规格说明) 是做什么的?
为用户提供的 .c/.h 文件生成单元测试套件,遵循 Maurício Aniche 的基于规格说明测试的 7 个步骤(《有效的软件测试》第 2 章)。作为技能的核心判断是:正确划分每个函数的输入域(最有价值的步骤),推导边界值,设计最小但能捕获缺陷的测试用例,在项目的正确测试框架中自动化执行,并迭代直至达到所需覆盖率。
在第 3 阶段之前阅读 references/specification-based-testing.md——它为您提供了关于如何思考分区和边界的校准,以及避免测试套件臃肿或脆弱的具体实践。
范围不变规则(请先阅读)
- 英文输出。 此技能产生的所有内容均为英文,即使使用其他语言编写:测试代码、测试名称、注释、目录、
Covers:注解、跳过/忽略消息、路径声明以及最终摘要。 - 仅限单元测试。 绝不生成集成、端到端、系统、HIL 或 Robot Framework 测试。粒度为隔离的函数/模块,依赖项使用假对象/模拟对象。
- 仅处理给定的文件。 测试套件针对用户传递的
.c/.h文件集合。不要扫描项目以查找其他目标,也不要在未指示的情况下自行测试相邻模块。 - 切勿修改目标文件——无论是临时修改还是“测试后恢复”。测试套件编译原始的
.c文件,不复制到测试目录。为访问难以测试的部分,使用公共 API、依赖项的假对象或单独的测试二进制文件。 - 自主运行。 生成、运行并报告,无需请求确认执行。有两个例外情况您需要停止并询问:(a) 路径 C(既不是 Zephyr 也不是 ESP-IDF)且无法识别任何单元测试框架(第 0 阶段);(b) 连续 2 次覆盖率迭代均未达到目标(第 8 阶段)。
- 您不撰写报告。 您不编写结果报告。运行工具并指向它们生成的产物(覆盖率 HTML、运行器输出)。“结果”是工具发出的内容,而不是您的叙述。
- 覆盖率目标: 默认函数≥95%,行≥95%,分支≥90%(95/95/90)。一旦达到目标,停止迭代并列出剩余未覆盖的行/分支。仅当用户要求时才追求 100/100/100。低于目标仅当缺失部分被证明是不可达的(日志/断言噪音、模块的可测试性限制),且需要为每个剩余的行/分支提供理由。
流程(Aniche 的 7 个步骤 → 9 个阶段)
这 7 个步骤不是严格的流水线——它们是迭代的(第 3 阶段通常会揭示某些内容,使您回到第 1 阶段)。以下阶段是主干。
| 阶段 | Aniche 步骤 | 您要做的事 |
|---|---|---|
| 0 | — | 检测环境 → 路径 A(Zephyr)、B(ESP-IDF)或 C(其他) |
| 1 | 1 理解 | 每个给定函数的需求、输入、输出 |
| 2 | 2 探索代码 | 绘制依赖关系图以使用假对象/模拟对象隔离 |
| 3 | 3 分区 | 编号的分区目录(最重要的一步) |
| 4 | 4 边界 | 编号的边界目录(开/关点) |
| 5 | 5 设计用例 | 组合分区/边界,减少组合数量 |
| 6 | 6 自动化 | 使用路径的框架编写测试套件,包含可追溯性 |
| 7 | — | 运行工具:执行 + 覆盖率 |
| 8 | 7 扩充 | 迭代缺口(覆盖率 + 目录中无测试的项) |
第 0 阶段 — 环境检测
按此顺序识别包含给定文件的项目环境,并选择路径。Robot Framework、HIL、集成 pytest 不是单元测试框架——它们在任何路径中都不算数。
-
Zephyr?→ 路径 A。 信号:
west.yml/west.yaml;CMakeLists.txt包含find_package(Zephyr ...);prj.conf;testcase.yaml/sample.yaml;tests/;ztest/ZTEST_SUITE。工具:twister + ztest + FFF + 覆盖率。阅读references/zephyr-path.md。 -
ESP-IDF?→ 路径 B。 信号:
CMakeLists.txt包含idf_component_register(;sdkconfig/sdkconfig.defaults;使用$ENV{IDF_PATH}/idf.py;main/组件。工具:ESP-IDF 的原生 Unity(主机构建,linux目标)+ gcov/gcovr。直接使用 Unity——不要停止询问。例外:如果项目已经始终使用另一个单元测试框架(Ceedling、GoogleTest…),则遵循该框架。阅读references/espidf-path.md。 -
其他(既不是 Zephyr 也不是 ESP-IDF)→ 路径 C。 检查现有的单元测试结构(Unity、CMock、Ceedling、GoogleTest、自定义)并忠实地遵循项目的模式。如果未找到单元测试框架,则停止并询问用户使用哪个——切勿在路径 C 中假设默认值。阅读
references/non-zephyr-path.md。
在响应文本中说明(不仅在您的推理中),在继续之前,说明所选路径以及证明其合理性的具体信号,包括文件和您找到的内容——例如“路径 A(Zephyr):zephyr/west.yml,tests/.../CMakeLists.txt 中的 find_package(Zephyr),tests/lib/ 中的 ZTEST_SUITE 套件。”
第 1 阶段 — 理解需求、输入和输出
对于测试套件将覆盖的每个函数,从阅读 .c/.h 中提取:
- 业务规则:它做什么以及它不能做什么(包括边界情况);
- 输入:类型、范围/域、变量之间的约束;
- 输出:输入如何变为结果(以及可观察的副作用:输出是否写入、是否返回错误、是否发出/未发出运动)。
部分规则是显式的;部分是通过领域规则(饱和、回绕、单位、符号、齿隙)推断的,这些规则是隐含的。这些隐含规则正是 bug 隐藏的地方。
第 2 阶段 — 探索代码
阅读整个 .c/.h 并构建要隔离的依赖关系图:HAL、驱动程序、内核/RTOS、I/O、时间、对其他模块的调用。每个依赖项将成为第 6 阶段的假对象/模拟对象。还要确定如何访问实际逻辑(直接公共 API,或通过句柄/vtable 调用 static 函数——通过真实 API 调用它们,切勿发明签名)。
第 3 阶段 — 识别分区(最重要的一步)
将 references/specification-based-testing.md 中的 3 个视角应用于每个函数:单独考虑每个输入、输入组合以及输出类别。考虑 NULL/空和隐含的领域规则。如果函数有状态,还要根据模块/上下文的状态进行划分。
针对每个函数,生成一个有名称、有编号的目录,在测试套件中具有稳定的 ID:
- 分区:
<func>/P1, P2, … - 边界(在第 4 阶段填写):
<func>/B1, B2, …
每个条目都有简短的描述。此目录是测试套件的可追溯性契约(在第 6 阶段实现)以及缺口(第 8 阶段)的真实来源。执行良好的第 3 阶段应在首次覆盖率通过时涵盖几乎所有内容。
第 4 阶段 — 分析边界
对于相邻分区之间的每个边界,在目录中添加开点(条件中的值)和关点(相反分区中最近的相邻值);相等条件给出一个开点和两个关点。删除已被其他用例覆盖的边界——不要重复。“分区还是边界?不重要”:关键是存在该用例。
第 5 阶段 — 设计测试用例
跨输入类别组合分区,但减少组合爆炸:测试异常情况(NULL/空)一次,不要将其与所有内容组合;使用领域知识来削减。使用单个种子输入,每个用例仅改变最小值(便于调试)。如有疑问,使用最简单的输入。对于不参与行为的输入,使用合理/实际的值。如果一个用例与多个其他用例具有相同的骨架,则将其规划为参数化。列出用例,并针对每个用例列出其涵盖的目录 ID。
第 6 阶段 — 自动化(带强制性可追溯性)
使用检测到的路径的框架编写测试套件(语法、隔离和模板的详细信息见 references/zephyr-path.md、references/espidf-path.md 或 references/non-zephyr-path.md)。
第 3/4 阶段的目录成为测试套件中的注释,分为两个级别:
- 每个函数的头部——在该函数测试部分顶部的块,列出完整目录(每个分区/边界及其 ID 和简短描述)。
- 每个测试的注解——每个测试引用其涵盖的项,例如
/* Covers: P3 + B1 */。
强制性规则(这是使可追溯性有价值的关键):
- 无孤立测试: 每个测试引用 ≥ 1 个目录项。
- 无孤立项: 每个分区/边界由 ≥ 1 个测试覆盖。没有测试的项被标记为缺口(供第 8 阶段使用)。
- 头部和注解之间 ID 稳定且相同——以便
grep "P4"列出头部和触及该分区的每个测试。 - 覆盖组合的测试需列出所有 ID(
/* Covers: P2 + P5 */)。 Covers:必须真实: 每个注解的 ID 必须与用例实际覆盖的内容(输入、状态和断言)匹配,而不仅仅是目录中存在的项。在第 7 阶段之前,根据每个测试主体重新阅读注解,并修复不匹配的注解。- 在路径 B 和 C 中,采用相同的方案,但需适应框架的语法(在每个
TEST(...)/TEST_CASE(...)/TEST_F(...)上方添加注释)。
规范形式(路径 A,ztest):
/*
* Catalog — motor_ctrl_transform (Phase 3 · spec-based testing)
* Partitions:
* P1 p_axis_values == NULL -> rejected, output not written
* P2 valid axis_count (1..N) -> transforms
* P3 positive value -> positive steps
* P4 negative value -> sign preserved
* P5 zero value -> zero steps
* Boundaries:
* B1 axis_count == N (max) -> accepted
* B2 axis_count == N+1 -> rejected
*/
/* Covers: P2 + P3 */
ZTEST(motor_ctrl_suite, test_transform_positive_gives_positive_steps) { /* ... */ }
/* Covers: P1 */
ZTEST(motor_ctrl_suite, test_transform_null_input_is_rejected) { /* ... */ }
注意:在较新的 Zephyr 中,运行器要求测试名称以
test_开头(ZTEST(suite, test_...))——没有此前缀,twister 不会收集该用例。
发现的缺陷:代码与文档不符
当代码与头文件、函数注释或 API 合同文档相矛盾时(例如,返回与文档不同的错误代码,失败时未恢复状态),不要使测试套件变红,也不要使用 ZTEST_EXPECT_FAIL。编写两个测试:
- 文档行为,附原因并忽略——对文档承诺的断言,但被跳过并附带原因:
ztest_test_skip()(路径 A)或TEST_IGNORE_MESSAGE("BUG: ...")(路径 B)。上方注释:/* Covers: Pn — BUG: <docs say X; code does Y> */。 - 特征描述——固定当前行为,以便任何更改(包括修复)都能显现。注释:
/* Covers: Pn — characterization of the BUG above */。
测试套件以绿色结束,bug 在每次运行中都能显现(跳过/忽略),并且二进制文件进入测量。在最终摘要中列出缺陷。
第 7 阶段 — 执行 + 覆盖率
在运行之前,激活项目环境。 测试工具很少在全局 PATH 上——它通常位于仓库的虚拟环境中。在得出结论“无法运行”之前,请先查找并激活环境(这适用于所有路径):
- 在固件根目录下查找 venv(
.venv/、venv/、env/),并使用其二进制文件(.venv/bin/west、.venv/bin/python)或source .venv/bin/activate。该 venv 通常是符号链接:使用test -e .venv/bin/activate或ls -laL(跟随链接)检查,切勿仅使用ls -d/find -type d。说明您找到了什么:所使用的 venv 路径,或“在<检查的目录>中未找到 venv”。 - Zephyr: 设置
ZEPHYR_BASE(例如deps/zephyr,或west topdir/.west/config),对于主机,设置ZEPHYR_TOOLCHAIN_VARIANT=host。如果west不在PATH上但 Zephyr 树存在,则直接运行 twister:python3 $ZEPHYR_BASE/scripts/twister ...。 - ESP-IDF: 确保 IDF 环境已设置(存在时执行
. $IDF_PATH/export.sh)以进行主机构建;否则直接使用gcc编译(参见espidf-path.md)。
仅在穷举此搜索后,才可诚实地报告无法在此处运行。
上下文经济(固件构建冗长,每次重新读取所有内容):
- 将构建、twister 和 gcovr 输出重定向到文件(
> build.log 2>&1),仅显示摘要(tail -n 30 build.log、grep -E 'error|FAIL|PASS|lines|branches' build.log)。仅当摘要无法解释失败时才打开整个日志。 - 一次性完整写入每个测试文件;通过有针对性的编辑进行修复。
- 在同一轮中组合独立的读取和检查;不要重新读取您已经读取过的文件。
运行路径的工具并指向其生成的产物(不要编写报告):
- 路径 A(Zephyr):
west twister --coverage -p native_sim -T <suite_path>。产物:twister-out/coverage/index.html、twister-out/coverage.json、twister-out/twister.xml、twister-out/twister.json。 - 路径 B(ESP-IDF): 使用 Unity +
gcov/gcovr进行主机构建(参见espidf-path.md);指向覆盖率报告(coverage.html/.xml)和测试输出。 - 路径 C(其他): 项目自己的运行器 + 覆盖率(例如
ceedling gcov,或通过测试构建的gcov/gcovr)。保留并指向发出的产物。
每个路径文件中的详细信息和故障排除。
第 8 阶段 — 覆盖率迭代
将工具报告的覆盖率与 95/95/90 目标(或用户要求的目标)进行比较。低于目标时,每个没有测试的可达行/分支都是一个缺口;一旦达到目标,停止并列出未覆盖的内容。对于每个缺口,使用两个来源添加用例:数值覆盖率(红色行/分支)和目录中尚未有测试的项。