3、可测试性设计核心原则:可控性、可观测性、分离性、模块化与接口契约

做嵌入式系统这么多年,我越来越觉得一个道理:测试不是最后补的,是设计时就该埋进去的。你想想看,一个系统如果连基本的状态都控制不了、内部行为都看不到,出了问题只能靠猜,那调试起来得多痛苦?

这一章我们就来聊聊可测试性设计的五个核心原则。说白了,它们就是让你的代码变得「好测」的底层逻辑。我个人习惯把这五个原则当作设计时的检查清单,每写一个模块都过一遍。

核心观点:可测试性不是测试团队的负担,而是开发团队的设计红利。前期多花10%的精力做可测试性设计,后期能省下50%的调试时间。
可测试性设计五原则关系图 可测试性设计 可控性 可观测性 分离性 模块化 接口契约 五个原则相互支撑:可控性和可观测性是目标,分离性和模块化是手段,接口契约是保障

3.1 可控性:让测试能「指哪打哪」

可控性,说白了就是你能不能随心所欲地让系统进入某个状态。我遇到过最典型的反例:一个电机控制模块,启动条件依赖十几个传感器信号,测试时得手动模拟每个信号才能让电机转起来。这哪是测试?这是受罪。

好的可控性设计应该做到:

  • 提供状态注入接口:允许测试代码直接设置模块内部状态,而不是绕一大圈走正常流程
  • 支持输入向量化:把外部输入(传感器、通信数据)抽象成可编程的测试向量
  • 暴露配置入口:关键参数、模式切换、阈值设置都能通过接口直接控制
我的习惯:每个模块至少留一个「测试模式」入口。在这个模式下,所有外部依赖都可以被 mock 或 stub 替换。这样单元测试和集成测试都好做。

举个例子,一个温度采集模块,正常流程是从 ADC 读取数据。但测试时你总不能真的去加热传感器吧?所以我会加一个 temp_set_raw_value(uint16_t adc_val) 的测试接口,直接注入 ADC 原始值。

/* 温度采集模块 - 可控性设计示例 */
typedef struct {
    uint16_t raw_adc;
    int16_t  temperature_celsius;
    uint8_t  fault_flag;
} TempSensor_State;

/* 正常接口 */
int16_t TempSensor_ReadTemperature(void);

/* 测试接口 - 仅在 TEST 编译选项下暴露 */
#ifdef UNIT_TEST
void TempSensor_InjectRawADC(uint16_t adc_val) {
    sensor_state.raw_adc = adc_val;
    /* 触发内部转换逻辑,直接得到温度值 */
    TempSensor_Convert();
}
#endif

3.2 可观测性:让内部状态「一目了然」

可观测性解决的是「黑盒」问题。系统跑起来了,里面到底发生了什么?变量对不对?状态机走到哪一步了?没有可观测性,你就像蒙着眼睛开车。

我曾经接手过一个通信协议栈,里面有个状态机,但没有任何日志输出。出问题的时候只能靠猜——是超时了?还是状态跳错了?后来我花了三天时间把所有状态转换点都加了 trace 输出,问题半小时就定位了。

可观测性的几个关键手段:

手段 说明 适用场景
日志分级输出 ERROR/WARN/INFO/DEBUG 四级,运行时可通过配置动态调整 所有模块,尤其是通信、状态机、异常处理
状态快照导出 将模块内部关键变量打包成结构体,通过接口一次性读出 复杂算法模块、控制环路、资源管理
断言与检查点 在关键路径上插入断言,违反预期时触发错误记录 参数校验、状态合法性、内存操作
性能计数器 统计函数调用次数、执行时间、队列深度等 实时性敏感模块、资源受限场景
注意:可观测性不是越多越好。日志太多会拖慢系统,尤其是实时系统。我建议把日志输出做成可配置的——发布版本只开 ERROR 级别,调试版本才开 DEBUG。

3.3 分离性:把「纠缠」变成「独立」

分离性,就是让模块之间的依赖关系尽量简单。你想想看,如果 A 模块调 B 模块,B 又调 C,C 又回调 A——这种循环依赖下,你根本没法单独测任何一个模块。

分离性的核心做法:

  • 依赖倒置:高层模块不依赖低层模块的具体实现,而是依赖抽象接口
  • 控制反转:把模块的控制权交给框架或调度器,模块只负责自己的逻辑
  • 事件驱动:模块之间通过事件或消息通信,而不是直接函数调用

我习惯用「接口隔离」来检查分离性:如果一个模块的改动会导致三个以上其他模块跟着改,那分离性一定出了问题。

/* 分离性设计对比 */

/* 不好的设计:直接依赖具体实现 */
void Motor_Start(void) {
    HAL_GPIO_WritePin(MOTOR_EN_GPIO, MOTOR_EN_PIN, GPIO_PIN_SET);
    HAL_Delay(10);
    HAL_GPIO_WritePin(MOTOR_DIR_GPIO, MOTOR_DIR_PIN, GPIO_PIN_SET);
}

/* 好的设计:通过抽象接口解耦 */
typedef struct {
    void (*set_enable)(bool enable);
    void (*set_direction)(MotorDir dir);
    bool (*get_status)(void);
} Motor_Interface;

void Motor_Start(Motor_Interface *iface) {
    iface->set_enable(true);
    /* 延迟由接口实现者负责,测试时可以替换为虚拟延迟 */
    iface->set_direction(MOTOR_DIR_FORWARD);
}

3.4 模块化:把大问题拆成小问题

模块化是老生常谈了,但真正做到位的项目不多。模块化的本质是高内聚、低耦合——一个模块只做一件事,并且把这件事做好。

我判断模块化好坏的标准很简单:

  1. 单一职责:你能用一句话说清楚这个模块是干什么的吗?如果不能,说明它太「胖」了
  2. 最小依赖:模块的 include 头文件越少越好,最好不超过 5 个
  3. 独立编译:模块可以单独编译通过,不需要依赖其他模块的实现
  4. 可替换性:换一个实现版本时,接口不变,调用方不用改代码
实战经验:我曾经把一个 3000 行的「万能驱动模块」拆成了 12 个小模块,每个模块 200-300 行。拆完之后,单元测试覆盖率从 20% 直接跳到 85%。为什么?因为小模块好测,边界条件容易覆盖。

3.5 接口契约:给模块之间「立规矩」

接口契约,就是模块之间互相承诺的「协议」。你提供什么输入?我返回什么输出?边界条件怎么处理?错误怎么报告?这些都要白纸黑字写清楚。

我见过太多因为接口定义模糊导致的 bug:调用方以为传 NULL 没问题,被调方却直接解引用崩溃了。这就是契约没定好。

好的接口契约应该包含:

契约要素 说明 示例
前置条件 调用方必须满足的条件 参数不能为 NULL、缓冲区大小至少为 64 字节
后置条件 函数执行后保证的结果 返回值 >= 0 表示成功,< 0 表示错误码
不变式 模块内部始终成立的条件 队列头指针永远指向有效节点、状态机不会进入非法状态
错误处理 异常情况下的行为约定 内存分配失败返回 NULL、超时返回 -ETIMEDOUT
/* 接口契约示例 - 环形缓冲区模块 */

/**
 * @brief 从环形缓冲区读取数据
 * 
 * @param buf   [in] 缓冲区句柄,不能为 NULL
 * @param data  [out] 输出数据指针,不能为 NULL,长度至少为 len
 * @param len   [in] 期望读取的字节数,必须 > 0
 * 
 * @return int 实际读取的字节数
 *         - 成功:返回 [1, len] 之间的值
 *         - 缓冲区为空:返回 0
 *         - 参数错误:返回 -EINVAL
 * 
 * @pre  buf != NULL && data != NULL && len > 0
 * @post 返回值 <= len
 * @invariant ring_buffer_size(buf) <= RING_BUFFER_MAX_CAPACITY
 */
int RingBuffer_Read(RingBuffer *buf, uint8_t *data, size_t len);
我的做法:每个模块的 .h 文件里,我都会用注释把接口契约写清楚。测试代码直接读头文件就知道怎么测——前置条件就是测试的输入约束,后置条件就是测试的断言依据。

3.6 五原则的协同效应

这五个原则不是孤立的,它们互相支撑:

  • 可控性 + 可观测性 = 测试的「手」和「眼」,一个负责输入,一个负责输出
  • 分离性 + 模块化 = 测试的「骨架」,让系统可拆解、可替换
  • 接口契约 = 测试的「标尺」,告诉你怎么测、测到什么算对

我建议你在设计新模块时,把这五个原则打印出来贴在工位上。每写完一个接口,问自己五个问题:

  1. 我能直接控制这个模块进入任意状态吗?(可控性)
  2. 我能看到模块内部的关键变量吗?(可观测性)
  3. 这个模块能脱离其他模块单独测试吗?(分离性)
  4. 这个模块的职责是不是单一且清晰的?(模块化)
  5. 接口的输入输出约束是不是明确的?(接口契约)

五个问题都回答「是」,那这个模块的可测试性就合格了。如果有一个「否」,嗯,趁早改,别等到集成测试时再后悔。