9、模拟(Mock)对象基础:什么是Mock、为什么需要Mock、手动实现简单的Mock

好,咱们今天聊点实在的——Mock。

我记得刚入行那会儿,写了个串口驱动模块,自测跑得飞起。结果一集成到主工程,整个系统直接崩了。查了两天,发现是底层硬件寄存器没初始化。那时候我就想:要是能有个东西,让我在测试时“假装”硬件已经就绪,该多好?

嗯,这就是Mock的起源。

什么是Mock?

说白了,Mock就是一个“替身”。

在单元测试里,我们测试的是某个函数或模块。但这个模块往往会调用别的模块——比如调用硬件接口、调用数据库、调用网络服务。这些外部依赖,在测试环境里要么没有,要么不稳定,要么跑起来太慢。

Mock就是造一个假的实现,替换掉这些外部依赖。

举个例子:

// 真实硬件接口
int read_temperature_sensor(void) {
    // 操作I2C寄存器,读取温度
    return i2c_read(0x48, 0x00);
}

// Mock版本
int mock_read_temperature_sensor(void) {
    return 25;  // 固定返回25度
}

你看,测试时把真实函数替换成Mock,就能控制返回值了。这样你测试上层逻辑时,就不用关心硬件到底有没有接好。

核心定义:Mock对象是测试替身的一种,它模拟真实对象的行为,并允许你验证交互是否正确。

为什么需要Mock?

我在项目中遇到过好几次这样的场景:

  • 依赖还没写好——团队并行开发,你负责模块A,但模块B还没写完。没有Mock,你只能干等。
  • 环境不稳定——测试要连数据库,但数据库经常挂。用Mock后,测试再也不依赖数据库了。
  • 执行太慢——每次测试都要写文件、读文件,一个用例跑3秒。用Mock后,3毫秒搞定。
  • 难以构造异常场景——你想测试“传感器断线”的情况,但真实传感器很难断线。用Mock,直接返回一个错误码就行。

你想想看,如果没有Mock,你的单元测试会变成什么?

它会变成集成测试。每次跑测试都要启动整个系统,部署环境,配置网络……那还叫“单元”测试吗?

我的经验:一个健康的单元测试,应该能在5秒内跑完所有用例。如果超过这个时间,八成是没用好Mock。

手动实现简单的Mock

很多同学一上来就上Mock框架,比如CMock、Unity Mock。但我建议你先手写几次Mock,理解它的本质。

为什么?因为框架只是工具,原理才是根本。你懂了原理,遇到框架解决不了的问题,还能自己造轮子。

场景:温度报警系统

假设我们有这样一个模块:

// temperature_alarm.h
int check_temperature(void);  // 返回0表示正常,1表示报警

// temperature_alarm.c
#include "temperature_alarm.h"
#include "sensor.h"  // 外部依赖

int check_temperature(void) {
    int temp = read_temperature_sensor();
    if (temp > 50) {
        return 1;  // 报警
    }
    return 0;
}

这里的 read_temperature_sensor() 是外部依赖。我们要测试 check_temperature(),就必须Mock掉这个传感器函数。

第一步:让代码可测试

很多C代码写死了函数调用,没法替换。我们需要加一层“间接层”。

// sensor.h
typedef int (*sensor_read_func_t)(void);

// 默认使用真实传感器
int real_read_temperature(void);

// 允许外部设置Mock函数
void set_sensor_read_func(sensor_read_func_t func);
// sensor.c
static sensor_read_func_t s_sensor_func = real_read_temperature;

void set_sensor_read_func(sensor_read_func_t func) {
    s_sensor_func = func;
}

int read_temperature_sensor(void) {
    return s_sensor_func();
}

嗯,这里要注意:我们用了函数指针。这样测试时就能把真实函数替换成Mock函数。

第二步:编写Mock函数

// test_temperature_alarm.c
#include "temperature_alarm.h"
#include "sensor.h"

// Mock函数:固定返回高温
int mock_read_high_temp(void) {
    return 60;
}

// Mock函数:固定返回正常温度
int mock_read_normal_temp(void) {
    return 25;
}

void test_check_temperature_should_alarm_when_high(void) {
    set_sensor_read_func(mock_read_high_temp);
    int result = check_temperature();
    // 断言:应该报警
    assert(result == 1);
}

void test_check_temperature_should_normal_when_ok(void) {
    set_sensor_read_func(mock_read_normal_temp);
    int result = check_temperature();
    // 断言:应该正常
    assert(result == 0);
}

你看,就这么简单。没有框架,没有宏,就是纯C代码。

避坑指南:我曾经犯过一个错误——在Mock函数里忘了重置状态。比如连续调用两次Mock,第二次返回了错误的值。后来我养成了习惯:每个测试用例开始前,都调用一次 set_sensor_read_func(NULL) 清空状态。

第三步:验证交互

Mock不仅能控制返回值,还能验证“是否被调用过”、“调用了多少次”。

// 带计数功能的Mock
static int g_mock_call_count = 0;

int mock_read_with_count(void) {
    g_mock_call_count++;
    return 25;
}

void test_check_temperature_should_call_sensor_once(void) {
    g_mock_call_count = 0;
    set_sensor_read_func(mock_read_with_count);
    
    check_temperature();
    
    // 验证:传感器只被调用了一次
    assert(g_mock_call_count == 1);
}

这就是Mock的另一个重要能力——行为验证。不光看结果对不对,还要看过程对不对。

Mock的核心要素

总结一下,手动实现Mock需要三个东西:

要素 说明 代码示例
可替换性 代码必须支持替换依赖 函数指针、弱符号、条件编译
可控返回值 Mock函数能返回预设值 固定返回值、查表返回
可验证交互 能检查调用次数、参数 全局计数器、参数记录

我的建议:刚开始做单元测试时,先用手动Mock。等你写了几十个Mock函数,觉得重复劳动太多时,再上框架。那时候你才知道框架帮你省了什么活。

知识体系图

下面这张图,帮你理清Mock在整个测试体系中的位置:

Mock对象知识体系 单元测试 测试替身 (Test Double) Stub(桩) Mock(模拟) Fake(伪造) Spy(间谍) 可控返回值 + 行为验证 Mock是测试替身的一种,核心在于验证交互行为

从图上你能看到:Mock不是唯一的测试替身。Stub只负责返回固定值,Fake是轻量级实现,Spy记录调用信息。而Mock,既要控制返回值,又要验证交互行为。

这也是为什么我说“手动实现一次Mock”很重要——你亲手写了计数、验证逻辑,才能真正理解Mock和Stub的区别。

一个小技巧:如果你发现某个Mock函数越写越复杂,比如要处理各种参数组合、返回不同值,那说明你的被测模块设计得不够好。这时候该考虑重构了,而不是继续堆Mock代码。

好了,关于Mock的基础就聊到这儿。记住一句话:Mock不是目的,可测试性才是。你写Mock,是为了让代码更容易被测试,而不是为了Mock而Mock。


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