安全关键嵌入式单元测试:可重放、隔离打桩与分类树用例设计 简介PDF白皮书《Tessy单元测试提高软件质量》面向嵌入式软件测试工程师、开发人员与质量保证人员讨论安全关键软件中单元测试的定位、标准要求与实践方法可帮助读者对照IEC 61508、ISO 26262、Automotive SPICE和IEC 62304梳理测试策略。压缩包仅含1个PDF文件约1.87MB篇幅精炼便于查阅。已有4876人学习下载。正文先说明单元测试的定义、适用时机、执行角色与自动化必要性再以Tessy为例展示被测单元、接口、初始测试用例、执行、故障排查和回归测试流程随后系统讲解分类树方法从识别测试相关方面、逐层分类、重复分类到在组合表中指定测试用例并附示例与工具支持说明。读者可借此掌握借助Tessy设计、执行和复盘单元测试的思路理解代码覆盖率与测试文档要求适合作为嵌入式单元测试入门与进阶参考。1. 单元测试的真正价值可重复重放很多人第一次接触单元测试工具时都默认它是一个用来查找缺陷的工具。但在安全关键嵌入式项目里单元测试真正交付的成果是一份能被反复重放的证据改了实现之后昨天的用例今天还能原样跑一遍并给出同样的结论。功能安全标准把“可重复、可回归、可追溯”写进测试要求却从不把“发现了多少缺陷”写进去。这就解释了一个常见现象手工执行出来的单元测试往往活不过第二次迭代被测函数改一行没人愿意把几十个用例重新喂一遍测试也就没人愿意再跑了。TESSY 这类工具的价值本质上就是把“重放”的成本压到接近零分类树方法解决的是另一个问题这些用例到底该从哪儿来。两件事凑在一起才谈得上提高软件质量。2. 隔离与桩单元测试为什么必须把被调函数换掉2.1 单元边界与接口测试用例的结构从哪来不同标准用词并不统一通用功能安全标准称“模块测试”汽车功能安全标准称“单元测试”机载适航标准谈“低级需求测试”。但如果把定义落到 C 语言上术语差异就消失了——一个单元等价于一个 C 函数也就是那个能脱离其余代码被独立测试的最小对象。这个“最小”不是抽象口号它直接决定了测试用例长什么样。单元的接口由三样东西构成函数读取的变量输入、函数写入的变量输出、以及返回值。三者可以重叠比如某个计数变量在函数里被 1它同时是输入也是输出。更广义地说被测单元调用的那些函数也属于接口的一部分因为它们决定了被测单元在真实环境中的行为。接口一旦确定测试用例的结构就确定了一个用例 一组输入变量的具体值 一组期望的输出变量值。这也说明了为什么单元测试是黑盒的。被测单元内部用 if 还是查表、有没有提前 return都不该影响用例设计——判断依据只有规格给定这组输入接口上应该呈现什么结果。这种“只认接口”的立场看着朴素却是后面所有机制的地基只有接口是稳定的用例才可能被重放只有用例可重放回归才有意义。2.2 原二进制与测试应用两条执行路径的代价差在哪同一个被测函数可以有两种跑法代价完全不同。第一种是原二进制测试把整个应用按最终形态编译、链接、分配好放进调试器在被测函数入口打断点让程序跑到断点然后喂入测试数据、单步或继续执行、读取结果再回到断点执行下一个用例。它的好处很实在——被测单元运行在它将来真正的地址上不需要为测试额外做一次编译和链接甚至目标板上已经是固件的场景也能直接测只要有个调试器连编译器都不必装。代价也很实在。第一被测函数在正常业务流程里不一定被调用或者调用它的时机不可控你得靠翻开关、触发中断这类特殊手段把它逼出来。第二没法打桩被测函数调用谁就真的调用谁于是别的单元改了一行这个单元的回归就红了看起来像是本单元退化其实是下游行为变化串了进来。第三输入数据的自由度受应用本身限制如果被测函数通过指针接收数据你能塞进去的数据量上限就是应用给这块缓冲区预留的大小。第二种是测试应用只从原工程里拿出被测函数另外配一个main()、启动代码以及被测函数所调用的那些函数的替身桩单独链接出一个专供测试的可执行程序。每个被测单元对应一份自己的测试应用。它对存储的处理有两种做法——把测试数据全部烧进测试应用里简单但用例数量一多测试应用可能塞不进目标板的存储或者把测试数据留在宿主机上跑哪个用例就下发哪个代价是通信开销略高好处是用例再多也不会撑爆目标存储。维度原二进制测试测试应用测试被测函数运行位置最终产品的真实地址测试专用程序额外编译链接不需要每个单元一份需要能否打桩基本不能可以且推荐输入数据自由度受应用预留缓冲区限制由测试程序自行分配用例间相互干扰下游改动会造成假失败被隔离目标机存储压力无额外压力数据烧录式方案压力大一条实用的判断是如果目标是一个已经烧在板子上、源码都不一定齐全的历史固件原二进制是唯一选择如果是正在开发的工程测试应用方案几乎总是更划算因为你买到的是隔离性和可复现性。2.3 桩函数可复现性的真正来源桩不是“随便返回一个值”的空函数。它的职责是让被测单元在与下游实现解耦的前提下仍然表现出正确的接口行为。一个写坏了的桩会让整轮测试的结论都不可信——这也是实际使用单元测试工具时最常见的问题。/* stub_sensor.c —— 传感器读取函数的桩 * 返回值通过测试夹具设定同时记录调用次数供用例断言 */ static int g_stub_value; /* 由用例在执行前设定 */ static int g_stub_call_cnt; /* 调用计数用例结束后检查 */ void stub_reset(int fixed_value) { g_stub_value fixed_value; /* 每个用例开始前重置避免用例间串味 */ g_stub_call_cnt 0; } int sensor_read(int channel) { (void)channel; /* 桩不关心参数但保留签名与真实函数一致 */ g_stub_call_cnt; return g_stub_value; } int stub_call_count(void) { return g_stub_call_cnt; }这段代码里有三个容易被忽略的点。stub_reset在每个用例执行前重置状态否则上一个用例的残留值会让结果出现漂移桩的函数签名必须和真实函数完全一致包括参数类型和返回类型否则链接期替换会失败或者行为未定义调用计数让“被测单元是否按预期次数调用了下游”变成一个可断言的输出这在验证错误处理和重试逻辑时尤其有用。桩的粒度是个取舍桩太粗比如让所有下游函数都返回同一个默认值被测单元走的分支就单一测不出东西桩太细为每个下游函数写一套可配置的行为模型维护成本会超过收益。经验做法是只对影响被测单元控制流的下游函数做可配置其余一律用最简单的不变返回值。2.4 第一个用例区间判断函数怎么翻车原文给的例子很小但把“接口决定用例”这句话演示得很清楚判断一个整数是否落在由起点和长度确定的区间内即start value start length只考虑整数。/* in_range.c —— 判断整数值是否落在 [start, startlen) 区间内 * 返回 1在区间内返回 0不在区间内 */ int in_range(int start, int len, int value) { int end start len; /* 先算上界注意 start len 可能溢出 */ if (value start) { return 0; /* 小于起点直接判否 */ } if (value end) { /* 缺陷点上界应为开区间 把 end 本身也算进来了 */ return 1; } return 0; }参数含义start是区间起点len是区间长度按契约应为正整数value是待判定值返回值表示判定结果。按规格start 5、len 2时合法集合是{5, 6}。对应的用例执行结果是这样用例startlenvalue期望返回实际返回结论TC152511通过TC252611通过TC352701失败TC452400通过作为工具侧的演示TESSY 会从源码里自动识别出这个函数的接口——三个入参和返回值外加它读写的全局变量——然后据此生成一份测试应用把用例数据下发到目标板执行再把输出变量的实际值回读上来做比对。识别接口这一步如果靠人工一个中等规模的工程跑一遍就是几天的工作量而且每改一次函数签名就要重来一次。这就是自动化的真实意义它的价值在于让“改完代码重跑一遍”从一项需要排期的任务变成一次点击。3. 分类树方法把想不全的用例变成可枚举的组合3.1 三个层次测试方面、分类、类上面那个例子里四个用例是怎么想出来的如果答案是“凭经验”那换个人、换个模块就不可复现了。分类树方法要解决的就是这个问题给出一套可重复的推导步骤让用例规格变成“枚举出来的”而不是“想到的”。它的结构只有三层。最上面是测试方面即与测试相关的关注点比如“区间是否有效”“待判定值相对区间的位置”“数值边界是否溢出”。每个方面向下拆成若干分类分类下面再列出具体的类——类就是取值的等价分组。如果一个类下面还有值得区分的子情况就继续往下拆于是整棵树是递归生长的。以区间判断函数为例树大致长这样区间有效性下有len 0、len 0、len 0三个类值位置下有“小于 start”“等于 start”“区间内部”“等于 end”“大于 end”五个类数值边界下有“普通值”和“INT_MAX 附近”两个类。这个结构的好处是每一步拆分都要求回答“为什么这几类要分开”——分开的标准是它们的处理逻辑不同或者它们的期望结果不同。凡是拆不出差异的方面就不该出现在树上否则只会增加噪声。3.2 组合表与组合爆炸怎么从笛卡尔积里挑行树建好之后把每个分类作为一列、每个类作为该列的可选值逐行填入组合就得到组合表。全组合是笛卡尔积3 × 5 × 2 30 行真跑起来成本不低。所以关键动作不是“全填”而是“挑行”。挑行有两条基本规则每个类至少被覆盖一次边界类和异常类必须出现在此之上再考虑类与类之间是否存在真实的耦合关系只对存在耦合的列做交叉。组合区间有效性值位置数值边界期望C1len 0等于 start普通值在区间内C2len 0区间内部普通值在区间内C3len 0等于 end普通值不在区间内C4len 0大于 end普通值不在区间内C5len 0等于 start普通值不在区间内C6len 0区间内部普通值规格未定义应报错C7len 0等于 endINT_MAX 附近不在区间内且不溢出这份表的直接产物就是 C3 和 C7 两个用例。C3 恰好命中了上一节那个缺陷C7 则暴露了另一个问题——start len在接近整数上限时会溢出end变成负数判定结果完全失真。这类缺陷靠“随手写几个用例”基本碰不到靠分类树里“数值边界”这个分类却几乎是必然产物。方法的价值就体现在这里它把“测得出”从运气问题变成了流程问题。3.3 覆盖率结构指标的下限不是质量的上限用例跑完之后工具通常会给出语句覆盖、分支覆盖、条件覆盖乃至更严格的修正条件判定覆盖等指标安全等级越高要求越严。这些数字有用但很容易被误读。覆盖率是结构指标它衡量的是“你的用例走过了代码里多少条路径”。它看不见规格。如果开发者漏实现了一个需求那段代码在源码里根本不存在任何覆盖率工具都不会报警——覆盖率 100% 的函数照样可能是功能残缺的。这就是为什么分类树这类从规格侧推导用例的方法不能省规格侧负责“该测什么”覆盖率负责“还有哪些代码没被走到”两者互补而不替代。另一个误读是把覆盖率高当成测试充分的证据。语句覆盖 100% 只说明每行都执行过不说明每行的每种结果都验证过分支覆盖能补上后半句但循环边界、数据组合的遗漏依然存在。在实际项目里我的习惯是把覆盖率当作下限门槛——低于阈值说明用例明显不足必须补达到阈值绝不等于可以停下来那只说明用例的数量够了充分性还得回到规格和分类树上判断。4. 谁写用例、谁执行单元测试的组织方式4.1 开发者测自己代码的盲区在哪让写代码的人测自己的代码问题不在态度而在认知。如果开发者漏实现了规格里的某段逻辑他在设计用例时也大概率想不到去测那段逻辑——因为在他的心智模型里那功能已经在了。这种盲区靠“更仔细一点”解决不了它属于结构性缺陷。第二个盲区在“意外输入”。开发者写代码时通常假设调用方守规矩于是用例也围绕“正常输入”展开对空指针、超出范围的枚举、长度为负这类情况天然不敏感。而这类输入恰恰是安全关键软件出问题的主要来源。4.2 外部测试者接手有哪些成本既然开发者不适合测自己的代码换个人来测行不行理论上更合理实践中有一个很一般的成本外部测试者要先花大量时间搞懂接口。他得弄清楚一个表示颜色的输入变量其取值究竟是 RGB 三个分量还是枚举序号还得判断某个结果变量的取值到底算对还是算错。这些知识原本存在于开发者的脑子里。于是大部分测试时间花在“读懂接口”上而不是“设计用例”上。这不能算浪费但确实是一笔不小的开销尤其是在模块数量多、接口风格不统一的项目里。4.3 分工方式规格侧写抽象用例实现侧做具体映射一种折中的做法是把测试活动切开。由没有参与编码的人基于规格设计用例并且直接写出期望结果用例里用抽象数据表达——比如“输入颜色为绿色”。这套抽象用例交给开发者由他负责把抽象值映射成具体值比如某个 RGB 三元组并执行。如果某个用例的结果不符合期望开发者当场就能进入调试和修改因为他最清楚实现细节。这个做法的巧妙之处在于它把“该测什么”和“怎么喂进去”分给了各自最有信息优势的一方。代价也很清楚它并不满足部分标准对开发与测试组织分离的要求而且让开发者承担大量测试执行工作在项目排期上往往很难被接受。所以这种形态在实践中并不常见通常是团队里有明确的测试角色、且测试工具能把执行成本压得很低时才会采用。无论人选怎么分有一件事是所有标准都咬死不放的测试必须可重复。被测对象或开发环境一改之前的测试就得能重跑。手工执行做不到这一点因为代价太高。所以至少要把执行环节自动化交给工具去完成接口识别、测试数据管理、测试应用生成、执行、结果评估、覆盖率统计和测试文档生成。这六件事串起来一次回归才可能真的变成“一次点击”。5. 避坑与排查单元测试落地时最容易翻车的五个地方5.1 构建与执行类翻车现象一明明没动过被测函数回归却红了。原因大多是没有打桩被测单元真实调用了下游函数下游的行为变化通过副作用传了进来也可能是某个桩在两个用例之间没有复位前一个用例的残留状态影响了后一个。解决方法是先确认测试应用里被调函数是否已被替换再检查每个用例执行前是否都有一次夹具重置把用例的执行顺序打乱跑一遍如果结果随之变化基本可以确定是用例间状态没有隔离。现象二用例数量一多测试应用加载失败或者跑不起来。原因是把全部用例的测试数据都烧进了测试应用目标板存储放不下。解决办法是把测试数据留在宿主机执行哪个用例就下发哪个通信开销会增加一些但用例规模从此不再受目标存储限制。现象三通过指针传参的用例数据只能塞进去一点点。这是原二进制方案的固有约束——缓冲区大小由应用本身预留测试侧无法越界。如果被测函数需要大量输入数据就得换到测试应用方案由测试程序自己分配缓冲区。顺带说一句选用原二进制方案前最好先确认被测函数在正常业务流程里到底能不能被触发否则你会为了让它跑起来去改产品代码那真是自己给自己制造麻烦。5.2 用例设计类翻车现象四覆盖率刷到 100%交付后仍然出问题。原因很典型覆盖率是结构指标它不会告诉你规格里有一条需求根本没实现也不会告诉你两个输入参数的组合从未被同时取到边界值。解决办法是双轨并行——用例来源以分类树为准从规格侧保证“该测的都测了”覆盖率只作为补齐下限的检查手段低于阈值必须补用例达到阈值则回头再看一遍分类树有没有漏掉的类。现象五把时序问题、多单元交互问题塞进单元测试。单元测试的边界就是单个函数在接口上的行为它答不了“两个单元之间的时序是否满足”这类问题硬塞进来只会得到一批结果不稳定、时好时坏的用例。正确做法是把这类验证上移到集成测试单元测试只负责守住“这个函数在给定输入下输出对不对”这一条线。守住这条线才是它提高软件质量的真实贡献。6. 取舍覆盖率阈值、桩的粒度与工具的边界单元测试做到什么程度算够没有一个放之四海皆准的数字。安全等级高的模块覆盖率门槛定得高是合理的因为它出错代价大但把同样阈值套到全部模块上只会催生一批为了凑数而写的用例。更实际的做法是按风险分层先用圈复杂度、调用频次、功能安全等级这几个维度给单元排个优先级复杂且被频繁调用的先测透简单的 getter 之类的走个语句覆盖即可。这个排序动作花不了多少时间却能决定有限测试资源投向哪里。桩的粒度同样是个成本问题。我倾向于把桩分成两类影响被测单元控制流的做成可配置能返回不同值、能模拟失败不影响控制流的一律用最简返回值。前者决定你能测出多少分支后者只为了保证链接能过。把两者混为一谈要么测不透要么维护成本失控。至于工具的边界得说清楚单元测试工具能自动识别接口、生成测试应用、管理用例、统计覆盖、导出文档这些都是“重放成本”的组成部分压得越低回归就越可能真的被执行。但它不会替你判断规格是否完整也不会告诉你某个分类该拆成几类。分类树那部分工作依然要靠人对规格的理解——工具负责执行人负责定义“什么叫做对了”。我刚接手安全关键模块那会儿曾经因为嫌麻烦跳过了给下游函数打桩这一步直接拿原二进制跑了一轮全部通过还挺得意。两周后下游一个函数改了默认返回值同一批用例集体标红我花了半天才确认被测函数一行没动。从那以后我每次建测试工程第一件事就是把桩和夹具复位写进去宁可多写二十行也不让回归结果里混进别人的变更。希望帮到你。本文还有配套的精品资源点击获取