尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cantata单元测试实战:C/C++嵌入式打桩与覆盖率落地

Cantata单元测试实战:C/C++嵌入式打桩与覆盖率落地 第一次接到Cantata 单元测试这个任务是在一个纯 C 写的老通信模块上。那个模块跑了好几年函数层层嵌套全局变量满天飞谁也不敢动。领导只给了一句话把它测起来覆盖率要到判定级。我当时第一个反应是用 gcov 加自己写断言折腾两天之后发现一个带静态函数和大量硬件接口的 C 模块靠手搭测试框架基本是在给自己挖坑。后来换成 Cantata 测试工具情况才好转——它能自动生成测试驱动、自动打桩、自动统计覆盖率把测试这件事从手工劳动变成了配置加脚本的工作。这篇就把 Cantata 的基本用法从头到尾捋一遍包括环境配置、第一个用例怎么写、桩函数怎么打、覆盖率报告怎么看、命令行怎么接进流水线以及我在实际项目里踩过的那些坑。不管你是刚接触单元测试的新手还是做过一段时间但总觉得覆盖率上不去的人应该都能从里面找到能直接抄的东西。1. Cantata 在 C/C 单元测试工具链里到底站哪个位置很多介绍 Cantata 的资料一上来就堆功能列表看完还是不知道它和别的工具差在哪。我更愿意从它替你干了哪些活这个角度去理解因为选型的时候真正决定成败的不是功能多少而是它能不能把最难的那部分自动化掉。1.1 一个会自己搭脚手架的测试平台写 C 单元测试最烦的从来不是写断言而是写断言之前的那一堆准备工作。被测函数是个static外部根本调不到函数内部调了三个硬件寄存器读写接口在 PC 上跑必然崩还依赖两个全局状态机变量不摆弄到正确状态分支就走不进去。这些问题用通用测试框架比如 Unity、GoogleTest你得自己一个个解决改源码加#define static、手工写桩函数、手工初始化全局变量。Cantata 的做法是让工具去干这些。你把源码导进工程它扫描出所有函数和变量然后你选择要测哪个函数它自动生成一个测试脚本模板里面已经包含了调用这个static函数所需的可见性处理、桩函数声明、全局变量访问接口。你要做的只剩下两件事把输入摆对把期望写清楚。这种脚手架自动化是它和通用框架最本质的区别也是它能用在大型遗留代码上的原因。它的测试脚本本身还是标准 C 代码加上一组宏。这一点很关键意味着你的测试脚本可以被任意 C 编译器编译也可以被静态分析工具扫一遍不会因为引入测试而破坏整个代码库的构建规则。1.2 和 gcov、Unity、GoogleTest 各自管哪一段经常有人问我gcov 不是也能出覆盖率吗为什么还要上 Cantata。这两者其实管的根本不是同一段活。下面这张表是我按实际使用体感整理的能比较直观地看出分工。工具主要职责需要手写的部分典型场景gcov / lcov覆盖率统计全部测试代码、桩函数、驱动已有完整测试只想看覆盖情况Unity / CMock断言与桩生成测试用例、部分桩、驱动框架中小型项目接口清晰可编译性良好GoogleTestC 断言与夹具测试用例、桩、驱动桌面端 C 业务代码Cantata驱动生成 打桩 覆盖率 静态检查测试意图与断言逻辑嵌入式、高安全要求、遗留 C 代码看表就能明白Cantata 把驱动生成和打桩这两块最容易劝退人的工作包了进去。但这不意味着它能替代 gcov——如果你已经有一套跑得很好的测试只想加覆盖率统计那用 gcov 更省事。Cantata 的价值在于从零到有的那一段也就是一个模块根本没有任何测试、结构还比较糟糕的时候。1.3 什么项目值得上 Cantata不是所有项目都值得引入它。我的经验是三个信号出现任意两个就值得考虑。第一代码里有大量static函数且这些函数承载了核心逻辑。用通用框架测这类函数要么改源码要么只能测到外层包装测不到真正出问题的地方。第二模块和硬件、操作系统、其他子系统强耦合不隔离就跑不起来。第三项目挂在某个需要认证的行业标准下测试报告要能追溯到需求人工整理的文档根本顶不住审查。反过来说如果是纯算法库、接口干净、没有静态函数、也不需要追溯那把 Cantata 引进来反而增加了一套工具链的学习成本。工具选型说到底就是算账不要因为听说它很强就上。2. 环境与工程配置后面顺不顺八成看这一步我在项目里见过太多次测试跑不起来的求助最后追下去十有八九不是脚本写错而是工程配置某个选项和实际编译环境不一致。Cantata 的配置项不算多但每一项都直接影响生成的驱动能不能编译过所以这一步值得慢一点。2.1 编译器配置的三层含义Cantata 里所谓的编译器配置其实包含三层理解清楚能省掉大量排查时间。第一层是宿主编译器用来编译测试脚本和生成的驱动跑在开发机上。这一层通常就是 gcc 或 clang配好路径即可。第二层是被测代码的编译选项包括宏定义、头文件搜索路径、语言标准C99、C11 还是 C14。这一层最容易出问题因为 Cantata 生成的驱动会把被测源码直接包含进来编译如果你的源码里有#ifdef CONFIG_XXX这样的条件编译而选项里没定义CONFIG_XXX那么被包含进来的代码路径和实际固件里跑的根本不是同一段。我吃过这个亏一个通信协议模块因为在配置里漏了一个USE_CRC16的宏导致测的一直是校验关闭的那条分支覆盖率怎么补都上不去。第三层是目标环境描述包括字节序、基本类型宽度int是 16 位还是 32 位、对齐规则。嵌入式项目如果目标平台和宿主机不一致这一层必须显式配置否则所有涉及指针和结构体布局的断言都会莫名其妙失败。我一般会在配置完成后先跑一个最简单的空函数用例确认环境自洽再往下写。2.2 新建工程时的选项文件与目录结构Cantata 的工程信息大多保存在一个选项文件里所有编译参数、源码路径、报告格式都从这里读。我的建议是把这个文件纳入版本管理和源码放在同一个仓库但不要和源码放同一个目录避免扫描时把测试产物当成源码扫进去。目录结构上我习惯这样分project/ src/ # 被测源码 test/ cantata/ project.opt # 工程选项文件 tests/ # 测试脚本 stubs/ # 手工桩函数如果有 results/ # 运行结果与报告把测试脚本和手工桩单独放是因为 Cantata 自动生成的桩和手工补的桩需要区分开否则升级工具版本重新生成时容易把手工写的部分覆盖掉。这个坑我踩过一次一个下午的工作量没了从那以后所有手工文件都带_manual后缀。2.3 源码扫描阶段的常见误判导入源码之后Cantata 会做一次解析列出可测函数。这一步有两个地方容易误判。一是条件编译分支。解析器只看到配置里生效的那部分代码被#if 0或者未定义宏挡住的代码不会出现在函数列表里也不会计入覆盖率分母。这不是 bug但会让人误以为这段代码测过了。写测试之前先确认你的宏定义和实际编译一致。二是内联函数和宏函数。inline函数可能被解析器当作普通函数列出但实际编译时被展开覆盖率统计口径会和预期不同。宏函数则完全不会出现在函数列表里需要你在测试里单独构造调用它的场景。我的做法是在扫描完成后拿函数列表和nm或objdump导出的符号表对一遍差异项单独记录测试计划里明确哪些不测、为什么。提示扫描结果和符号表对不上的函数不要直接跳过先把差异原因写进测试计划的备注里。审查的时候能解释清楚为什么没测和测了价值是一样的。3. 第一个测试用例怎么从零跑到绿配置搞定之后就到了真正写用例的环节。这部分我会把骨架、调用、断言三件事拆开讲因为新手最容易在这里犯的错就是把三件事混在一行里结果失败时完全不知道是输入没设对还是断言写错了。3.1 INIT_TEST 与 END_TEST 之间到底发生了什么Cantata 的测试函数结构大致是这样void TC_001_add_normal(void) { INIT_TEST(); /* 设置输入 */ /* 调用被测函数 */ /* 断言 */ END_TEST(); }INIT_TEST()和END_TEST()是成对的它们之间是一套隔离环境。INIT_TEST做的工作包括重置所有桩函数的调用记录、恢复被测模块的全局变量到初始状态、清空输入输出缓冲。END_TEST则负责检查是否所有预设的调用期望都被满足、是否有断言失败、是否有未预期的函数调用然后结算这个用例的结果。这个设计的意义在于用例之间互不污染。如果你不写这两个宏前一用例里设置的桩返回值会带到下一用例全局变量的修改也会残留最后表现就是单独跑能过一起跑就挂。我刚开始用的时候图省事跳过了INIT_TEST结果花了半天时间追一个根本不存在的 bug。写脚本时还有个小习惯值得养成每个测试函数上方用注释写清楚测试目的和前置条件一份是给审查者看的一份是给未来的自己看的。半年后回来看你会感谢自己。3.2 CALL 调用被测函数与返回值的捕获调用被测函数要用CALL宏而不是直接写函数调用。原因很简单直接调用的话返回值需要你自己声明变量接收而CALL会帮你把返回值存到一个内部位置之后用返回值断言宏去取。CALL(add(1, 2)); CHK_RETURN_VALUE(3);CHK_RETURN_VALUE的语义是上一次 CALL 的返回值应该等于 3。这种隐式捕获的设计让脚本看起来很简洁但也带来一个限制一次 CALL 之后必须紧接着检查返回值如果中间又插入了一次 CALL前一次的返回值就被覆盖了。这一点在测那种内部连续调用多个子函数的场景时要特别注意。如果被测函数返回void那就不需要断言返回值转而检查它产生的副作用——改了什么全局变量、调用了什么外部函数、写入了哪个输出参数。这三种副作用分别对应后面会讲的全局变量断言、调用断言和参数断言。3.3 输入参数的赋值与输出变量的读取对于通过指针传出结果的函数比如int parse(const char *in, int *out)Cantata 提供了设置输出参数空间和读取实际写入值的手段。基本思路是先声明一个符合类型的变量把它作为参数地址传进去调用完成后再检查这个变量。int out_val 0; CALL(parse(123, out_val)); CHK_EQUALS(out_val, 123);这里out_val是被测函数写入的所以用普通断言宏即可。如果函数是通过全局变量输出结果的那就要用针对全局变量的读取接口不能直接引用符号名——因为static全局变量在测试脚本里通常是不可见的必须通过 Cantata 生成的访问函数去读写。我个人的建议是能通过参数传的就不通过全局变量。测试一个大量使用全局变量的模块脚本里会充斥各种读写访问器调用可读性急剧下降。如果条件允许重构时把关键状态改成通过参数传递测试成本会下降一个数量级。4. 打桩把被测函数从真实世界里隔离出来单元测试的核心前提是只测这一个单元其他所有依赖都要被替换成可控的替身。Cantata 把这件事叫做打桩桩函数的质量直接决定测试用例能不能覆盖到边界情况。4.1 什么时候非打桩不可有三种情况必须打桩判断标准很清晰。第一种是依赖不可在测试环境执行的代码比如直接操作硬件寄存器的读写函数、依赖 RTOS 的任务创建函数、写文件的 IO 函数。这些函数在开发机上跑不起来必须在链接阶段替换掉。第二种是依赖返回值不稳定或不可控的函数比如读系统时间的get_tick()、随机数生成、从传感器读数据。测试要覆盖超时数值越界这类边界就得让这些函数按你的意愿返回特定值。第三种是需要验证调用行为的函数比如要确认在某个条件下确实调用了告警上报接口、且只调用了一次。这类需求光看返回值满足不了必须借助桩的调用记录功能。判断的核心问题是这个函数的行为会不会影响我这次要验证的逻辑会就打桩不会就让它跑真实实现反而更省事。我见过把所有外部函数全打桩的测试脚本写了八百行实际有效断言不到十条这是典型的过度打桩。4.2 STUB_RETURN、参数约束与调用次数断言最简单的桩就是给它一个固定返回值。Cantata 里用类似下面的方式STUB_RETURN(read_sensor, 42);意思是本次测试里read_sensor被调用时返回 42。如果这个函数在同一个用例里被调用多次你需要用可以重复设置的形式或者按调用顺序给出返回序列否则第二次调用可能拿到未定义的值。这个细节新手最容易忽略表现就是第一次断言过了第二次断言拿到垃圾值。参数约束用来区分同一个函数不同入参返回不同结果的场景STUB_PARAMETERS(read_sensor, 1); STUB_RETURN_VALUE(read_sensor, 100); STUB_PARAMETERS(read_sensor, 2); STUB_RETURN_VALUE(read_sensor, 200);顺带说一句不同版本的 Cantata 在这些宏的命名上略有出入有的版本是STUB_RETURN(func, val)有的是STUB_RETURN_VALUE以上手时你手上的语法文档为准别硬记。调用次数断言用于验证行为CHK_CALL(report_alarm); CHK_CALL_COUNT(report_alarm, 1);CHK_CALL断言这个函数至少被调过一次带计数的版本则精确匹配次数。我一般会把至少一次和恰好一次分清楚如果业务上要求不能重复告警那必须用精确计数的版本用宽松版本等于没测。4.3 桩函数的重复行为与序列化状态有一种场景比较复杂被测函数在一个循环里反复调用同一个外部接口而每次调用应该返回不同的值模拟重试成功第三次才失败这类逻辑。这时候固定返回值不够用需要按调用序列来设置。STUB_RETURN_SEQUENCE(send_packet, -1, -1, 0);含义是第一次返回 -1第二次 -1第三次 0模拟两次失败后成功。如果被测函数的循环次数超过序列长度后面的调用行为取决于工具设置有的版本会重复最后一个值有的会报错。这一点务必实测确认否则测试结果会是看着过了但过的方式和你想的不一样。还有一种更麻烦的情况桩函数本身需要维护状态比如模拟一个有连接状态的通信对象——没连接时发送返回错误连接后才正常返回。这种用简短的返回值序列表达不清楚需要写一个带状态的手工桩。Cantata 允许你把自动生成的桩替换成手写实现我一般只在确实需要状态机的时候才这么做因为手工桩会脱离自动生成的管理重新扫描代码时容易被漏掉。注意手工桩一旦引入就要在工程配置里明确标记为不可自动覆盖并且加注释说明状态转换规则。我见过因为手工桩没被记录后来重新生成测试时整个用例静默失效的情况非常隐蔽。5. 覆盖率看什么语句、判定、MC/DC 三档到底差在哪覆盖率是 Cantata 最常被拿到台面上的能力但很多人拿到报告只会看一个总数其实真正有价值的是没覆盖的那些条目的分布和原因。5.1 三档覆盖率的判定口径语句覆盖率最容易达到只要每行代码被执行过就算数它的问题是发现不了条件没测全这类缺陷。判定覆盖率要求每个判断的真假两个方向都被走到比语句级严格不少。MC/DC 更细要求在一个由多个条件组成的判断里每个条件都能独立影响最终结果也就是说每个条件都要有至少一次只改变它、其余不变结果跟着变的测试。用一段代码说明差异if (a 0 b 0) { do_something(); }语句覆盖率只需要构造一次a0 b0为真的场景即可。判定覆盖率还需要一次整体为假的场景。MC/DC 则需要三组(真, 真)、(假, 真)、(真, 假)让a和b各自都能独立左右结果。对嵌入式项目来说判断该做到哪一档取决于项目适用的行业标准要求。标准要求 MC/DC 的就老老实实构造独立影响对只要求判定的就不必把用例量翻倍。我在项目里见过为了覆盖得漂亮硬上 MC/DC结果用例数量暴涨维护成本远超收益。5.2 未覆盖条目怎么定位、怎么补拿到报告之后我的习惯是先把未覆盖条目分成四类分类处理。第一类是防御性代码比如参数非法时的错误返回。这类分支往往需要构造极端输入才能走到。补测的时候直接构造边界值空指针用桩返回、数值用类型最大值最小值、字符串用空串。第二类是异常处理路径比如内存分配失败、通信超时。这类必须靠桩函数返回错误码来触发属于打桩能力的一次检验。第三类是真的不该测的死代码比如历史遗留的兼容分支、断言失败后的兜底。这类不要硬凑用例去覆盖正确的做法是在测试计划里登记为合理未覆盖写明原因。审查的时候能解释清楚比强行覆盖更有说服力。第四类是需要重构才能测的代码比如一个函数里嵌套了五层判断逻辑上根本构造不出单独的路径。这种情况我会反馈给开发把函数拆小。工具帮不了你的是代码结构问题遇到这类只能从源头改。5.3 报告导出与需求追溯Cantata 支持把覆盖率数据和测试结果导出成结构化报告常见的是 HTML 供人看、XML 供工具消费。如果项目需要做需求追溯还可以把测试用例和需求条目建立映射生成需求—用例—结果三者的对照表。我实际用下来追溯这块的价值主要在审查环节。审查者不关心你写了多少断言他关心的是这条需求对应哪个用例这个用例跑没跑过覆盖到什么程度。提前把映射关系做好审查时能从系统里直接导出比临时整理文档省太多时间。导出时有个细节要注意报告里的绝对路径在不同机器上不一致会导致报表无法比较。建议在工程配置里把工作目录设成相对路径让报告在不同环境下保持一致这样每日构建之间的覆盖率变化才能自动比对。6. 命令行与持续集成让测试脱离 IDE 跑起来界面里点一下跑测试适合开发阶段。但真正的质量门禁要放在流水线上这就要求整个流程能通过命令行驱动。Cantata 提供了命令行版本基本思路是先生成构建文件再执行运行最后导出报告。6.1 关键命令与选项典型的三步走大致是这样的形式# 生成构建文件 cantata -p project.opt -genmake # 执行全部测试 cantata -p project.opt -run -all # 导出报告 cantata -p project.opt -report -format xml不同版本参数名会有差异具体以cantata -help输出为准。有几个选项我觉得有必要单独说。-p指定工程选项文件这个文件在流水线上最好用相对路径避免工作目录变化导致找不到文件。执行范围选项用来控制跑哪些用例初次接入流水线时可以只跑冒烟用例子集稳定之后再放开全量。报告格式选项决定输出是给人看的还是给工具解析的流水线上一般两种都要出。执行结果的返回码很关键流水线靠它判断这一步是成功还是失败。接入的时候一定要验证一下故意让一个用例失败看流水线是否正确中断。我曾经遇到过返回码恒为 0 的情况等于流水线是个摆设。6.2 让结果可被流水线消费报告出来后还要解决怎么展示的问题。如果流水线支持直接解析 XML那最省事把报告路径配置进去就行。如果只支持文本输出那就写个解析脚本从报告里提取总用例数、通过数、失败数、各项覆盖率输出成流水线能识别的格式。我一般会额外做一件事保存历史覆盖率数据。每次构建把覆盖率数值追加到一个简单的 CSV 里时间长了就能看出趋势。覆盖率突然下降往往意味着新提交的代码没带测试这比看单次报告的绝对值有用得多。趋势图还能在评审时提供有力依据说明测试投入确实在积累。提示历史数据用最简单的格式存就好一行一次构建字段包括时间、提交号、语句覆盖率、判定覆盖率、用例总数。别上复杂方案维护成本远高于收益。7. 常见坑实录前面讲的是应该怎么做这一节讲实际做的时候会怎么翻车。这些都是我在项目里真实遇到的而且大多不是工具的问题是对测试环境理解不到位造成的。7.1 静态函数与内部全局变量static函数和static全局变量最大的麻烦是外部看不到。Cantata 通过生成访问接口解决了这个问题但访问接口本身有使用约束。对静态函数的测试如果该函数调用了其他静态函数那么这些内部调用关系也需要被正确处理。如果你把内部被调用的函数也打了桩那测的就不是原来的调用链了。我建议测静态函数时默认让它调用真实的内部实现只在确实需要隔离外部依赖时才打桩保持调用链的完整性。对静态全局变量访问接口的读写会绕过一些编译器的优化假设。如果这个变量在源码里被声明为volatile或者参与中断处理测试环境下读写它的行为和真实运行会不一致。这类变量在测试计划里要单独标注避免误判。我遇到过测试通过了但实际硬件跑挂的案例原因就是测试环境下全局变量的更新顺序和数据手册要求的不一致测试工具没法帮你发现这种问题。7.2 同一个函数被调用多次后的断言错位前面提过一次这里展开说。假设被测函数内部连续调用了两次calculate()你想验证第一次的结果和第二次不同CALL(func_under_test()); CHK_RETURN_VALUE(expected_first); CHK_RETURN_VALUE(expected_second);第二行断言取的还是最后一次调用func_under_test的返回值而不是两次calculate各自的返回值。要验证内部调用得用桩的调用记录和参数约束或者给calculate设置返回值序列再由被测函数自己处理。这个坑的表象是断言莫名其妙失败实际是语义理解错了。我的处理办法是先明确断言的对象是哪一次调用把每次调用单独编号在注释里写清楚这次断言验证的是哪一步再写代码。7.3 编译选项与宏定义不一致这是覆盖面最广的一类坑前面已经提过一次但值得再强调。测试脚本编译时用的宏定义、头文件路径、语言标准必须和被测代码的实际编译环境严格一致。判断是否一致有个简单办法把测试环境编译出的目标文件和实际项目编译出的同名模块的目标文件做对比看符号表和字符串常量是否基本一致。差异太大的话说明条件编译分支差异明显测试的就不是同一份代码。还有一种隐蔽的情况头文件路径顺序不同导致同名头文件被不同版本的头文件抢先包含。这种问题在配置复杂的大项目里很常见排查时优先检查头文件搜索路径的顺序而不是怀疑测试脚本。7.4 桩函数和真实实现的符号冲突打完桩之后链接阶段可能出现符号重复定义或者调用了不该调用的实现。原因是桩函数的符号名和被测代码链接的真实实现同名而链接顺序决定了用哪个。标准做法是让桩函数在链接时优先于真实实现或者干脆把真实实现所在的源文件从测试链接里排除。Cantata 的工程配置里通常能控制这个但要注意别把被测函数自己所在的文件也排除了。如果被测模块是编译成库再链接的那桩替换会更麻烦因为库文件里的符号优先级比较高。这种情况我一般会把被测源码直接以源文件形式编译进测试工程而不是链接预编译库虽然编译慢一点但符号控制更清晰。8. 我在长期使用中攒下的几条实用经验说到最后分享几个不太写在文档里但确实省时间的小做法。测试脚本的命名我坚持用一个固定格式TC_序号_被测函数_场景描述。序号保证执行顺序可控场景描述让失败信息一眼能看出问题在哪。失败报告里显示的就是函数名名字起得好看报告的时间能省一半。每个测试用例只验证一件事。听起来是废话但实际写的时候特别容易破戒——想着顺手把这个也验了结果用例失败时根本不知道是哪个逻辑出问题。拆开写虽然用例数量多了但定位成本大幅下降。桩函数的默认返回值不要随便设成 0。0 在很多场景下是合法的成功返回值容易掩盖桩根本没被调用的情况。我习惯把默认值设成一个明显的非法值比如 -9999一旦断言里出现这个数就知道是桩配置漏了。覆盖率报告定期看但不要每天追。每天追会让团队把精力花在凑数字上而不是思考测试有没有真正验证逻辑。我的节奏是每个迭代结束看一次趋势重点看新代码的覆盖率老代码的绝对值作为参考。一开始不要把目标定成全量 MC/DC。先从关键模块的判定覆盖做起把流程跑顺、把桩和配置的经验攒起来再逐步提高标准。工具本身不难学难的是把它用在合适的深度上——测得太浅没意义测得太深维护不动找到中间那个平衡点才是这套工具真正发挥价值的地方。
返回列表