安全重构的第一步:构建测试防护网

重构这件事,说白了就是「不改功能,只改结构」。但问题来了——你怎么知道改完之后功能没变?

我见过太多团队,重构前信心满满,重构后线上崩得一塌糊涂。原因很简单:没有测试防护网。你想想看,没有安全网就去走钢丝,那不是勇敢,是莽撞。

所以今天咱们聊的,就是怎么给C代码织一张靠谱的测试网。

为什么C语言尤其需要测试防护网

C语言和Java、Python不一样。它没有虚拟机帮你兜底,没有反射机制让你偷懒,更没有包管理器给你一键装测试框架。C代码一旦跑飞,就是段错误、内存泄漏、野指针——直接崩给你看。

我记得有一次接手一个嵌入式项目,底层驱动全是裸写指针操作。我改了一个结构体字段顺序,结果整个通信协议都乱了。为什么?因为没有单元测试告诉我「你改坏了」。从那以后,我给自己定了个规矩:不改没有测试的代码。

核心原则: 没有测试防护网,就不要谈重构。这不是建议,是纪律。

测试金字塔在C语言中的应用

测试金字塔这个概念,最早是Mike Cohn提出来的。它把测试分成三层:底层是单元测试,中间是集成测试,顶层是端到端测试。比例大概是这样的:

层级 速度 数量 C语言中的典型工具
单元测试 毫秒级 大量(70%+) Unity、Criterion、CMock
集成测试 秒级 中等(20%) 自制测试桩、硬件模拟器
端到端测试 分钟级 少量(10%) 硬件在环测试、系统级脚本

为什么底层要放那么多单元测试?因为单元测试跑得快、定位准。你改了一个函数,跑一下对应的单元测试,几毫秒就知道有没有改坏。集成测试和端到端测试虽然更接近真实场景,但跑一次要等半天,不适合频繁执行。

我个人习惯是:每次提交代码前,先跑一遍单元测试。全部通过,再考虑集成测试。端到端测试留给CI/CD去做。

编写可测试的C代码

很多C程序员抱怨「C代码没法测」。其实不是没法测,是代码写得不够好。我总结了几个让C代码变得可测试的实用技巧。

1. 函数要短,职责要单一

一个函数超过50行,基本就很难测了。为什么?因为它的分支太多,你要覆盖所有路径,得写几十个测试用例。而且一旦出问题,你根本不知道是哪个分支坏了。

// 不可测试的写法
int process_data(char *input) {
    // 解析、校验、转换、输出,全揉在一起
    // 想单独测解析逻辑?没门
}

// 可测试的写法
int parse_input(char *input, ParsedData *out);
int validate_data(ParsedData *data);
int transform_data(ParsedData *data, OutputData *out);
int output_result(OutputData *data);

每个函数只做一件事。这样你就能单独测parse_input,单独测validate_data。出了问题,一眼就知道是哪个环节坏了。

2. 用依赖注入代替全局变量

C语言里全局变量是测试的头号杀手。为什么?因为全局变量让函数有了隐式依赖。你测一个函数,还得先设置一堆全局状态,测完还得清理——麻烦得要命。

// 糟糕的写法
static int g_sensor_value;

int read_sensor() {
    return g_sensor_value;
}

// 可测试的写法
int read_sensor(int *sensor_value) {
    // 从硬件读取
    *sensor_value = hardware_read();
    return 0;
}

把依赖通过参数传进去,而不是藏在全局变量里。这样测试的时候,你可以传一个模拟值进去,不用真的去操作硬件。

小技巧: 如果实在没法去掉全局变量,至少用条件编译把测试和真实环境隔离开。比如用 #ifdef UNIT_TEST 来替换真实实现。

3. 接口要稳定,实现要灵活

我见过很多C代码,头文件和实现文件混在一起写。测试的时候想mock一个函数,发现根本没法下手。为什么?因为接口和实现没有分离。

正确的做法是:头文件只暴露必要的接口,实现细节全部藏在.c文件里。测试代码只依赖头文件,不依赖实现。

// sensor.h
int sensor_init(void);
int sensor_read(int *value);

// sensor.c
static int calibrate(int raw) {
    return raw * 2 + 1;
}

int sensor_read(int *value) {
    int raw = hardware_read();
    *value = calibrate(raw);
    return 0;
}

这样测试代码只需要包含sensor.h,然后mock掉hardware_read(),就能单独测sensor_read的逻辑了。

4. 用桩和mock隔离外部依赖

C语言没有多态,没有接口继承,怎么模拟外部依赖?答案是:用函数指针,或者用链接时替换。

// 用函数指针实现可替换的依赖
typedef int (*read_func_t)(void);

int process_data(read_func_t read_func) {
    int value = read_func();
    return value * 2;
}

// 测试时传入模拟函数
int mock_read(void) {
    return 42;
}

void test_process_data(void) {
    int result = process_data(mock_read);
    assert(result == 84);
}

如果不想改函数签名,也可以用链接时替换。编译测试时,把真实的hardware_read.o换成mock_read.o。这样不用改一行生产代码,就能完成隔离。

注意: 链接时替换虽然方便,但容易让人忘记「现在跑的是mock版本」。我建议在测试代码里加个明显的日志输出,提醒自己当前是测试模式。

一张图看懂测试防护网

下面这张图展示了测试防护网的核心结构。从底层到顶层,每一层都在保护你的代码不被改坏。

C语言测试防护网结构 端到端测试 硬件在环 · 系统脚本 集成测试 测试桩 · 模拟器 · 模块间交互 单元测试 Unity · Criterion · CMock · 函数级验证 数量少 · 速度慢 数量多 · 速度快 测试防护网 = 大量单元测试 + 适量集成测试 + 少量端到端测试 重构风险

你看,底层单元测试最多,跑得最快。它们就像一张细密的网,帮你兜住大部分低级错误。集成测试和端到端测试虽然数量少,但能捕获跨模块、跨系统的问题。三层叠加,才能做到「改了不怕,跑了放心」。

避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路。

  • 不要一开始就追求100%覆盖率。 我曾经在一个项目上死磕覆盖率,结果测试代码比生产代码还多,维护成本翻倍。后来我学乖了:核心逻辑必须覆盖,边缘逻辑看情况,错误处理路径至少覆盖主要分支。
  • 不要用真实硬件跑单元测试。 单元测试应该能在开发机上秒级跑完。如果依赖硬件,就跑不快,跑不快就没人愿意跑。用mock把硬件隔离掉。
  • 不要写「测试的测试」。 我见过有人给测试代码写测试——这完全是浪费生命。测试代码应该足够简单,简单到一眼就能看出对不对。如果测试代码复杂到需要测试,说明你的设计有问题。
  • 不要等代码写完了再补测试。 测试应该和代码一起写,甚至先写测试再写代码(TDD)。补测试的痛,谁补谁知道。

嗯,今天就聊到这儿。测试防护网这件事,说起来简单,做起来需要一点耐心。但相信我,一旦你尝到了「改完代码敢直接上线」的甜头,就再也回不去了。

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