结构体与单元测试:结构体相关的测试策略、mock对象的设计

单元测试这东西,说实话,很多嵌入式工程师一开始都不太重视。我早年也踩过这个坑——代码写了一大堆,一联调就崩,定位问题花的时间比写代码还多。后来我学乖了:结构体这种核心数据结构,必须单独测。

为什么结构体特别需要单元测试?你想想看,结构体往往承载着系统的核心状态。一个字段错了,整个模块的行为就全歪了。而且结构体指针满天飞,一个野指针就能让你调一整天。

结构体测试的三大痛点

我在项目中总结过,结构体相关的测试,主要难在三个地方:

  • 依赖太多:一个结构体往往关联好几个模块,比如一个设备结构体里挂着驱动、缓冲、回调函数。你要测它,得先把这些依赖都准备好。
  • 状态爆炸:结构体字段多,组合起来的状态数是指数级的。你不可能全测,但漏测又容易出问题。
  • 副作用难控:结构体经常通过指针传参,函数内部改了字段,外部也跟着变。测试用例之间容易互相影响。

核心原则:结构体的单元测试,本质上是测试它的行为契约,而不是测试它的内存布局。你关心的是:给定输入,输出对不对?状态转换是否符合预期?

测试策略:从简单到复杂

我个人习惯把结构体测试分成三个层次,一层一层往上走:

第一层:纯数据测试

最简单的场景——结构体只存数据,不涉及外部依赖。比如一个坐标点、一个配置参数。这种测试就是构造实例、赋值、读值,验证字段对不对。

// 被测结构体
typedef struct {
    int x;
    int y;
} Point;

// 测试用例
void test_Point_creation(void) {
    Point p = {10, 20};
    assert(p.x == 10);
    assert(p.y == 20);
}

void test_Point_add(void) {
    Point a = {1, 2};
    Point b = {3, 4};
    Point result = {a.x + b.x, a.y + b.y};
    assert(result.x == 4);
    assert(result.y == 6);
}

这种测试没什么花头,但千万别跳过。我见过有人觉得太简单不写,结果后来重构时改了字段类型,编译没报错,但逻辑全错了——因为没有测试兜底。

第二层:带内部逻辑的结构体

结构体里如果有函数指针、状态机、或者复杂的计算逻辑,那就需要设计状态覆盖了。比如一个环形缓冲区结构体:

typedef struct {
    uint8_t *buffer;
    size_t head;
    size_t tail;
    size_t capacity;
    bool is_full;
} RingBuffer;

测试这种结构体,我一般会画一个状态表:

操作 初始状态 预期 head 预期 tail 预期 is_full
写入 1 字节 0 1 false
写满 0 0 true
读取 1 字节 1 0 false
读空 有数据 1 1 false

然后针对每个状态转换写测试用例。边界条件尤其要测——比如写满后再写、读空后再读。

第三层:带外部依赖的结构体

这才是真正的难点。比如一个传感器驱动结构体,它内部调用了I2C读写函数、GPIO控制函数。你要测它,不能真的去操作硬件吧?

这时候就需要 mock对象 了。

Mock对象的设计思路

说白了,mock就是替身。你把真实硬件换成一个模拟对象,它记录下谁调了它、传了什么参数、返回什么值。然后你在测试里验证这些记录。

我常用的做法是这样的:

// 原始结构体
typedef struct {
    int (*read)(uint8_t reg, uint8_t *data);
    int (*write)(uint8_t reg, uint8_t data);
    void (*delay_ms)(uint32_t ms);
} Sensor;

// Mock结构体——加一个记录日志的字段
typedef struct {
    int (*read)(uint8_t reg, uint8_t *data);
    int (*write)(uint8_t reg, uint8_t data);
    void (*delay_ms)(uint32_t ms);
    
    // mock 记录
    int read_call_count;
    uint8_t last_read_reg;
    uint8_t last_write_reg;
    uint8_t last_write_data;
    int mock_read_return;
} MockSensor;

然后写mock函数:

int mock_read(uint8_t reg, uint8_t *data) {
    MockSensor *mock = get_current_mock(); // 全局或线程局部
    mock->read_call_count++;
    mock->last_read_reg = reg;
    *data = 0xAA; // 预设返回值
    return mock->mock_read_return;
}

小技巧:我习惯把mock对象设计成可配置的。比如可以设置返回值、可以设置延迟、可以设置错误码。这样同一个mock能覆盖多种场景,不用每个测试都重写一套。

一个完整的测试例子

假设我们要测一个温度传感器驱动,它内部调用I2C读取两个字节,然后计算温度值。我们用mock来模拟I2C:

// 被测函数
float sensor_read_temperature(Sensor *s) {
    uint8_t high, low;
    if (s->i2c_read(s->addr, 0x00, &high) != 0) return -1.0f;
    if (s->i2c_read(s->addr, 0x01, &low) != 0) return -1.0f;
    int16_t raw = (high << 8) | low;
    return raw * 0.0625f;
}

// 测试用例
void test_sensor_read_temperature_normal(void) {
    MockI2C mock;
    mock_init(&mock);
    mock_set_read(&mock, 0x00, 0x1A); // 高字节
    mock_set_read(&mock, 0x01, 0xB0); // 低字节
    
    Sensor s;
    s.addr = 0x48;
    s.i2c_read = mock_i2c_read;
    s.i2c_write = NULL;
    s.mock_ctx = &mock;
    
    float temp = sensor_read_temperature(&s);
    assert(fabs(temp - 26.5) < 0.01);
    assert(mock.read_call_count == 2);
}

你看,这样测下来,驱动逻辑对不对、I2C调用次数对不对、返回值处理对不对,全都能覆盖到。而且不需要接真实硬件,在开发机上就能跑。

注意:mock对象一定要能验证调用顺序和次数。我遇到过一种bug——函数调了两次read,但第二次传的寄存器地址错了。如果mock只记录最后一次调用,这种bug根本抓不住。

SVG:结构体测试策略全景图

结构体单元测试策略全景 第一层 纯数据测试 构造实例、赋值、读值 验证字段正确性 第二层 内部逻辑测试 状态转换、边界条件 函数指针、状态机 第三层 外部依赖测试 Mock对象设计 硬件抽象、依赖注入 Mock对象设计要点 记录调用次数和参数 可配置返回值(正常/异常) 支持验证调用顺序 不依赖真实硬件 可复用、可组合 线程安全(全局mock上下文)

避坑指南

最后分享几个我踩过的坑:

  • 别mock过头:我见过有人把结构体里所有函数都mock了,结果测试测的全是mock本身,真正的逻辑一点没测到。记住,只mock外部依赖,内部逻辑要测真的。
  • 注意结构体对齐:不同编译器、不同架构下,结构体的内存布局可能不一样。如果你的测试依赖了特定字段偏移,那换平台就会挂。我一般会在测试里加一个静态断言,检查关键字段的偏移量。
  • 清理全局状态:mock对象如果用了全局变量,每个测试用例跑完后一定要重置。不然下一个用例读到脏数据,排查起来很痛苦。

我的习惯:每个测试文件开头放一个setup函数,把所有mock对象初始化一遍。结尾放一个teardown,检查有没有未释放的资源。这样跑1000个用例也不会串。

结构体的单元测试,说白了就是给代码上保险。一开始写测试确实费时间,但等你遇到一次回归测试把隐藏bug揪出来的时候,你就会觉得——值了。


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