4. 桩模块与驱动模块设计:Stub与Driver的原理、设计原则、代码实现
集成测试做到一定深度,你会发现一个绕不开的问题:被测模块的上下游依赖还没写好,或者根本没法在目标环境里跑。这时候怎么办?硬等?不现实。我的做法是——造“替身”。
Stub(桩模块)和 Driver(驱动模块)就是干这个的。说白了,它们都是测试替身,但角色完全不同。我见过不少团队把这两个概念搞混,结果测试代码写得四不像,维护成本比被测代码还高。今天咱们就把这事彻底理清楚。
4.1 为什么需要Stub和Driver?
先看一个典型场景。你负责模块B,它调用模块A的接口,又被模块C调用。现在要测B,但A还没写好,C也没准备好。你怎么办?
- 没有Stub:B调用A时,链接不过,编译都过不了。
- 没有Driver:没人调用B,测试用例跑不起来。
Stub解决的是“被调用依赖缺失”的问题。Driver解决的是“调用者缺失”的问题。两者配合,才能把被测模块孤立出来,做可控的测试。
核心原则:Stub和Driver只模拟接口行为,不实现业务逻辑。它们的存在是为了让被测模块能编译、能运行、能被验证。
4.2 Stub(桩模块)原理与设计
4.2.1 什么是Stub?
Stub是被测模块的下游依赖的替身。它接收来自被测模块的调用,返回预设的数据。我习惯把Stub叫做“听话的假人”——你让它回什么,它就回什么。
举个例子,你的模块要读取温度传感器数据,但传感器硬件还没到位。写一个Stub,每次调用都返回25.0°C。这就是最朴素的Stub。
4.2.2 Stub的设计原则
- 接口一致:Stub的接口签名必须和真实模块完全一致。参数类型、返回值类型、异常声明,一个都不能差。
- 行为可控:Stub的返回值应该可以通过测试用例配置。不能写死,否则测不了边界条件。
- 行为简单:Stub里不要写复杂逻辑。它只负责返回数据,不负责验证数据。
- 可记录调用:好的Stub会记录自己被调用了多少次、传入了什么参数。这在验证交互行为时非常有用。
我的经验:我早期做Stub时,喜欢在里面加一堆打印日志。后来发现,日志太多反而干扰测试输出。现在我只在Stub里放一个调用计数器和一个参数缓存,够用就好。
4.2.3 Stub代码实现示例
假设被测模块需要调用一个 Sensor_Read() 函数,返回温度值。真实实现会操作硬件寄存器,Stub则简单得多。
/* sensor_stub.h */
#ifndef SENSOR_STUB_H
#define SENSOR_STUB_H
#include <stdint.h>
/* 与真实接口完全一致 */
int32_t Sensor_Read(void);
/* Stub专用控制接口 */
void SensorStub_SetReturnValue(int32_t value);
void SensorStub_Reset(void);
uint32_t SensorStub_GetCallCount(void);
#endif
/* sensor_stub.c */
#include "sensor_stub.h"
static int32_t stub_return_value = 0;
static uint32_t call_count = 0;
int32_t Sensor_Read(void) {
call_count++;
return stub_return_value;
}
void SensorStub_SetReturnValue(int32_t value) {
stub_return_value = value;
}
void SensorStub_Reset(void) {
stub_return_value = 0;
call_count = 0;
}
uint32_t SensorStub_GetCallCount(void) {
return call_count;
}
你看,Stub本身只有十几行代码。测试用例里这样用:
void test_temperature_alert_high(void) {
SensorStub_Reset();
SensorStub_SetReturnValue(85); /* 模拟高温 */
uint8_t alert = TemperatureMonitor_CheckAlert();
TEST_ASSERT_EQUAL(1, alert);
TEST_ASSERT_EQUAL(1, SensorStub_GetCallCount());
}
注意:Stub的复位操作必须在每个测试用例开始前执行。我曾经因为忘记复位,导致上一个用例的返回值污染了下一个用例,排查了整整半天。
4.3 Driver(驱动模块)原理与设计
4.3.1 什么是Driver?
Driver是测试用例与被测模块之间的桥梁。它负责构造输入数据、调用被测模块的接口、收集输出结果。说白了,Driver就是“测试用例的执行引擎”。
我见过有人把Driver和测试框架混为一谈。其实不一样。测试框架(如Unity、CppUTest)提供断言和运行环境,Driver负责具体的调用逻辑和数据准备。
4.3.2 Driver的设计原则
- 职责单一:一个Driver只驱动一个被测模块。不要搞“万能Driver”。
- 数据驱动:Driver的输入数据应该从外部传入,而不是硬编码在Driver内部。
- 结果可观测:Driver必须能把被测模块的输出暴露给测试框架做断言。
- 环境隔离:Driver不依赖真实硬件或操作系统。它运行在Host环境。
我的习惯:我会把Driver设计成“可配置的调用器”。用一个结构体把输入参数、期望输出、超时时间都包起来。这样写测试用例时,只需要填充结构体,不用重复写调用代码。
4.3.3 Driver代码实现示例
假设被测模块是一个CAN报文解析器 CanParser_Decode()。Driver负责构造CAN帧数据,调用解析器,并返回解析结果。
/* can_parser_driver.h */
#ifndef CAN_PARSER_DRIVER_H
#define CAN_PARSER_DRIVER_H
#include <stdint.h>
typedef struct {
uint32_t can_id;
uint8_t data[8];
uint8_t data_len;
} CanFrame_t;
typedef struct {
float speed;
uint16_t rpm;
uint8_t error_code;
} ParseResult_t;
/* Driver核心接口 */
ParseResult_t Driver_CanParser_Decode(const CanFrame_t *frame);
/* Driver辅助接口 */
void Driver_CanParser_Init(void);
void Driver_CanParser_Deinit(void);
#endif
/* can_parser_driver.c */
#include "can_parser_driver.h"
#include "can_parser.h" /* 被测模块的真实头文件 */
void Driver_CanParser_Init(void) {
/* 初始化被测模块需要的资源 */
CanParser_Init();
}
ParseResult_t Driver_CanParser_Decode(const CanFrame_t *frame) {
ParseResult_t result = {0};
uint8_t status;
/* 构造输入,调用被测模块 */
status = CanParser_Decode(frame->can_id,
frame->data,
frame->data_len,
&result.speed,
&result.rpm,
&result.error_code);
if (status != 0) {
result.error_code = 0xFF; /* 标记解析失败 */
}
return result;
}
void Driver_CanParser_Deinit(void) {
CanParser_Deinit();
}
测试用例里这样用Driver:
void test_can_parser_normal_frame(void) {
CanFrame_t frame = {
.can_id = 0x123,
.data = {0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
.data_len = 8
};
Driver_CanParser_Init();
ParseResult_t result = Driver_CanParser_Decode(&frame);
Driver_CanParser_Deinit();
TEST_ASSERT_FLOAT_WITHIN(0.1, 65.0, result.speed);
TEST_ASSERT_EQUAL(0, result.error_code);
}
4.4 Stub与Driver的对比
| 维度 | Stub(桩模块) | Driver(驱动模块) |
|---|---|---|
| 角色 | 下游依赖的替身 | 上游调用者的替身 |
| 方向 | 被被测模块调用 | 调用被测模块 |
| 主要职责 | 返回预设数据 | 构造输入并触发调用 |
| 复杂度 | 通常很简单 | 可能包含数据准备逻辑 |
| 调用记录 | 需要记录调用次数和参数 | 通常不需要 |
| 典型场景 | 硬件抽象层、通信协议栈 | 应用层模块、算法模块 |
4.5 知识结构图:Stub与Driver在集成测试中的位置
4.6 避坑指南:我踩过的那些坑
- Stub里写业务逻辑:我曾经在一个Stub里实现了完整的PID控制算法,就为了模拟传感器输出。结果Stub比真实模块还复杂,调试时根本分不清是Stub的bug还是被测模块的bug。记住,Stub只返回数据,不实现算法。
- Driver和测试用例耦合太紧:有人把测试数据直接写在Driver的代码里。换一个测试场景就得改Driver。正确的做法是让Driver接收参数,数据由测试用例提供。
- 忽略Stub的调用验证:只验证返回值,不验证调用次数和参数。结果被测模块多调了一次Stub,测试依然通过。后来我强制要求每个测试用例至少验证一次调用计数。
- Driver里做断言:Driver只负责调用和返回结果,断言应该交给测试框架。Driver里做断言会导致测试逻辑分散,维护困难。
总结一下:Stub和Driver是集成测试的左右手。Stub管“下游”,Driver管“上游”。设计时记住四个字——替身不替逻辑。它们只负责模拟接口行为,不负责实现业务逻辑。做到这一点,你的集成测试代码就会干净、可控、好维护。