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实现。适合简单场景。

测试接口的组织结构

我画了一张图,帮你理清头文件里测试接口的布局:

头文件测试接口布局 sensor_driver.h 公共接口(产品代码可见) • sensor_init() • sensor_read_temperature() • i2c_read() / i2c_write() • 类型定义、常量宏 测试接口(#ifdef UNIT_TEST) • Mock函数指针类型定义 • sensor_set_mock_i2c() • 测试辅助宏 • 静态变量访问接口 编译控制 产品代码:gcc -DNDEBUG ... → 测试接口被排除 测试代码:gcc -DUNIT_TEST ... → 测试接口生效 关键设计原则 ① 测试接口不改变公共接口的语义 ② 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%,而且每次改代码都敢放心重构。你试试看,应该也会喜欢上这种设计方式。


公众号:蓝海资料掘金营,微信deep3321