20、单元测试进阶:Mock对象、桩函数(stub)、覆盖率分析
单元测试做到一定程度,你会发现一个尴尬的局面:
你测的那个函数,调用了别的模块。别的模块又依赖硬件、依赖数据库、依赖网络。一跑测试,整个系统都得跟着转。这还叫「单元」测试吗?
我早年吃过这个亏。有一次测一个通信协议栈的底层函数,每次跑测试都要连开发板。结果板子被别人占着,我干等了一下午。后来我学乖了——把外部依赖「隔离」掉,只测我自己的逻辑。
这就是今天要聊的三个核心概念:Mock对象、桩函数(Stub)、覆盖率分析。
20.1 为什么需要隔离外部依赖?
你想想看,一个函数如果调用了 read_sensor(),而这个函数真的去读硬件寄存器——那你的单元测试就变成了「硬件测试」。硬件没准备好,测试就过不了。
隔离的目的很简单:
- 让测试只关注函数本身的逻辑,不关心外部环境
- 让测试可以重复执行,不受硬件、网络、数据库状态影响
- 让测试跑得快,几毫秒跑完,而不是等硬件响应
核心原则:单元测试只测「你的代码」,不测「别人的代码」。
20.2 桩函数(Stub):最简单的替身
桩函数,说白了就是一个「假函数」。它长得跟真函数一模一样——函数名、参数、返回值类型都相同。但它的内部逻辑被替换成一段固定的、可控的代码。
举个例子。假设你有一个温度采集函数:
// sensor.h
int read_temperature(void);
// 你的业务逻辑
int check_overheat(void) {
int temp = read_temperature();
if (temp > 85) {
return 1; // 过热
}
return 0;
}
现在你要测 check_overheat()。但 read_temperature() 真的去读硬件,你没法控制它返回什么值。
怎么办?写一个桩函数:
// test_stub.c
static int mock_temp = 0;
void set_mock_temperature(int temp) {
mock_temp = temp;
}
int read_temperature(void) {
return mock_temp; // 返回我们设定的值
}
然后在测试代码里:
void test_check_overheat(void) {
set_mock_temperature(100);
assert(check_overheat() == 1); // 应该返回过热
set_mock_temperature(25);
assert(check_overheat() == 0); // 应该返回正常
}
你看,read_temperature() 这个桩函数,完全由我们控制。想让它返回什么,它就返回什么。这就是桩函数的核心价值——可控。
我的习惯:桩函数尽量简单。不要在里面写复杂逻辑,否则你等于在测试两套代码。我一般就用一个全局变量来控制返回值,简单粗暴。
20.3 Mock对象:比桩函数更「聪明」
桩函数只管「返回什么」。但有时候,我们还想知道「有没有被调用」、「调用了多少次」、「传了什么参数」。
这就是 Mock 对象的用武之地。
Mock 对象除了提供返回值,还会记录调用信息。你可以事后检查这些记录,验证函数的行为是否符合预期。
还是那个温度的例子。假设 read_temperature() 有一个参数——传感器编号:
int read_temperature(int sensor_id);
你的业务逻辑可能这样:
int check_all_sensors(void) {
int max_temp = 0;
for (int i = 0; i < 4; i++) {
int t = read_temperature(i);
if (t > max_temp) max_temp = t;
}
return max_temp;
}
用 Mock 的方式,我们可以这样写:
// mock_sensor.c
static int call_count = 0;
static int last_sensor_id = -1;
static int return_values[4] = {0};
void set_mock_return(int sensor_id, int temp) {
return_values[sensor_id] = temp;
}
int read_temperature(int sensor_id) {
call_count++;
last_sensor_id = sensor_id;
return return_values[sensor_id];
}
int get_call_count(void) { return call_count; }
int get_last_sensor_id(void) { return last_sensor_id; }
测试代码:
void test_check_all_sensors(void) {
set_mock_return(0, 30);
set_mock_return(1, 40);
set_mock_return(2, 35);
set_mock_return(3, 50);
int result = check_all_sensors();
assert(result == 50);
// 验证调用次数
assert(get_call_count() == 4);
}
看到了吗?Mock 不仅控制了返回值,还记录了「调用了4次」。如果代码里漏掉了某个传感器,调用次数不对,测试就能发现。
我曾经踩过的坑:Mock 对象记录的信息太多,测试代码变得又臭又长。后来我学到一个原则——只验证你关心的行为。不要每个测试都检查调用次数、参数、返回值全查一遍。那样维护成本太高。
20.4 覆盖率分析:你到底测了多少?
写了一大堆测试,心里没底。到底测了多少代码?哪些分支没跑到?
覆盖率分析就是回答这个问题的。
常用的覆盖率指标有几种:
| 覆盖率类型 | 含义 | 我的建议 |
|---|---|---|
| 语句覆盖率 | 每行代码是否被执行过 | 基础指标,但不够 |
| 分支覆盖率 | 每个 if/else、switch case 是否都走到 | 我一般要求 90% 以上 |
| 条件覆盖率 | 每个布尔子表达式是否都取过 true/false | 安全关键代码必须做 |
| 函数覆盖率 | 每个函数是否被调用过 | 容易达到,但别忽视 |
在 C 语言项目中,我常用 gcov 和 lcov 来做覆盖率分析。用法很简单:
# 编译时加 -fprofile-arcs -ftest-coverage
gcc -fprofile-arcs -ftest-coverage -o test_program test.c
# 运行测试
./test_program
# 生成覆盖率报告
gcov test.c
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report
跑完之后,打开 coverage_report/index.html,就能看到哪些行是红色(没跑到),哪些是绿色(跑到了)。
我的经验:覆盖率不是越高越好。100% 覆盖率不代表代码没有 bug。但覆盖率太低(比如低于 70%),说明测试写得太少,风险很大。我个人习惯把分支覆盖率定在 85% 以上,安全相关的模块要求 95%。
20.5 实战:一个完整的测试框架示例
说了这么多,咱们来一个完整的例子。假设有一个简单的温度管理系统:
// temp_manager.c
#include "sensor.h"
int get_max_temperature(void) {
int max = -100;
for (int i = 0; i < 3; i++) {
int t = read_temperature(i);
if (t > max) {
max = t;
}
}
return max;
}
int is_system_overheat(void) {
int max = get_max_temperature();
if (max > 80) {
return 1;
} else if (max < 0) {
return -1; // 传感器故障
}
return 0;
}
测试代码:
// test_temp_manager.c
#include <assert.h>
// Mock 实现
static int mock_temps[3] = {0};
void set_mock_temp(int sensor_id, int temp) {
mock_temps[sensor_id] = temp;
}
int read_temperature(int sensor_id) {
return mock_temps[sensor_id];
}
// 测试用例
void test_get_max_temperature_normal(void) {
set_mock_temp(0, 30);
set_mock_temp(1, 50);
set_mock_temp(2, 40);
assert(get_max_temperature() == 50);
}
void test_is_system_overheat_over_80(void) {
set_mock_temp(0, 85);
set_mock_temp(1, 70);
set_mock_temp(2, 60);
assert(is_system_overheat() == 1);
}
void test_is_system_overheat_sensor_fault(void) {
set_mock_temp(0, -10);
set_mock_temp(1, -20);
set_mock_temp(2, -5);
assert(is_system_overheat() == -1);
}
void test_is_system_overheat_normal(void) {
set_mock_temp(0, 30);
set_mock_temp(1, 50);
set_mock_temp(2, 40);
assert(is_system_overheat() == 0);
}
int main(void) {
test_get_max_temperature_normal();
test_is_system_overheat_over_80();
test_is_system_overheat_sensor_fault();
test_is_system_overheat_normal();
printf("All tests passed!\n");
return 0;
}
这个例子覆盖了 is_system_overheat() 的三个分支:过热、传感器故障、正常。加上 get_max_temperature() 的正常路径。跑一下覆盖率分析,你会发现分支覆盖率是 100%。
小技巧:写测试的时候,先想清楚「这个函数有哪些分支」。然后每个分支写一个测试用例。这样写出来的测试,覆盖率自然就上去了。
20.6 知识体系总览
下面这张图,把今天讲的内容串起来了:
20.7 一些实用建议
最后,分享几个我这些年积累下来的经验:
- 不要过度 Mock。 如果一个函数只是简单的数学计算,没有外部依赖,直接测就好,没必要 Mock。
- 桩函数和 Mock 可以混用。 有些函数只需要固定返回值(用 Stub),有些需要验证调用行为(用 Mock)。按需选择。
- 覆盖率报告要定期看。 我习惯每次提交代码前跑一次覆盖率,看看新增代码有没有被测试覆盖到。
- 别追求 100% 覆盖率。 有些错误处理代码(比如 malloc 失败)很难模拟。覆盖到主要逻辑和关键分支就够了。
注意:Mock 和 Stub 只是测试工具,不是设计模式。如果你的代码因为「不好测」而需要大量 Mock,那说明代码本身的设计可能有问题——耦合太紧、职责不单一。这时候应该先重构,再写测试。
好了,这一章的内容就到这里。Mock、Stub、覆盖率分析,这三样东西用好了,你的单元测试才能真正发挥作用。下次写测试的时候,试试看能不能把外部依赖都隔离掉,只测你自己的逻辑——你会发现,测试变得又快又稳。
公众号:蓝海资料掘金营,微信deep3321