28、头文件中的测试接口:单元测试宏、Mock 接口设计
说实话,很多嵌入式工程师对测试的态度是「能跑就行」。我以前也这么想,直到有一次在量产前发现一个定时器溢出bug,排查了整整三天——最后发现是某个模块的边界条件没测到。从那以后,我养成了一个习惯:在头文件里就把测试接口设计好。
今天咱们聊聊,怎么在头文件里优雅地嵌入单元测试宏和Mock接口。这不是花架子,是能救命的。
为什么要在头文件里放测试接口?
你想想看,C语言的模块化编程,头文件就是模块的「脸面」。对外暴露什么接口,隐藏什么实现,全看头文件怎么设计。但测试接口呢?传统做法是单独写个test.c,再include一堆头文件。这样做的问题很明显:
- 测试代码和源码分离,维护起来容易脱节
- 私有函数没法直接测,得用条件编译或者extern hack
- Mock对象无处安放,要么污染全局命名空间,要么写一堆重复代码
我的做法很简单:在头文件里用条件编译包裹测试接口。平时编译产品代码,这些接口是透明的;测试时打开一个宏,所有测试工具就位。
核心原则:测试接口是头文件设计的一部分,不是事后补丁。
单元测试宏:轻量级断言与测试框架
嵌入式环境资源有限,跑不了gtest这种庞然大物。我一般自己搓一套轻量级宏,放在头文件里。
基础断言宏
// test_utils.h
#ifndef TEST_UTILS_H
#define TEST_UTILS_H
#include <stdio.h>
#include <stdlib.h>
// 测试计数
static int test_pass = 0;
static int test_fail = 0;
#define TEST_ASSERT(cond, msg) \
do { \
if (!(cond)) { \
test_fail++; \
printf("[FAIL] %s:%d - %s\n", __FILE__, __LINE__, msg); \
} else { \
test_pass++; \
} \
} while(0)
#define TEST_ASSERT_EQ(a, b, msg) \
TEST_ASSERT((a) == (b), msg)
#define TEST_ASSERT_NEAR(a, b, tol, msg) \
TEST_ASSERT(((a) > (b) - (tol)) && ((a) < (b) + (tol)), msg)
#define TEST_REPORT() \
printf("\n=== Test Report: %d passed, %d failed ===\n", \
test_pass, test_fail)
#endif // TEST_UTILS_H
这里有个细节:do { ... } while(0) 包裹宏体,是为了防止在if-else语句中出问题。我以前吃过这个亏,宏展开后语法错误,排查了半天。
测试分组与夹具宏
当测试多了,需要分组管理。我习惯用这种模式:
// 测试分组开始
#define TEST_GROUP(name) \
static void test_group_##name(void)
// 测试用例
#define TEST_CASE(name) \
do { \
printf(" [TEST] %s\n", #name); \
/* 这里可以放setup代码 */ \
} while(0)
// 使用示例
TEST_GROUP(timer) {
TEST_CASE(overflow) {
// 测试逻辑
TEST_ASSERT(timer_overflow_flag == 1, "overflow should be set");
}
TEST_CASE(compare_match) {
// 测试逻辑
TEST_ASSERT_EQ(timer_compare_value, 1000, "compare value mismatch");
}
}
我的习惯:每个模块的头文件里,用 #ifdef UNIT_TEST 包裹测试宏。这样产品代码编译时,测试代码完全不占空间。
Mock接口设计:让依赖可控
嵌入式开发最头疼的就是硬件依赖。你写了个传感器驱动,但硬件还没回来,怎么测?Mock就是干这个的。
函数指针式Mock
最灵活的方式,是把依赖的函数声明为可替换的:
// sensor_driver.h
#ifndef SENSOR_DRIVER_H
#define SENSOR_DRIVER_H
#include <stdint.h>
// 真实I2C读写函数
int i2c_read(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len);
int i2c_write(uint8_t dev_addr, uint8_t reg_addr, const uint8_t *data, uint16_t len);
// 传感器初始化
int sensor_init(void);
// 读取温度
float sensor_read_temperature(void);
#ifdef UNIT_TEST
// 测试接口:替换I2C读写函数
typedef int (*i2c_read_t)(uint8_t, uint8_t, uint8_t*, uint16_t);
typedef int (*i2c_write_t)(uint8_t, uint8_t, const uint8_t*, uint16_t);
void sensor_set_mock_i2c(i2c_read_t read_func, i2c_write_t write_func);
#endif
#endif // SENSOR_DRIVER_H
在测试文件里,你可以这样用:
// test_sensor.c
#include "sensor_driver.h"
// Mock实现
static int mock_i2c_read(uint8_t dev, uint8_t reg, uint8_t *data, uint16_t len) {
// 返回预设值
data[0] = 0x1A; // 模拟温度高字节
data[1] = 0x2B; // 模拟温度低字节
return 0;
}
static int mock_i2c_write(uint8_t dev, uint8_t reg, const uint8_t *data, uint16_t len) {
// 记录写入操作
printf("Mock write: reg=0x%02X, data=0x%02X\n", reg, data[0]);
return 0;
}
void test_sensor_read(void) {
sensor_set_mock_i2c(mock_i2c_read, mock_i2c_write);
float temp = sensor_read_temperature();
TEST_ASSERT_NEAR(temp, 26.7, 0.1, "temperature should be ~26.7C");
}
注意:函数指针式Mock有性能开销。在中断服务函数或高频调用路径上慎用。我一般只在模块边界用,内部函数用宏替换。
编译期Mock:宏替换
如果Mock的函数是静态的或者内联的,可以用宏在编译期替换:
// uart_driver.h
#ifndef UART_DRIVER_H
#define UART_DRIVER_H
#include <stdint.h>
// 真实实现
int uart_send_byte(uint8_t byte);
// 发送字符串
void uart_send_string(const char *str);
#ifdef UNIT_TEST
// 编译期Mock:替换为测试桩
#define uart_send_byte(byte) mock_uart_send_byte(byte)
int mock_uart_send_byte(uint8_t byte) {
static uint8_t last_byte;
last_byte = byte;
return 0;
}
#endif
#endif // UART_DRIVER_H
这种方式的优点是零运行时开销,缺点是只能有一个Mock实现。适合简单场景。
测试接口的组织结构
我画了一张图,帮你理清头文件里测试接口的布局:
避坑指南:我踩过的雷
这些年用头文件测试接口,我遇到过不少坑。挑几个典型的说说:
- 宏展开导致多次求值:
TEST_ASSERT(++x > 0, "x should be positive")这种写法,宏展开后x会被加两次。解决方案:宏参数用局部变量暂存。 - 静态变量冲突:头文件里定义
static int test_pass,如果多个.c文件都include这个头文件,每个编译单元都有自己的副本。测试报告会不准。我后来改用全局变量加extern声明。 - Mock函数忘记重置:有一次测试A用例设置了Mock,B用例没设置,结果B用例调用了A的Mock,数据全乱套。现在我在每个测试用例开始前强制重置Mock。
我的经验:测试接口设计得越早越好。等代码写完了再补测试,你会发现很多接口根本没法测——耦合太深,Mock无从下手。
实际项目中的模板
最后,分享一个我常用的头文件模板。你可以直接拿来改:
// module_template.h
#ifndef MODULE_TEMPLATE_H
#define MODULE_TEMPLATE_H
#include <stdint.h>
// ========== 公共接口 ==========
int32_t module_init(void);
int32_t module_process(uint32_t input, uint32_t *output);
// ========== 测试接口(条件编译) ==========
#ifdef UNIT_TEST
#include "test_utils.h"
// Mock接口
typedef int32_t (*mock_init_t)(void);
typedef int32_t (*mock_process_t)(uint32_t, uint32_t*);
void module_set_mock_init(mock_init_t func);
void module_set_mock_process(mock_process_t func);
// 测试辅助函数
int32_t module_get_internal_state(void);
void module_reset_state(void);
// 测试用例声明
void test_module_init(void);
void test_module_process_normal(void);
void test_module_process_error(void);
#endif // UNIT_TEST
#endif // MODULE_TEMPLATE_H
嗯,这套模式我用在好几个量产项目里了。效果嘛——测试覆盖率从30%提到了85%,而且每次改代码都敢放心重构。你试试看,应该也会喜欢上这种设计方式。