CMock入门:自动生成模拟、配置与使用、常见陷阱
单元测试里有个绕不开的难题——依赖。你的函数调了别的模块,调了硬件接口,调了操作系统API。怎么办?真跑?那还叫单元测试吗?
这时候就需要模拟(Mock)了。而CMock,是我用过最顺手的C语言Mock工具之一。它帮你自动生成模拟函数,你不用手写一堆桩代码。嗯,今天我们就来聊聊它。
核心观点:CMock的核心价值在于“自动”。你给它一个头文件,它自动生成对应的Mock函数。你只需要在测试里调用断言,检查函数是否被调用、传了什么参数、返回了什么值。
CMock是什么?
CMock是一个基于Unity测试框架的Mock生成工具。它解析C头文件,为每个函数生成一个模拟版本。说白了,就是把你的函数“替换”成一个可以记录调用信息的替身。
我个人习惯把CMock和Unity搭配使用。Unity负责测试框架,CMock负责模拟依赖。两者配合,写起测试来非常顺手。
安装与配置
CMock的安装不算复杂。它用Ruby写的,所以你得先装Ruby。然后通过gem安装:
gem install cmock
装完之后,你需要在项目里配置CMock。我一般会在测试目录下放一个cmock_config.yml文件:
:cmock:
:mock_prefix: mock_
:plugins:
- :ignore
- :ignore_arg
- :expect
- :callback
:includes:
- <stdint.h>
- <stdbool.h>
:treat_as:
uint8: "HEX8"
uint16: "HEX16"
uint32: "HEX32"
这里有几个关键点:
- mock_prefix:生成的Mock文件前缀。比如
mock_gpio.h。 - plugins:插件列表。
:ignore允许你忽略某些参数检查,:expect用于设置期望值。 - treat_as:类型映射。把
uint8映射成十六进制显示,调试时更直观。
小提示:我第一次用CMock时,没配treat_as,结果断言失败时打印的全是十进制数字,看得我眼花。配成HEX后,一眼就能看出寄存器值对不对。
生成Mock文件
配置好之后,生成Mock文件就一行命令:
ruby cmock.rb -o build/mocks -I include/ inc/gpio.h
这条命令会解析gpio.h,在build/mocks目录下生成mock_gpio.c和mock_gpio.h。你只需要在测试文件里包含这个Mock头文件,就能用了。
使用CMock:一个完整例子
假设我们有这样一个头文件gpio.h:
#ifndef GPIO_H
#define GPIO_H
#include <stdint.h>
void gpio_set_pin(uint8_t pin, uint8_t value);
uint8_t gpio_read_pin(uint8_t pin);
#endif
CMock会生成对应的Mock函数。现在我们来写一个测试:
#include "unity.h"
#include "mock_gpio.h"
void test_led_toggle(void) {
// 设置期望:gpio_set_pin会被调用两次
gpio_set_pin_Expect(5, 1);
gpio_set_pin_Expect(5, 0);
// 调用被测函数
led_toggle(5);
// 断言:如果led_toggle内部没有按顺序调用gpio_set_pin,测试会失败
}
这里gpio_set_pin_Expect是CMock自动生成的。它告诉Mock:我期望gpio_set_pin被调用,参数是(5, 1),然后是(5, 0)。如果实际调用顺序或参数不对,测试就报红。
关键点:CMock的Expect机制是“严格顺序匹配”的。你设了几个Expect,被测函数就必须按顺序调用几次。多一次、少一次、顺序错了,都不行。
常见陷阱与避坑指南
用CMock久了,我踩过不少坑。这里列几个最常见的:
陷阱1:Expect数量不匹配
我曾经写过一个测试,设了3个Expect,但被测函数里有个条件分支,有时候只调2次。测试时好时坏,查了半天才发现是条件分支没覆盖到。
注意:如果函数调用次数不确定,可以用_Ignore系列函数。比如gpio_set_pin_Ignore(),它会忽略所有对该函数的调用检查。
陷阱2:指针参数比较
CMock默认用memcmp比较指针指向的内容。但如果你传的是结构体指针,而结构体里有填充字节,比较就会失败。我遇到过两次,都是因为结构体没初始化,填充字节是随机值。
解决办法:要么用_IgnoreArg_xxx忽略该参数,要么在测试前把结构体memset成0。
陷阱3:回调函数
有些函数会注册回调。CMock的:callback插件可以处理这种情况。但要注意,回调的调用时机可能和Expect的顺序冲突。我建议:如果回调逻辑复杂,不如手写一个简单的Mock,别硬用CMock。
SVG流程图:CMock工作流程
配置进阶:插件详解
CMock的插件机制很强大。我常用的几个:
| 插件名 | 作用 | 使用场景 |
|---|---|---|
:ignore |
忽略所有参数检查 | 不关心具体参数,只关心函数被调用了 |
:ignore_arg |
忽略指定参数 | 某个参数难预测,比如时间戳 |
:expect |
严格匹配参数和调用顺序 | 需要精确验证调用行为 |
:callback |
支持回调函数模拟 | 被测函数注册了回调,需要触发 |
:return_thru_ptr |
通过指针参数返回值 | 函数通过指针输出数据 |
我的经验:刚开始用CMock时,我恨不得每个参数都用Expect。后来发现,很多参数其实没必要验证。比如一个日志函数的字符串参数,你只要确认它被调用了就行,具体打印什么内容,在单元测试阶段不用太纠结。用_Ignore反而让测试更稳定。
实战:处理复杂依赖
有一次我在项目中遇到一个函数,它调用了三个不同的模块:一个I2C驱动、一个定时器、一个日志模块。如果手写Mock,光这三个模块就得写几百行桩代码。用CMock,我只需要:
#include "mock_i2c.h"
#include "mock_timer.h"
#include "mock_logger.h"
void test_sensor_read(void) {
// 设置I2C写入期望
i2c_write_Expect(0x50, 0x00, 0x01);
// 设置I2C读取期望,并返回模拟数据
i2c_read_ExpectAndReturn(0x50, 0x00, 0x42);
// 设置定时器延时期望
timer_delay_ms_Expect(10);
// 设置日志期望
logger_info_Ignore();
// 执行被测函数
uint8_t result = sensor_read(0x50);
// 验证结果
TEST_ASSERT_EQUAL_HEX8(0x42, result);
}
你看,三行Expect加一个Ignore,就把三个模块的依赖全搞定了。这就是CMock的价值——把精力集中在测试逻辑上,而不是Mock实现上。
注意:CMock生成的Mock函数默认是全局的。如果你的测试文件里有多个测试用例,记得在每个用例开始前调用mock_xxx_Init(),结束后调用mock_xxx_Verify()。否则,上一个用例的Expect会影响下一个用例。
总结
CMock是个好工具,但它不是万能的。我个人觉得,它最适合那些接口清晰、调用关系明确的模块。如果你的函数里有大量回调、异步调用、或者复杂的指针操作,手写Mock可能更可控。
嗯,说到底,工具是为人服务的。CMock能帮你省时间,但别被它束缚。该手写时就手写,该自动生成时就自动生成。灵活搭配,才是正道。
公众号:蓝海资料掘金营,微信deep3321