结构体与单元测试:结构体相关的测试策略、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对象如果用了全局变量,每个测试用例跑完后一定要重置。不然下一个用例读到脏数据,排查起来很痛苦。
我的习惯:每个测试文件开头放一个setup函数,把所有mock对象初始化一遍。结尾放一个teardown,检查有没有未释放的资源。这样跑1000个用例也不会串。
结构体的单元测试,说白了就是给代码上保险。一开始写测试确实费时间,但等你遇到一次回归测试把隐藏bug揪出来的时候,你就会觉得——值了。
公众号:蓝海资料掘金营,微信deep3321