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 语言项目中,我常用 gcovlcov 来做覆盖率分析。用法很简单:

# 编译时加 -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 知识体系总览

下面这张图,把今天讲的内容串起来了:

单元测试进阶知识体系 桩函数 (Stub) Mock 对象 覆盖率分析 固定返回值 替换外部依赖 记录调用信息 验证调用次数/参数 语句覆盖率 分支覆盖率 条件覆盖率 目标:隔离外部依赖 → 验证函数行为 → 量化测试质量

20.7 一些实用建议

最后,分享几个我这些年积累下来的经验:

  • 不要过度 Mock。 如果一个函数只是简单的数学计算,没有外部依赖,直接测就好,没必要 Mock。
  • 桩函数和 Mock 可以混用。 有些函数只需要固定返回值(用 Stub),有些需要验证调用行为(用 Mock)。按需选择。
  • 覆盖率报告要定期看。 我习惯每次提交代码前跑一次覆盖率,看看新增代码有没有被测试覆盖到。
  • 别追求 100% 覆盖率。 有些错误处理代码(比如 malloc 失败)很难模拟。覆盖到主要逻辑和关键分支就够了。

注意:Mock 和 Stub 只是测试工具,不是设计模式。如果你的代码因为「不好测」而需要大量 Mock,那说明代码本身的设计可能有问题——耦合太紧、职责不单一。这时候应该先重构,再写测试。

好了,这一章的内容就到这里。Mock、Stub、覆盖率分析,这三样东西用好了,你的单元测试才能真正发挥作用。下次写测试的时候,试试看能不能把外部依赖都隔离掉,只测你自己的逻辑——你会发现,测试变得又快又稳。


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