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

资讯详情

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

嵌入式C语言TDD实战:Unity+CMock工具链与硬件解耦设计

嵌入式C语言TDD实战:Unity+CMock工具链与硬件解耦设计 1. 项目概述为什么嵌入式C也需要测试驱动开发如果你是一名嵌入式软件工程师每天打交道的是单片机、传感器、实时操作系统和有限的内存资源当听到“测试驱动开发”这个词时你的第一反应是什么是觉得那套写测试、写代码、重构的循环太“重”不适合资源紧张的嵌入式环境还是认为硬件依赖太强单元测试根本无从下手几年前的我也是这么想的。直到项目里的Bug像地鼠一样层出不穷每次修改一个看似简单的功能都可能引发另一处看似无关的模块崩溃我才开始正视这个问题。《Test-Driven Development for Embedded C》这本书正是为解决这种困境而生的。它不是一个空谈敏捷方法论的理论册子而是一本由资深嵌入式开发者James W. Grenning撰写的实战指南。这本书的核心价值在于它打破了“TDD只适用于高层应用开发”的迷思用一套完整的工具链和实践方法证明了即使在最“底层”的C语言嵌入式开发中测试驱动开发不仅可行而且是提升代码质量、加速开发节奏、降低长期维护成本的利器。它瞄准的正是我们这些在寄存器、内存映射和硬件中断中挣扎却又渴望写出健壮、可靠代码的工程师。这本书适合谁首先是那些正在或即将使用C语言进行嵌入式开发的工程师无论你是刚入门的新手还是经验丰富的老手只要你对代码质量有要求这本书都能提供新的视角。其次是团队的技术负责人或架构师如果你在寻找一种能够提升团队交付物可靠性的工程实践书中的团队协作和持续集成部分极具参考价值。最后甚至是对TDD感兴趣的应用层C/C开发者书中的许多测试理念和框架使用技巧是相通的。简单说这本书解决的核心问题是如何在资源受限、硬件依赖强的嵌入式C开发环境中系统化地实施TDD从而交付缺陷更少、设计更清晰、更易于更改的固件代码。2. 核心理念与范式转变从“调试驱动”到“测试驱动”在深入工具和实操之前我们必须先完成一次思维上的“范式转变”。传统的嵌入式开发流程我称之为“调试驱动开发”。通常的步骤是理解需求 - 直接编写实现代码 - 将代码烧录到目标板 - 通过串口打印、点灯或调试器单步执行来验证功能 - 发现错误 - 回头修改代码 - 再次烧录测试。这个循环不仅耗时而且极度依赖硬件测试覆盖点随机很多边界条件和异常路径只有在特定情况下才会暴露甚至到了客户现场才爆发。TDD则将这个流程彻底翻转。它的核心循环是“红-绿-重构”红针对一个微小、明确的功能点先编写一个必定会失败的单元测试。这个测试定义了代码的“行为契约”。绿编写最少、最简单的代码让这个测试通过。此时不关心代码结构是否优美只关心功能正确。重构在测试通过的保护下优化代码结构消除重复提升可读性和可维护性同时确保测试始终为绿。这个循环的关键在于“先写测试”。对于嵌入式开发者这听起来可能反直觉——我连硬件抽象层都没写怎么测试这本书的精髓就在于它通过“双循环开发”和“仿冒对象”等模式回答了这个问题。2.1 双循环开发模式解耦硬件与逻辑这是将TDD引入嵌入式领域的基石。我们不再直接在目标硬件上开发业务逻辑而是将开发分为两个层次单元测试循环在开发主机如你的PC上运行。这里使用专门的单元测试框架如书中最推崇的UnityCMock组合测试纯粹的C语言业务逻辑和算法。这个环境完全脱离目标硬件运行速度快秒级反馈即时。系统测试循环在目标硬件或高保真模拟器上运行。这里测试的是与硬件紧密相关的代码、集成后的模块以及整体的系统行为。这个循环较慢但用于验证硬件交互的正确性。大部分开发时间可能超过80%都在第一个循环中。我们通过设计将业务逻辑与硬件操作如读写寄存器、发送SPI数据分离开来。业务逻辑代码在任何有C编译器的环境中都可测试而将硬件操作封装成一层薄薄的、可替换的“适配器”接口。在单元测试中我们用“仿冒对象”来替换这些硬件适配器从而模拟各种硬件行为正常、异常、超时等实现对业务逻辑的充分测试。2.2 测试驱动带来的设计收益强迫自己先写测试会倒逼出更好的软件设计这可能是比“减少Bug”更重要的长期收益高内聚、低耦合为了让代码可测试你必须思考模块间的依赖关系。自然而然地你会得到功能内聚、接口清晰的模块模块之间通过定义良好的接口通信而不是直接操作全局变量或硬件寄存器。明确的接口契约测试用例本身就是对接口行为最精确的文档。它说明了在给定输入下期望得到什么输出或发生什么副作用。应对变化的勇气当需要修改或增加功能时背后有完整的测试套件作为安全网。你可以大胆重构代码只要测试通过就有信心没有破坏原有功能。这极大地提升了代码的演化能力。注意很多工程师的抵触情绪来自于“写测试浪费时间”的误解。TDD初期可能会感觉速度变慢因为它要求更多的前期思考和设计。但从整个项目生命周期来看它通过减少调试时间、降低集成风险、简化重构往往能带来显著的净时间节省和更高的交付质量。这是一种投资而非开销。3. 工具链构建为嵌入式C量身定制的测试生态工欲善其事必先利其器。书中花了大量篇幅介绍如何搭建一个高效的嵌入式C TDD工具链。这套工具链的核心是“Unity”测试框架和“CMock”仿冒框架它们通常与“Ceedling”构建工具配合使用形成一套完整的解决方案。3.1 Unity极致简约的单元测试框架Unity是一个用ANSI C编写的、极其轻量级的单元测试框架。它的设计哲学是简单和可移植非常适合嵌入式环境。你甚至可以将它的核心源文件直接包含进你的项目。它的核心组件包括断言宏提供丰富的断言函数如TEST_ASSERT_EQUAL_INT(expected, actual),TEST_ASSERT_EQUAL_HEX8,TEST_ASSERT_EQUAL_STRING,TEST_ASSERT_EQUAL_FLOAT带容差等。这些宏在测试失败时会输出详细的错误信息包括文件名、行号、期望值和实际值。测试组织通过TEST()宏来定义一个测试用例RUN_TEST()宏来运行它。测试用例被组织在setUp()和tearDown()函数之间这两个函数在每个测试用例运行前后被调用用于初始化和清理测试环境。测试运行器你需要编写一个main函数在其中调用UNITY_BEGIN()然后按顺序RUN_TEST你的所有测试用例最后调用UNITY_END()。框架会汇总所有测试结果。一个简单的Unity测试示例// test_calculator.c #include unity.h #include calculator.h void setUp(void) { // 每个测试前的初始化例如初始化被测试模块 calculator_init(); } void tearDown(void) { // 每个测试后的清理 } void test_Add_TwoPositiveNumbers(void) { // 测试计算 2 3 int result calculator_add(2, 3); TEST_ASSERT_EQUAL_INT(5, result); // 断言期望值为5 } void test_Add_PositiveAndNegative(void) { // 测试计算 5 (-3) int result calculator_add(5, -3); TEST_ASSERT_EQUAL_INT(2, result); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_Add_TwoPositiveNumbers); RUN_TEST(test_Add_PositiveAndNegative); return UNITY_END(); }在主机上编译并运行这个程序如果calculator_add函数实现正确你会看到输出“2 Tests 0 Failures 0 Ignored”。3.2 CMock模拟依赖实现隔离测试这是嵌入式TDD中最强大的工具之一。嵌入式代码充满了对硬件的依赖如HAL_UART_Transmit、对其它模块的依赖如database_read。CMock能根据你的头文件自动生成这些依赖的“仿冒”版本。工作原理解析头文件你告诉CMock一个头文件例如hal_uart.h。生成仿冒模块CMock会分析头文件中的所有函数声明生成一个同名的仿冒源文件如Mockhal_uart.c和Mockhal_uart.h。控制与验证在你的测试代码中你可以设定返回值当被测代码调用HAL_UART_Transmit时让仿冒函数返回一个你预设的值例如成功HAL_OK或失败HAL_ERROR。验证调用检查被测代码是否以预期的参数、预期的次数调用了某个仿冒函数。触发回调模拟硬件中断让仿冒函数在调用时触发一个回调函数。实操示例测试一个通过UART发送命令的模块假设我们有command_sender.c它依赖hal_uart.h中的HAL_UART_Transmit函数。// test_command_sender.c #include unity.h #include Mockhal_uart.h // 引入CMock生成的仿冒头文件 #include command_sender.h void setUp(void) { } void tearDown(void) { } void test_SendCommand_ShouldTransmitCorrectData(void) { uint8_t expected_data[] {0xAA, 0x55, 0x01}; int expected_len sizeof(expected_data); // 1. 预期HAL_UART_Transmit将被调用一次 // 2. 预期调用时第二个参数数据指针指向的数据应与expected_data匹配 // 3. 预期第三个参数长度应为expected_len // 4. 设定当被调用时返回HAL_OK HAL_UART_Transmit_ExpectWithArrayAndReturn( UART_HANDLE, // 第一个参数句柄 expected_data, // 第二个参数期望的数据数组 expected_len, 1, // 第三个参数期望长度比较精度1字节 expected_len, // 第四个参数数据数组大小用于内部内存分配 HAL_OK // 返回值 ); // 执行被测函数 bool result send_command(COMMAND_ACTIVATE); // 验证被测函数的返回值以及隐含地通过CMock验证了期望的调用是否发生 TEST_ASSERT_TRUE(result); }在这个测试中我们完全隔离了硬件UART。我们并不真正发送数据而是验证了send_command函数是否正确地构造了数据包并尝试调用发送接口。CMock会在运行时检查这些“期望”如果调用未发生、参数不匹配或调用次数不对测试就会失败。3.3 Ceedling自动化构建与测试执行手动管理测试文件、编译、链接和运行会很繁琐。Ceedling是一个基于Ruby的构建工具它集成了Unity、CMock并提供了类似make的自动化流程。一个典型的项目结构如下your_project/ ├── project.yml # Ceedling配置文件 ├── src/ │ ├── module_a.c │ └── module_a.h ├── test/ │ ├── test_module_a.c # 测试文件 │ └── support/ # 可能放一些测试用的头文件 └── vendor/ ├── unity/ └── cmock/在project.yml中你配置源文件路径、测试文件路径、工具链、编译选项等。然后通过简单的命令即可完成所有工作ceedling test:all运行所有测试。ceedling test:module_a运行特定模块的测试。ceedling gcov:all生成测试覆盖率报告。ceedling clean清理构建文件。Ceedling极大地简化了TDD的日常操作让你可以专注于“红-绿-重构”的循环本身。4. 实战演练为一个LED控制器模块实施TDD让我们通过一个完整的、简化的实战例子将上述理念和工具串联起来。假设我们要为一个嵌入式系统开发一个LED状态指示器模块。硬件上有一个LED通过一个GPIO引脚控制高电平点亮。4.1 需求分析与任务分解首先我们不要想如何写LED_On()函数。而是从需求出发将其分解为可测试的微小任务LED模块需要初始化配置对应的GPIO引脚为输出模式。可以打开LED置高电平。可以关闭LED置低电平。可以切换LED状态如果当前是开则关反之亦然。可选可以查询LED当前状态。我们选择从最简单的“打开LED”开始。4.2 第一个测试LED_On步骤1创建测试文件与头文件在test/目录下创建test_led_controller.c。同时创建src/led_controller.h先定义接口。// src/led_controller.h #ifndef LED_CONTROLLER_H #define LED_CONTROLLER_H #include stdbool.h void led_controller_init(void); void led_controller_on(void); #endif现在我们还没有.c文件这是TDD的常态先有接口测试后有实现。步骤2编写第一个失败的测试红// test/test_led_controller.c #include unity.h #include Mockhal_gpio.h // 假设我们依赖一个硬件抽象层GPIO模块 #include led_controller.h void setUp(void) { // 可以在这里初始化一些东西目前可能不需要 } void tearDown(void) { // 清理 } void test_LedControllerOn_ShouldSetGpioPinHigh(void) { // 期望初始化后调用led_controller_on()时 // 应该以正确的参数调用HAL_GPIO_WritePin函数一次将引脚置高。 // 假设LED连接的引脚是GPIO_PIN_5端口是GPIOA。 HAL_GPIO_WritePin_Expect(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); // 执行调用初始化虽然测试不关心但实际函数可能需要 led_controller_init(); // 执行调用打开函数 led_controller_on(); // 验证由CMock的_Expect宏完成。如果调用不符合预期测试会在此失败。 }使用Ceedling运行这个测试ceedling test:led_controller。毫无疑问它会编译失败因为led_controller.c不存在函数未定义。这就是“红”。步骤3编写最少代码通过测试绿创建src/led_controller.c并实现最简单的、能让测试通过的代码。// src/led_controller.c #include led_controller.h #include hal_gpio.h // 包含真实的硬件头文件 void led_controller_init(void) { // 为了通过测试初始化函数可以暂时为空或者进行必要的GPIO初始化。 // 但我们的测试目前没有对init做期望所以空着也能通过。 // 注意后续测试会驱动我们完善init函数。 } void led_controller_on(void) { // 这是测试期望的行为向指定引脚写入高电平。 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); }再次运行测试。现在编译应该成功并且测试通过绿CMock验证了HAL_GPIO_WritePin确实被调用了一次且参数正确。步骤4重构目前代码很简单没什么可重构的。但我们已经建立了一个模式测试定义了led_controller_on的行为是“调用HAL_GPIO_WritePin设置特定引脚为高”。4.3 驱动出设计初始化与硬件抽象接下来我们为led_controller_init添加测试。这驱动我们思考一个重要问题硬件的配置参数如GPIO端口、引脚号应该写死在.c文件里吗为了更好的可测试性和可移植性最好通过初始化函数传入。步骤1修改接口增加配置参数// src/led_controller.h typedef struct { GPIO_TypeDef* port; uint16_t pin; } led_controller_config_t; void led_controller_init(const led_controller_config_t* config); void led_controller_on(void); // 注意on函数现在不需要参数因为它操作的是初始化时配置好的那个LED。步骤2编写初始化测试// test/test_led_controller.c (新增测试) void test_LedControllerInit_ShouldConfigureGpioPinAsOutput(void) { led_controller_config_t config {GPIOA, GPIO_PIN_5}; GPIO_InitTypeDef expected_init_struct; // 期望HAL_GPIO_Init被调用一次并且其第二个参数InitStruct的某些字段符合预期 // 例如模式应为输出模式引脚号正确。 // 由于HAL_GPIO_Init是一个复杂结构体参数我们可以使用CMock的_ExpectMem或忽略某些字段。 // 这里简化处理我们假设有一个仿冒函数可以检查关键字段。 // 实际上我们可能需要更精细的仿冒或使用“忽略”功能。 HAL_GPIO_Init_IgnoreAndReturn(HAL_OK); // 先忽略参数细节只关心被调用 led_controller_init(config); }这个测试驱动我们实现init函数它需要调用HAL_GPIO_Init。同时我们需要一个模块内部的静态变量来保存config以便on/off函数使用。步骤3实现初始化与状态保存// src/led_controller.c #include led_controller.h #include hal_gpio.h static led_controller_config_t led_config; // 静态变量保存配置 void led_controller_init(const led_controller_config_t* config) { if (config NULL) return; // 简单的错误处理 led_config *config; // 保存配置 GPIO_InitTypeDef gpio_init {0}; gpio_init.Pin led_config.pin; gpio_init.Mode GPIO_MODE_OUTPUT_PP; // 推挽输出 gpio_init.Pull GPIO_NOPULL; gpio_init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(led_config.port, gpio_init); } void led_controller_on(void) { HAL_GPIO_WritePin(led_config.port, led_config.pin, GPIO_PIN_SET); }现在我们有了一个可配置的LED控制器。on函数的行为依赖于init时传入的配置这通过静态变量实现。我们的测试也需要更新在setUp中初始化配置或者每个测试自己初始化。4.4 完善功能添加Off、Toggle和状态查询遵循同样的“红-绿-重构”循环我们可以快速添加其他功能。测试驱动off和toggle// test/test_led_controller.c void test_LedControllerOff_ShouldSetGpioPinLow(void) { HAL_GPIO_WritePin_Expect(led_config.port, led_config.pin, GPIO_PIN_RESET); led_controller_off(); } void test_LedControllerToggle_ShouldInvertGpioPinState(void) { // 注意Toggle可能通过HAL_GPIO_TogglePin实现或者通过ReadPin和WritePin组合实现。 // 假设HAL提供了TogglePin。 HAL_GPIO_TogglePin_Expect(led_config.port, led_config.pin); led_controller_toggle(); }实现这些函数非常简单就是调用对应的HAL函数。测试驱动状态查询这是一个更有趣的例子。状态查询不应该直接去读GPIO引脚因为那可能是硬件状态而我们想查询的是软件“认为”的状态或者我们需要同时考虑。通常我们会在模块内部维护一个软件状态变量。// test/test_led_controller.c void test_LedControllerGetState_ShouldReturnTrueWhenOn(void) { // 场景先打开LED然后查询状态 HAL_GPIO_WritePin_Expect(led_config.port, led_config.pin, GPIO_PIN_SET); led_controller_on(); // 此时模块内部的软件状态应为“开” TEST_ASSERT_TRUE(led_controller_get_state()); } void test_LedControllerGetState_ShouldReturnFalseWhenOff(void) { HAL_GPIO_WritePin_Expect(led_config.port, led_config.pin, GPIO_PIN_RESET); led_controller_off(); TEST_ASSERT_FALSE(led_controller_get_state()); }这个测试驱动我们在模块内部增加一个bool led_state变量在on/off时更新它get_state时返回它。这比直接读取硬件引脚更可控也更容易测试。4.5 重构与设计优化在实现了一系列功能后我们审视代码。发现on、off、toggle都直接操作硬件而get_state操作的是软件缓存。这里存在一个潜在的不一致如果硬件被外部因素改变了状态软件缓存就失效了。这是一个设计决策点。方案A纯软件状态on/off只更新软件状态并调用硬件操作。get_state返回软件状态。一致性高但硬件状态可能因外部短路等原因与软件状态不符。方案B实时读取硬件get_state直接读取硬件引脚。状态绝对真实但每次查询都有硬件访问开销且测试时需要对HAL_GPIO_ReadPin进行仿冒。方案C混合模式默认使用软件缓存但提供一个led_controller_sync()函数来从硬件同步状态。TDD没有给出唯一答案但它通过测试暴露了这些设计问题迫使我们在早期就思考并做出选择。假设我们选择方案A因为它简单且满足大多数指示用途。那么我们的代码就是一致的。最终的重构可能包括检查是否有重复的代码例如多个测试里都有的led_config初始化可以移到setUp中。确保头文件中的函数声明有良好的文档注释。考虑错误处理如init传入NULL指针。通过这个完整的循环我们从一个简单的测试开始逐步驱动出了一个具有初始化、开关、切换、状态查询功能的、可配置的、易于测试的LED控制器模块。所有业务逻辑都在主机上通过了测试无需动用一次开发板。5. 高级模式与集成测试策略当单元测试覆盖了大部分模块后我们需要面对更复杂的情况模块间集成、与真实硬件的交互、中断和异步操作。书中也提供了应对这些挑战的模式。5.1 针对依赖复杂模块的测试策略当你的模块依赖一个非常复杂的外部模块例如一个完整的文件系统或协议栈时为所有函数生成仿冒对象可能不现实。这时可以采用“函数指针替换”或“链接期替换”的方法。函数指针替换依赖注入 在模块内部不直接调用外部函数而是通过一个函数指针接口来调用。在单元测试中你可以将这个指针指向一个简单的测试桩函数在产品代码中将它指向真实的实现。// hal_interface.h typedef struct { int (*read_sensor)(void); void (*set_actuator)(int value); } hal_interface_t; // my_module.c static hal_interface_t hal; // 默认为空或默认实现 void my_module_init(const hal_interface_t* iface) { if (iface ! NULL) { hal *iface; } } int my_module_do_work(void) { int val hal.read_sensor(); // 通过接口调用 // ... 处理 val hal.set_actuator(new_val); return 0; }在测试中你可以传入一个自定义的hal_interface_t结构体其中的函数是你编写的、可预测的测试桩。5.2 集成测试与硬件相关测试单元测试再好也不能完全替代在真实硬件上的验证。对于硬件相关代码我们需要“硬件抽象层”的集成测试。HAL层测试针对你编写的硬件抽象层或直接使用芯片厂商的HAL库的封装层编写在目标板上运行的测试。这些测试通常被称为“系统测试”或“集成测试”。它们的目标是验证HAL函数是否按照芯片手册的说明正确操作了寄存器。这些测试可能涉及读取/写入特定的内存地址并使用调试器或逻辑分析仪验证结果。“桩”替换为“真身”在目标板上将单元测试中使用的仿冒对象Mock替换为真实的HAL实现。运行一个完整的测试套件验证模块与真实硬件协同工作是否正常。这可以捕捉那些因硬件时序、中断优先级等单元测试无法模拟的问题。使用测试夹具对于复杂的硬件交互如SPI通信、ADC采样可以制作简单的测试夹具Test Fixture例如用杜邦线连接MCU的SPI引脚到一个已知的ADC芯片测试代码读取的电压值是否在预期范围内。5.3 测试中断服务程序与异步逻辑测试ISR是嵌入式TDD中的一个难点因为ISR通常由硬件事件异步触发。策略如下将ISR瘦身为“事件记录器”ISR里只做最必要的事清除中断标志、读取关键数据、设置一个软件标志或向队列放入一个事件。复杂的处理逻辑放到一个由主循环或任务调用的“事件处理器”函数中。测试事件处理器这样你就可以像测试普通函数一样测试这个“事件处理器”。在测试中你手动设置软件标志或填充队列然后调用处理器函数验证其行为。间接测试ISR对于ISR本身可以编写一个特殊的、在主机上运行的测试直接调用ISR函数把它当作普通函数验证它是否正确设置了标志或数据。虽然这不能测试真正的异步性但可以验证其逻辑。6. 常见问题、陷阱与效能提升技巧在实际项目中推行嵌入式TDD你会遇到各种挑战。以下是一些常见问题及应对策略很多是实践中踩坑后总结的经验。6.1 如何说服团队和老板这是最大的非技术障碍。可以从以下几个角度切入质量与成本展示传统调试方法在项目后期修复Bug的高成本时间、人力、客户信誉。TDD能提前发现大部分逻辑错误。回归安全网当需要修改旧代码或添加新功能时完整的测试套件能给你信心避免“修改一处崩溃一片”。设计文档测试用例本身就是活的、不会过时的设计文档。新成员通过阅读测试能快速理解模块的行为。渐进式引入不要强求全盘推翻。可以从一个新模块、一个工具类库、或者一个Bug修复开始先用TDD做出一个样板用结果说话。6.2 测试代码本身成为维护负担如果测试代码写得不好确实会。遵循以下原则保持测试简洁一个测试函数只测试一个概念或场景。避免冗长的设置和复杂的断言。使用清晰的命名测试函数名应该像文档一样例如test_CalculateDiscount_ShouldApplyTenPercentForVIPCustomer。利用setUp和tearDown将测试的公共初始化/清理代码放在这里避免重复。定期重构测试代码和生产代码一样当测试代码变得混乱、重复时也需要重构。6.3 测试运行太慢单元测试应该极快秒级。如果慢检查是否混入了硬件测试确保单元测试在主机上运行不链接任何硬件库。测试依赖了庞大的第三方库尽量使用仿冒对象隔离这些依赖。测试数据量过大为算法测试选择有代表性的边界值而不是海量随机数据。6.4 如何处理全局变量和静态函数全局变量尽量避免。如果必须使用考虑通过函数接口来访问它这样在测试中可以通过仿冒来控制它。或者在测试文件中使用extern关键字声明它以便直接设置其值。静态函数静态函数static意味着“本文件内私有”。如果你发现需要测试一个静态函数这通常是一个设计信号这个函数可能足够重要应该被提取到一个独立的、可测试的模块中或者其功能应该通过公有函数间接测试。6.5 测试覆盖率追求100%100%的测试覆盖率是一个美好的目标但并非总是必要或经济的。优先保证复杂业务逻辑和算法必须高覆盖。错误处理路径如空指针检查、返回值判断等这些是Bug高发区。公共API的所有分支用户可见的行为都要测到。 对于一些简单的getter/setter或硬件直接映射的代码覆盖率可以适当放宽。使用像gcov这样的工具来生成覆盖率报告并关注那些未被覆盖的、重要的代码行。6.6 与持续集成结合将你的测试套件集成到CI/CD管道中如Jenkins, GitLab CI。每次代码提交都自动触发完整的单元测试构建和运行。这能立即发现因集成引入的破坏保持代码库的健康。对于嵌入式项目可以在CI服务器上交叉编译测试代码并在主机上运行。硬件相关的集成测试可以安排在夜间或定期的硬件测试台上进行。7. 个人实践心得与踩坑记录从我自己的经验来看在嵌入式项目中成功应用TDD最难的不是技术而是习惯和思维的转变。最初几周会非常别扭感觉束手束脚。但一旦跨过那个门槛你就会体会到那种“安全感”——对代码修改和重构的安全感。一个关键的体会是TDD并不能消除所有Bug尤其是硬件相关的、时序性的、并发性的Bug。但它能几乎消灭所有纯逻辑的、算法上的、接口契约上的Bug。这已经将调试工作的重心从“找逻辑错误”转移到了“解决系统集成和硬件交互问题”后者通常更复杂但数量少得多也更有挑战性。另一个心得是关于设计。TDD强迫你在写第一行产品代码之前就思考接口和依赖。很多时候仅仅为了写出一个可测试的测试你就会发现当前的设计有多么糟糕高耦合、职责不清从而在编码早期就进行重构得到更清晰、更模块化的设计。这比写完一大堆代码后再回头重构要容易得多成本也低得多。踩过的一个坑是“过度仿冒”。早期为了通过测试有时会把一个模块的所有依赖都仿冒掉。结果测试全部通过但集成时发现仿冒的假设和真实硬件的行为有细微差别比如一个函数在错误时返回-1而仿冒我们设成了0。因此仿冒的粒度要合适对于底层硬件操作仿冒其行为即可成功/失败、返回数据不必仿冒其内部状态机所有细节。对于更上层的模块仿冒可以更精确。最后不要试图一步到位。从一个小模块开始哪怕只是一个简单的数学工具函数。体验“红-绿-重构”的节奏感受测试带来的信心。然后逐渐扩展到有硬件依赖的模块学习使用CMock。像任何技能一样嵌入式TDD也需要练习。这本书提供了绝佳的地图和工具但真正的路需要你一行代码、一个测试地去走。
返回列表