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.cmock_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工作流程

头文件 gpio.h CMock 生成器 解析头文件 生成Mock代码 Mock文件 mock_gpio.c / .h 测试文件 test_led.c (包含 mock_gpio.h) 编译 & 运行 Unity + CMock 联合测试 核心流程说明 1. 编写头文件定义接口 2. CMock自动生成Mock代码 3. 测试文件包含Mock头文件 4. 使用Expect设置期望调用 5. 编译运行,验证调用行为

配置进阶:插件详解

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