C++单元测试的Mock实战:从手写替身到GMock框架

发布时间:2026/10/2 14:41:46
C++单元测试的Mock实战:从手写替身到GMock框架
做C单元测试的人迟早会撞上同一个问题被测类里new出来的那个依赖对象你根本控制不了它。想测订单服务结果它内部直接new了一个支付网关测试一跑就去连真实服务器想测玩家类它调用音频引擎播放音效CI 机器上没有声卡直接崩溃想测一个缓存管理器它依赖的系统时钟怎么都拨不动。这时候你就需要 Mock——一个能顶替真实依赖、帮你编排行为、还能悄悄记录“被测代码到底调了谁、调了几次、传了什么参数”的测试替身。这个主题在 C 里比在 Java、Python 里更绕因为 C 的类对象有值语义、有 RAII、有虚函数、有模板处处影响你“能不能 Mock、怎么 Mock”。这篇东西我会从核心思路讲起再给一套可以直接抄的实操流程最后把我踩过的编译错误和运行时坑都列出来希望能帮你少走几步弯路。1. 先搞清楚类对象测试里的 Mock 到底解决什么问题1.1 一个真实案例订单服务里的支付网关想象一下你正在写一个订单服务里面有一个保存订单并扣款的方法类似这样class OrderService { public: bool PlaceOrder(const Order order) { PaymentGateway gateway; bool paid gateway.Charge(order.GetCardNo(), order.GetTotal()); if (!paid) return false; return SaveOrder(order); } };这代码一眼看过去没什么问题但你要为PlaceOrder写单元测试时就懵了PaymentGateway内部调用真实的第三方支付接口单元测试又不能真的去扣钱也不能依赖外网连通。就算你硬着头皮测结果也是不稳定的今天支付接口慢一秒你的测试就挂一秒完全失去了“快速定位问题”的意义。这时候 Mock 的思路很简单把PaymentGateway换成替身让它在测试环境里“假装”扣款成功或者失败你就能只测OrderService自己的逻辑而不用关心外部依赖怎么实现。1.2 Mock、Stub、Fake 别混成一锅粥很多人把测试替身都叫 Mock但其实严格分的话C 单元测试里常见的替身有四种类型核心能力典型用途Dummy只用来填参数方法不会被调用函数签名需要传一个对象但被测路径用不到Stub返回预设结果不关心调用次数和参数让被测代码走某个分支比如“支付成功”分支Fake有简单可用实现但不依赖外部资源内存版数据库、内存版消息队列Mock不但返回预设结果还验证调用行为断言“传参正确”“调用且只调用了一次”“顺序正确”一句话总结Stub 关心“返回什么”Mock 关心“怎么被调用的”。你把前者当 Mock 用一般也能跑通但就失去了一大半价值——因为很多 bug 恰恰出在“调用了不该调用的方法”或“参数传成了别的东西”上。1.3 什么场景才值得上 Mock不是每个测试都需要 Mock我一般判断的标准有三个依赖对象副作用明显且不可控。比如网络、文件系统、数据库、真实时钟、随机数、硬件接口、UI 控件。测试环境里操作这些轻则慢重则直接挂。依赖对象在测试环境根本不存在。比如第三方 SDK 只在生产环境部署CI 机器上连头文件都缺。你要专门验证交互行为。比如一个缓存过期策略你要确认“超过 TTL 后确实重新加载了数据”只看返回值可能不够因为状态可能没有变化。反过来如果一个依赖对象本身干干净净没有 IO、没有外部副作用、跑起来飞快那就没必要 Mock直接用它反而更真实。过度 Mock 会让测试变成“自导自演”这是我见过单元测试最常见的失败姿势之一。2. C 圈主流的 Mock 方案怎么选2.1 手写替身类最朴素但最灵活在没有框架的年代大家都是手写替身。思路很简单定义同一个接口然后用一个测试类继承它把需要的方法重写掉顺便记录调用。class MockPaymentGateway : public PaymentGateway { public: bool Charge(const std::string cardNo, double amount) override { chargeCalled; lastCardNo cardNo; lastAmount amount; return chargeResult; } int chargeCalled 0; std::string lastCardNo; double lastAmount 0; bool chargeResult true; };然后测试里直接对chargeCalled、lastCardNo做断言。这个方案我还是很推荐新手先试一次的因为写一遍你就能理解所有 Mock 框架的原理——无非就是“继承接口 重写方法 记录调用 可配置返回值”。手写的缺点也明显类多了之后样板代码太多每个方法的参数匹配、调用次数控制都要自己维护项目一大就累。2.2 GMock事实标准能力全面GMock 是 Google 出品的 C Mock 框架通常和 GoogleTest 一起用。它能做到定义 Mock 类只需要一行宏比如MOCK_METHOD支持参数匹配器_匹配任意值Eq(10)匹配精确值支持行为编排WillOnce(Return(true))表示第一次返回 trueWillRepeatedly(Return(false))表示后续一直返回 false支持调用次数断言Times(1)、AtLeast(2)、Exactly(3)支持调用顺序控制InSequence可以验证两个方法的先后关系。我在实际项目里用 GMock 最顺手的地方是一个接口十几个方法我只需要 Mock 其中两三个其余不关心的直接不设置期望框架默认返回零值不会碍事。这一点对手写替身来说非常奢侈。2.3 FakeIt、Trompeloeil 等其他选择FakeIt 最大的卖点是“不需要改被测类的接口”甚至对非虚函数也能通过链接期打桩实现 Mock写起来更简洁但有些技巧性较强出了问题不好排查。Trompeloeil 是 C14/17 时代比较流行的一个库API 设计非常现代化对std::unique_ptr等现代 C 特性支持好文档也清晰。还有一个 HippoMocks用法直白但对新版 C 标准跟进不够及时。如果你主要用 GoogleTest我建议直接上 GMock因为配套成熟、资料多、踩坑的人也多遇到问题基本搜得到答案。如果你不用 GoogleTest而是自己写的轻量测试框架那 FakeIt 或 Trompeloeil 对你的侵入性可能更小。2.4 我建议的学习路线不要一上来就铺大框架。我的建议是先手写一个替身类理解“Mock 的本质是接口多态”然后换 GMock 跑一个最小例子感受参数匹配和调用次数断言带来的效率提升最后再研究 NiceMock、StrictMock 这类进阶工具。基础打牢之后后面遇到“非虚函数怎么 Mock”“模板类怎么 Mock”这种问题你才能有自己的判断而不是网上说什么就跟着抄什么。3. 实操核心让被测代码“可 Mock”3.1 依赖注入是前提条件前面那个OrderService的例子最大的问题不是缺 Mock 框架而是PaymentGateway gateway;直接被写死在方法体里。像这种在内部直接new出来的对象任何 Mock 框架都救不了你想替换它必须先把依赖变成“外部传入”。常见做法有三种// 方式一构造函数注入 class OrderService { public: explicit OrderService(PaymentGateway* gateway) : gateway_(gateway) {} private: PaymentGateway* gateway_; }; // 方式二setter 注入 class OrderService { public: void SetPaymentGateway(PaymentGateway* gateway) { gateway_ gateway; } private: PaymentGateway* gateway_ nullptr; }; // 方式三模板注入编译期多态 template typename PaymentGatewayT class OrderServiceT { public: bool PlaceOrder(const Order order) { PaymentGatewayT gateway; // ... } };前两种都要求PaymentGateway是一个可以继承、可以重写方法的“多态类型”通常意味着至少有一个虚函数而且我们传的是指针。第三种不用虚函数后面我会专门展开。注意在构造函数里做太多事情是 Mock 的敌人。我见过很多代码构造函数内部直接 new 依赖、连数据库、加载配置文件导致测试时连构造被测对象都费劲。类设计阶段就应该把“轻量构造、依赖注入”当成一条规矩。3.2 从接口到 Mock 类假设我们已经把依赖抽象成了接口class PaymentGateway { public: virtual ~PaymentGateway() default; virtual bool Charge(const std::string cardNo, double amount) 0; virtual bool Refund(const std::string transactionId) 0; };基于 GMockMock 类长这样#include gmock/gmock.h class MockPaymentGateway : public PaymentGateway { public: MOCK_METHOD(bool, Charge, (const std::string cardNo, double amount), (override)); MOCK_METHOD(bool, Refund, (const std::string transactionId), (override)); };这里MOCK_METHOD是 GMock 新版的宏参数从左到右分别是返回值类型、方法名、参数列表、修饰符如override、const、noexcept。老式写法是MOCK_METHOD0、MOCK_METHOD1、MOCK_METHOD2这样按参数个数区分新版已经统一了建议优先用新宏。一个很实际的问题是如果接口里的方法标记成了protectedGMock 能不能 Mock可以但要把 Mock 类里对应方法的访问权限也放在protected或者public下。其实我建议接口里的虚方法全部声明为public因为 Mock 本身就是用来替代外界可见的行为访问级别过严反而给自己添堵。3.3 用 EXPECT_CALL 编排行为和断言下面是一个完整的测试用例演示了 GMock 最核心的EXPECT_CALL用法TEST(OrderServiceTest, PlaceOrderSuccessWhenChargeSucceeds) { MockPaymentGateway mockGateway; OrderService service(mockGateway); EXPECT_CALL(mockGateway, Charge(_, _)) .WillOnce(Return(true)); Order order(card-1234, 99.9); ASSERT_TRUE(service.PlaceOrder(order)); }这里_是匹配器表示“任意参数都可以”。也可以用更精确的匹配EXPECT_CALL(mockGateway, Charge(StrEq(card-1234), Gt(50.0))) .WillOnce(Return(true));EXPECT_CALL在PlaceOrder之前设置这是 GMock 的要求期望必须在调用发生前声明。另外WillOnce(Return(true))表示第一次调用返回 true如果被测代码调了第二次GMock 会报错“unexpected call”因为期望没有覆盖第二次调用。想覆盖多次可以用EXPECT_CALL(mockGateway, Charge(_, _)) .WillOnce(Return(true)) .WillOnce(Return(false)) .WillRepeatedly(Return(true));这段的意思是第一次返回 true第二次返回 false第三次及以后返回 true。如果只关心“总会返回某个值”也可以直接EXPECT_CALL(mockGateway, Charge(_, _)) .WillRepeatedly(Return(true));3.4 NiceMock 与 StrictMock 怎么选默认情况下GMock 使用的一种中间状态如果你调用了 mock 上的某个方法但没有给它设置EXPECT_CALL它会输出一个“uninteresting call”警告然后返回默认值指针返回 nullptr、bool 返回 false、整数返回 0。这个默认行为往往让新手很困惑因为测试能通过但日志里一堆警告。如果你确定“不关心某个方法”想让测试环境干净一点可以用NiceMockNiceMockMockPaymentGateway mockGateway;用了NiceMock那些没有设置期望的调用会安静地返回默认值。反过来StrictMock会更严苛任何没有设置期望的调用都直接判失败。StrictMockMockPaymentGateway mockGateway;我的经验是新写的测试先用StrictMock跑一遍。因为 StrictMock 能逼你把被测代码所有的外部依赖调用都明示出来一旦发现你没想到的调用往往就是代码逻辑的隐藏分支或者未预期的行为。确认所有关键路径都覆盖到之后再决定是否退化成 NiceMock 减小噪音。直接上 StrictMock 的代价是测试代码更啰嗦但排查问题的效率会提高很多。4. 遇到非虚函数和模板类怎么办4.1 非虚函数 Mock 的三个出路GMock 之所以能“篡改”被 Mock 对象的方法调用靠的是虚函数的动态绑定。如果PaymentGateway::Charge不是虚函数那MockPaymentGateway继承重写就根本不会生效——你用基类指针调用时走的还是原来的非虚函数。这时候有三个出路提取接口。把依赖类中需要 Mock 的方法提取成一个纯虚接口让真实类和 Mock 类各自实现。这是最正统、最推荐的做法代价是要改生产代码。模板注入。把依赖类型作为模板参数测试时传入 Mock 类型。不用虚函数也能做到编译期多态后面详细说。链接期替换。把依赖类的实现编译到单独的.cpp文件里测试时链接另一个 mock 版本的.o文件。这种做法能处理非虚函数但耦合构建系统容易弄乱项目结构我不太建议在普通项目里用。如果项目代码已经完全成型去改接口成本太高那可以先试着把“被测类要用的那部分行为”抽象出来只提取一个最小接口通常改动量不会很大。很多“这里不该有虚函数”的顾虑其实是被测类内部直接用std::unique_ptrConcreteDependency造成的改成std::unique_ptrDependencyInterface之后一切豁然开朗。4.2 模板注入编译期 Mock模板注入的思想很简单被测类不再持有接口指针而是把依赖当成模板类型来用template typename PaymentGatewayT class OrderServiceT { public: bool PlaceOrder(const Order order, PaymentGatewayT gateway) { bool paid gateway.Charge(order.GetCardNo(), order.GetTotal()); if (!paid) return false; return SaveOrder(order); } };测试时传入一个手写的测试替身或 GMock 的 Mock 类class MockPaymentGateway { public: MOCK_METHOD(bool, Charge, (const std::string cardNo, double amount)); MOCK_METHOD(bool, Refund, (const std::string transactionId)); }; TEST(OrderServiceTTest, PlaceOrderSucceeds) { NiceMockMockPaymentGateway gateway; OrderServiceTMockPaymentGateway service; EXPECT_CALL(gateway, Charge(_, _)).WillOnce(Return(true)); Order order(card-1234, 99.9); ASSERT_TRUE(service.PlaceOrder(order, gateway)); }这个方案的最大好处是不需要虚函数、不需要继承、不需要修改真实依赖类。缺点也很明显所有使用OrderServiceT的地方都要带着模板参数编译期报错信息比较长而且你失去了“运行时替换依赖”的能力——如果以后想在运行时根据配置选择不同的支付网关实现模板方案就不够灵活了。所以我的选择标准是如果依赖在编译期就能确定优先模板如果依赖可能运行时切换或者被测类已经有大量模块依赖接口优先接口加指针注入。4.3 链接期替换的坑链接期替换的思路是把真实依赖的实现文件不要编进测试目标换成另一个 mock 实现文件。这能处理非虚函数但坑非常多源文件里如果有 namespace、static 变量、宏定义替换时经常漏Mock 实现必须和真实实现保持完全一致的符号否则链接报错莫名其妙一旦真实依赖头文件里包含了大量第三方库头文件Mock 文件也得包含同样的头否则类型检查过不去如果同一份代码在多个测试二进制里复用构建系统配置会非常复杂。我身边有团队这么干过后来维护成本高到爆炸。除非是维护别人留下的老代码、短期内没能力改接口否则我不建议新项目采用链接期替换。5. 常见编译报错与运行时坑5.1 编译期高频问题排查报错现象常见原因解决办法Cannot convert from MockPaymentGateway* to PaymentGateway*Mock 类没有正确继承接口确认class MockPaymentGateway : public PaymentGatewayMOCK_METHOD宏参数数量不对使用了老式宏却按新式写法传参统一用新版MOCK_METHOD(返回类型, 方法名, (参数列表), (修饰符))访问protected成员时编译失败生产代码的protected依赖在测试代码里不可见将依赖方法改为public或把 Mock 类设为友元链接时找不到gmock相关符号没有链接 gmock 库链接参数加-lgmock -lgtest -lpthreadCMake 里target_link_libraries(... gmock gtest)报错gmock要求 C17/C20编译器标准太老在 CMake 里设置set(CMAKE_CXX_STANDARD 17)还有一个我经常遇到的项目的持续集成环境是 Windows MSVCGMock 在 DEBUG 模式下偶尔会触发奇怪的断言。这时先看NDEBUG是否定义——Release 模式通常没事如果必须用 Debug 跑测试留意一下是不是 GMock 版本太老升级到新版本基本能解决。5.2 运行期断言失败排查GMock 的断言失败信息一般很直白像这样Actual function call count doesnt match EXPECT_CALL(gateway, Charge(_, _))...这表示被测代码对Charge的调用次数和Times指定的不一致。排查顺序我建议这样来第一步确认期望是在调用之前声明的。GMock 是“先声明期望再触发调用”的模型如果在被测代码执行之后再EXPECT_CALL那基本必然失败。第二步检查匹配器。EXPECT_CALL(gateway, Charge(card-1234, 99.9))和实际传参(card-1234, 99.90)在浮点数比较上可能有精度问题。尽量用DoubleEq(99.9)或者Gt(50.0)这种容差匹配器不要在 mock 期望里直接比较浮点。第三步看WillOnce和WillRepeatedly的顺序。一个最常见的错误是EXPECT_CALL(gateway, Charge(_, _)) .WillRepeatedly(Return(true)) .WillOnce(Return(false));这时第二个WillOnce会被追加到队列后面实际第二次调用返回的其实还是 true而不是你想的 false。正确顺序是WillOnce在前WillRepeatedly在后。第四步确认 Mock 对象的生命周期。GMock 在 Mock 对象析构时会自动验证所有期望是否满足如果 mock 对象比被测对象先析构被测对象析构时还会去调用已经被销毁的 mock轻则崩溃重则出现随机性测试失败。建议用testing::Mock的VerifyAndClearExpectations手动验证一次或者确保被测对象先析构。5.3 让 Mock 测试更稳的小习惯除了排查我更想分享几个让 Mock 测试长期好维护的习惯。第一不要依赖全局状态。每个测试用例里都创建自己的 Mock 对象不要让 Mock 对象挂在全局变量或者 static 变量上。否则测试并行运行时互相对彼此的调用次数产生影响很难排查。第二能用状态验证就别死磕行为验证。Mock 最强的能力是“验证调用”但也是它最烦人的地方——你断言“方法被调用了三次”之后如果被测代码做了无关紧要的优化少调一次测试就崩了。很多时候只要被测代码最终返回值和最终状态是对的中间调用了几次并不重要。我倾向于用NiceMock处理“不关心调用次数”的依赖只为真正关键的交互写EXPECT_CALL。第三给测试用例补参数捕获。比如要验证“传给了支付网关的金额是否等于订单总价”除了在Charge的匹配器里直接写_你还可以用SaveArg或DoAll把实际参数存下来std::string actualCardNo; double actualAmount 0.0; EXPECT_CALL(gateway, Charge(_, _)) .WillOnce(DoAll(SaveArg0(actualCardNo), SaveArg1(actualAmount), Return(true)));这一步对排查“参数传错”的 bug 特别有帮助。我还经常把存下来的参数和预期值一起打进日志失败时不用打开调试器就能看明白是哪一步错了。第四在 CI 上跑一遍 ASan/UBSan。Mock 代码用了大量模板和宏很容易在内存越界、未定义行为上翻车。本地测试过了不代表 CI 上没问题至少跑一次 AddressSanitizer 能省很多事。我自己的体会是Mock 的威力不是“帮我少写几行代码”而是逼着我把类的依赖关系设计清楚。一个类如果到处new依赖、构造时连数据库、方法体里藏着不可控的 IO那它谁都很难 Mock问题其实出在代码结构本身而不是测试工具不够好用。反过来一旦你习惯了依赖注入 接口抽象 模板注入这套组合C 类对象的单元测试会变成一件很顺手的事——改动一个类的内部逻辑测试稳稳地告诉你有没有破坏外部行为这种感觉比什么框架都值钱。最后分享一个小技巧Mock 接口里的方法时如果拿不准某个方法的参数类型可以先在接口里注释“此方法用于测试 Mock”类名上也可以加Mock前缀。这样以后维护代码的人看到MockPaymentGateway就能立刻明白这是个测试替身不会往生产代码里误引用。C 项目里替身类满天飞本来就正常命名清楚是对后来人最大的温柔。