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的设计原则

  1. 接口一致:Stub的接口签名必须和真实模块完全一致。参数类型、返回值类型、异常声明,一个都不能差。
  2. 行为可控:Stub的返回值应该可以通过测试用例配置。不能写死,否则测不了边界条件。
  3. 行为简单:Stub里不要写复杂逻辑。它只负责返回数据,不负责验证数据。
  4. 可记录调用:好的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的设计原则

  1. 职责单一:一个Driver只驱动一个被测模块。不要搞“万能Driver”。
  2. 数据驱动:Driver的输入数据应该从外部传入,而不是硬编码在Driver内部。
  3. 结果可观测:Driver必须能把被测模块的输出暴露给测试框架做断言。
  4. 环境隔离: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在集成测试中的位置

集成测试中的Stub与Driver 测试用例 Driver(驱动模块) 被测模块(SUT) Stub(桩模块) 驱动 调用 调用 说明 Driver:模拟上层调用者,构造输入数据,调用被测模块。 Stub:模拟下层依赖,接收被测模块调用,返回预设数据。

4.6 避坑指南:我踩过的那些坑

  • Stub里写业务逻辑:我曾经在一个Stub里实现了完整的PID控制算法,就为了模拟传感器输出。结果Stub比真实模块还复杂,调试时根本分不清是Stub的bug还是被测模块的bug。记住,Stub只返回数据,不实现算法。
  • Driver和测试用例耦合太紧:有人把测试数据直接写在Driver的代码里。换一个测试场景就得改Driver。正确的做法是让Driver接收参数,数据由测试用例提供。
  • 忽略Stub的调用验证:只验证返回值,不验证调用次数和参数。结果被测模块多调了一次Stub,测试依然通过。后来我强制要求每个测试用例至少验证一次调用计数。
  • Driver里做断言:Driver只负责调用和返回结果,断言应该交给测试框架。Driver里做断言会导致测试逻辑分散,维护困难。

总结一下:Stub和Driver是集成测试的左右手。Stub管“下游”,Driver管“上游”。设计时记住四个字——替身不替逻辑。它们只负责模拟接口行为,不负责实现业务逻辑。做到这一点,你的集成测试代码就会干净、可控、好维护。