yaml-cpp 测试实践:Legacy gMock FAQ 全解——mock 编写、期望匹配与调试指南
【免费下载链接】yaml-cppA YAML parser and emitter in C++项目地址: https://gitcode.com/GitHub_Trending/ya/yaml-cpp
导读
本文以 gMock(Google Mock)官方 FAQ(Legacy gMock FAQ)为骨架,系统梳理在 C++ 单元测试中使用 gMock 编写 mock 对象时最常遇到的问题:方法未被 mock 掉、可变参数函数与 const 参数的处理、期望不匹配的调试、调用次数约束、"新期望覆盖旧期望" 的匹配规则、参数副作用动作的编写,以及内存检查与编译期资源问题。同时结合本仓库(yaml-cpp)实际测试代码,展示这些 FAQ 结论在真实项目中的落地形态——yaml-cpp 的解析器事件流测试正是依赖 gMock 的StrictMock、InSequence与EXPECT_CALL来逐事件校验 YAML 解析输出的。
读完本文,你将掌握:如何判断并修复 mock 失效问题、如何让EXPECT_CALL与ON_CALL各司其职、如何用--gmock_verbose定位期望不匹配、如何用InSequence/WillOnce表达调用顺序、以及如何自定义 Action 处理 mock 参数。
一、为什么 mock 方法总是调用到真实对象
1. 方法必须是 virtual
FAQ 的第一条结论简洁明确:要让方法被 mock,它必须是 virtual 的,除非采用 high-perf 依赖注入技术。
原因是MOCK_METHOD生成的 mock 类通过继承并 override 基类的虚函数来接管调用。如果基类方法不是 virtual,派生类中同签名的方法会直接隐藏(hide)而非覆盖(override)基类实现,导致多态分发失效,调用仍落在真实对象上。
在 yaml-cpp 中,被 mock 的接口全部是纯虚函数。例如事件处理接口 eventhandler.h 中OnDocumentStart、OnScalar、OnSequenceStart、OnMapStart等均为virtual ... = 0,且~EventHandler()显式声明为virtual。测试侧的 mock 类 mock_event_handler.h 通过MOCK_METHODn逐一覆盖这些接口,保证了Parser::HandleNextDocument分发事件时调用的是 mock 实现。
2. 非虚方法怎么办:high-perf 依赖注入
如果确需 mock 非虚方法,FAQ 指向 cookbook 的 Mocking Non-virtual Methods 配方:让 mock 类与真实类不共享基类,只保持相同的方法签名;然后在编译期通过模板参数选择具体类型——
// 真实类:无任何虚成员 class ConcretePacketStream { public: void AppendPacket(Packet* new_packet); const Packet* GetPacket(size_t packet_number) const; size_t NumberOfPackets() const; }; // mock 类:不继承任何类,仅定义同签名方法 class MockPacketStream { public: MOCK_METHOD(const Packet*, GetPacket, (size_t packet_number), (const)); MOCK_METHOD(size_t, NumberOfPackets, (), (const)); }; // 生产代码与测试代码通过模板类型参数切换 template <class PacketStream> void CreateConnection(PacketStream* stream) { ... } template <class PacketStream> class PacketReader { public: void ReadPackets(PacketStream* stream, size_t packet_num); };生产代码实例化PacketReader<ConcretePacketStream>,测试代码实例化PacketReader<MockPacketStream>并对其设置期望。由于两者无继承关系,选择必须在编译期(模板)完成,而非运行期(虚函数)。
二、mock 不了的方法:可变参数函数与顶层 const 参数
1. 可变参数函数(variadic function)无法直接 mock
FAQ 明确指出:gMock无法直接 mock 一个带省略号(...)参数的函数。根本原因在于 mock 对象在编译期无法获知可变参数的个数与类型——只有基类作者才知道调用协议,而 gMock 不可能"读心"。
可行的替代方案是为该函数提供重载版本,把可能的参数组合显式枚举出来。FAQ 同时提醒:省略号参数继承自 C,并非真正的 C++ 特性,传参含构造/析构函数的对象时不安全,应尽量在 C++ 中避免使用。
2. MSVC 警告 C4301 / C4373 与顶层 const 参数
当 mock 方法带顶层const参数(如const int i)时,MSVC 可能报出以下警告:
warning C4301: 'MockFoo::Bar': overriding virtual function only differs from 'Foo::Bar' by const/volatile qualifier # Visual C++ 2008 SP1 之后变为: warning C4373: 'MockFoo::Bar': virtual function overrides 'Foo::Bar', previous versions of the compiler did not override when parameters only differed by const/volatile qualifiers这是 MSVC 的已知缺陷,同样的代码用 gcc 编译并无问题。原因在于 C++ 语言规则:函数声明中的顶层const修饰符会被忽略,因此以下两个声明等价:
virtual void Bar(int i) = 0; virtual void Bar(const int i) = 0; // 顶层 const 无意义结论:在Foo和MockFoo中都删掉顶层const即可规避该警告。
但要注意区分顶层 const与所指对象的 const。若参数是指针或引用,const修饰指针所指对象(pointee/referee)依然有意义:
void Bar(int* p); // p 与 *p 均非 const void Bar(const int* p); // p 非 const,但 *p 是 const这两者并不等价,不可混为一谈。
三、期望不匹配:从 verbose 日志到默认动作
1. 用--gmock_verbose=info定位问题
当 gMock 报告期望未满足却看不出原因时,FAQ 建议以--gmock_verbose=info运行测试。该标志会让 gMock 打印每次 mock 函数调用的跟踪信息,通过研读调用轨迹即可判断期望为何未命中。
若看到如下提示:
The mock function has no default action set, and its return type has no default value set.则应尝试添加默认动作(ON_CALL/DefaultValue<T>)。FAQ 还提示一个已知问题:对没有默认动作的 mock 的意外调用,不会打印实际参数与期望参数的详细比对,因此设置默认动作也有助于获得更详细的失败信息。
2. 程序崩溃时ScopedMockLog刷屏
当测试崩溃时,失败信号处理器会输出大量信息(堆栈、地址映射等),多线程深堆栈场景下更是成倍放大。ScopedMockLog拦截到这些不匹配任何期望的日志时,会逐条报错。FAQ 给出的建议是:要么学会忽略这些错误,要么把期望写得更健壮,例如:
using ::testing::AnyNumber; using ::testing::Not; ... // 忽略任何不是我们产生的日志。 EXPECT_CALL(log, Log(_, Not(EndsWith("/my_file.cc")), _)) .Times(AnyNumber());3. 如何断言"绝不被调用"
要断言某个函数从未被调用,使用.Times(0):
using ::testing::_; ... EXPECT_CALL(foo, Bar(_)) .Times(0);结合 yaml-cpp 的测试代码可见,Times之外的默认期望是"允许任意次数调用";只有显式写Times(0)(或Times(AnyNumber()))才是对该调用次数约束的明确声明。
四、理解 gMock 的匹配哲学:顺序、重复报告与"新期望覆盖旧期望"
1. 两次报告同样的期望状态并不冗余
FAQ 解释:gMock 每次检测到失败都会打印相关信息(mock 参数、相关期望的状态等)。若两次失败之间某个期望的状态没有变化,就会看到同样的状态描述出现两次。但这两次对应的是不同的时间点——状态相同这一事实本身就是有用的调试信息,因此并非冗余。
2. 为什么"新期望覆盖旧期望"
gMock 默认按从后往前的顺序搜索期望与ON_CALL。这样设计是为了支持一个非常有用的模式:先在 mock 构造或 fixture 的SetUp()阶段为常见情形设定默认行为,再在具体测试中用更精确的规则覆盖。若改为从前向后搜索,该模式将无法实现。
FAQ 以一个反面示例说明:有人试图用两条倒序的WillOnce表达"第一次返回 1,第二次返回 2",却抱怨要倒着写。正确的做法有两种:
方式一:用InSequence保证顺序,期望按自然顺序书写:
using ::testing::Return; ... { InSequence s; EXPECT_CALL(foo, Bar()) .WillOnce(Return(1)) .RetiresOnSaturation(); EXPECT_CALL(foo, Bar()) .WillOnce(Return(2)) .RetiresOnSaturation(); }方式二:把动作序列放进同一个期望:
using ::testing::Return; ... EXPECT_CALL(foo, Bar()) .WillOnce(Return(1)) .WillOnce(Return(2)) .RetiresOnSaturation();FAQ 强调,gMock 的基本哲学是:默认情况下期望不要求按任何特定顺序匹配,若要强制顺序必须显式声明——因为测试很容易被无意中过度约束(over-specify)。
yaml-cpp 的解析器事件测试正是这一哲学的典型应用。handler_test.h 在 fixture 中声明了InSequence sequence;和StrictMock<MockEventHandler> handler;,于是 handler_test.cpp 中一系列EXPECT_CALL(handler, OnDocumentStart(_))、OnMapStart(...)、OnScalar(...)、OnMapEnd()、OnDocumentEnd()必须按 YAML 事件流的严格顺序逐一命中——这正是解析器输出顺序性的直接验证。
3. 为什么有ON_CALL默认行为还会收到警告
FAQ 明确回答:宁可啰嗦,不可放过("being neat vs being safe, we lean toward the latter")。ON_CALL写在 mock 构造或SetUp()中是为了提供"默认行为",它不代表调用被期望。若没有对应的EXPECT_CALL而方法仍被调用,很可能就是 bug;悄悄放行会让缺陷悄无声息地溜过去。
如果你确信这些调用是合理的,应改用EXPECT_CALL并声明调用次数:
using ::testing::_; ... EXPECT_CALL(foo, Bar(_)) .WillRepeatedly(...); // 而不是 ON_CALL(...).WillByDefault(...)这等于告诉 gMock:"我确实期望这些调用",从而抑制警告。此外可用--gmock_verbose控制输出量,可选值包括info、warning、error——调试输出太吵时降低 verbosity 即可(详见 Cheat Sheet 的 Flags 表)。
五、对 mock 参数执行动作:DeleteArg、Invoke 与自定义 Action
1. 在 action 中删除参数
若 mock 函数接收指针参数且希望在动作中删除它,可使用testing::DeleteArg<N>()删除第 N 个(从 0 开始)参数:
using ::testing::_; ... MOCK_METHOD(void, Bar, (X* x, const Y& y)); ... EXPECT_CALL(mock_foo_, Bar(_, _)) .WillOnce(testing::DeleteArg<0>());2. 对参数执行任意动作
gMock 未直接支持的动作,可通过以下三种途径实现:
- 用
MakeAction()构造单类型动作; - 用
MakePolymorphicAction()构造多态动作(适用于Return(value)这类可用于多种函数类型的动作); - 写一个 stub 函数并用
Invoke()调用它。
using ::testing::_; using ::testing::Invoke; ... MOCK_METHOD(void, Bar, (X* p)); ... EXPECT_CALL(mock_foo_, Bar(_)) .WillOnce(Invoke(MyAction(...)));3. 自定义动作选型:Invoke 还是 ActionInterface?
FAQ 给出的选型建议:
- 动作只适用于某一种函数类型时,用
Invoke()更简单; - 动作要用于多种函数类型(如
Return(*value*))时,MakePolymorphicAction()最省事; - 需要精确控制动作可用的函数类型范围时,实现
ActionInterface接口,参见gmock-actions.h中Return()的实现。
4.SetArgPointee()与 "conflicting return type specified"
在WillOnce()中使用SetArgPointee()时,gcc 报 "conflicting return type specified" 是因为:SetArgPointee()只描述副作用(写回指针所指对象),没有提供返回值,gMock 不知道 mock 方法应该返回什么。需要用DoAll()把SetArgPointee()与一个Return()串联,后者为被 mock 的 API 提供合适返回值。完整示例见 Mocking Side Effects 配方。
六、面向对象设计层面的 FAQ:static 函数、复杂动作与内存
1. 能否 mock static/全局函数?
FAQ 的回答是"可以,但需要改造"。需要 mock 静态函数通常是模块耦合过紧的信号——此时更值得做的是定义一个小的接口,让调用通过该接口进行,从而可被轻松 mock。这最初会有些工作量,但通常很快回本。
2. "我的 mock 要干很多复杂的事,gMock 太难用了!"
FAQ 用一句反问点明本质:这是用错了工具。它区分了两种测试范式:
- 状态型测试(state-based testing):执行代码后断言返回值或系统最终状态;
- 交互型测试(interaction-based testing):mock 对象验证自己是否被以正确的方式调用,并在错误出现的第一时间报告,从而精确定位出错上下文——这通常比状态型测试更高效经济。
若你只是在做状态型测试、用测试替身模拟真实对象,fake(假实现)往往比 mock 更合适;mock 的强项在于交互验证而非执行复杂动作。感到"mock 很痛苦"时,要么是工具选错,要么是问题本身就问错了。
3. "Uninteresting function call encountered" 警告需要恐慌吗?
FAQ 明确回答:不需要,这只是 FYI(供参考信息)。它的含义是:某个 mock 函数没有设置任何期望(按 gMock 规则即"你不关心它的调用,可以任意次数调用"),而它确实被调用了——这并不违规。
但也存在一种可能:你本意是禁止调用却忘了写EXPECT_CALL(foo, Bar()).Times(0)。因此看到该消息时若你认为不应存在无趣调用,就应排查——gMock 会dump 堆栈跟踪,据此可以定位是哪个 mock 函数、如何被调用的。
4. 堆检查(heapcheck)失败:虚析构函数
mock 对象导致堆检查失败、真实对象却正常时,FAQ 首先提醒检查被 mock 的基类是否有虚析构函数。当通过基类指针delete派生类对象而基类析构非虚时,派生类析构不会执行:
class Base { public: ~Base() { ... } // 非虚——应改为 virtual }; class Derived : public Base { private: std::string value_; }; Base* p = new Derived; delete p; // 只调用 ~Base(),~Derived() 不执行,value_ 泄漏将~Base()改为 virtual 后,delete p会正确调用~Derived()。在 yaml-cpp 中,eventhandler.h 的virtual ~EventHandler() = default;正是这一原则的实践——被 mock 的接口类必须保证多态删除的安全性。
5. 巨型 mock 类导致 MSVC 内存耗尽
FAQ 观察到:启用/clr(CLR 公共语言运行时支持)标志时,Visual C++ 编译 mock 类会消耗5~6 倍的内存。建议在编译原生 C++ mock 时避免使用/clr。
七、FAQ 结论在 yaml-cpp 测试中的实际落地
为了让上述 FAQ 的每条结论都能落到可验证的代码上,这里汇总 yaml-cpp 仓库中与 gMock 直接相关的关键路径:
| FAQ 主题 | yaml-cpp 仓库中的对应证据 |
|---|---|
| 方法必须 virtual | eventhandler.h 中事件接口全部为纯虚函数 |
| 虚析构的必要性 | eventhandler.h 中virtual ~EventHandler() = default; |
| MOCK_METHOD 定义 mock | mock_event_handler.h 用MOCK_METHOD0/1/2/4覆盖全部事件回调 |
| InSequence / StrictMock 顺序期望 | handler_test.h 在 fixture 中声明InSequence sequence;与StrictMock<MockEventHandler> handler; |
| EXPECT_CALL 逐事件断言 | handler_test.cpp 按文档-映射-标量-结束事件流顺序设置期望 |
| NiceMock 忽略无趣调用 | handler_test.h 中IgnoreParse使用NiceMock<MockEventHandler>容忍无趣调用 |
| gmock 头文件与链接 | test/CMakeLists.txt 将 googletest-1.16.0 作为测试依赖引入 |
从源码结构看,yaml-cpp 的解析器事件测试把Parser::HandleNextDocument的事件回调全部交给StrictMock<MockEventHandler>,配合 fixture 级别的InSequence,实现了对 YAML 解析事件序列的全序验证——这正是"交互型测试"在解析器正确性验证上的典型用法:不检查最终 Node 状态,而是逐事件比对回调序列是否符合 YAML 规范。
八、速查:gMock 常用调试标志与动作
综合本文涉及的 gMock 命令与选项,汇总如下(完整列表见 gmock_cheat_sheet.md):
| 选项 / 用法 | 作用 |
|---|---|
--gmock_verbose=info | 打印每次 mock 调用的跟踪,用于排查期望未满足 |
--gmock_verbose=warning/error | 降低输出级别,调试时减少噪音 |
--gmock_catch_leaked_mocks=0 | 不把泄漏的 mock 对象当作测试失败报告 |
EXPECT_CALL(...).Times(0) | 断言函数绝不被调用 |
EXPECT_CALL(...).Times(AnyNumber()) | 允许任意次数调用(并抑制无趣调用警告) |
ON_CALL(...).WillByDefault(...) | 设置默认行为,不构成期望 |
InSequence s; | 让块内期望按书写顺序匹配 |
WillOnce(Return(1)).WillOnce(Return(2)) | 同一期望内按序返回不同值 |
testing::DeleteArg<N>() | 在动作中删除第 N 个指针参数 |
Invoke(...)/MakePolymorphicAction(...)/ActionInterface | 自定义动作的三种实现途径 |
DoAll(SetArgPointee(...), Return(...)) | 同时产生副作用并提供返回值 |
FAQ 的核心思想可以总结为一句话:gMock 的期望(EXPECT_CALL)与默认行为(ON_CALL)是两种不同语义的机制——前者声明"必须发生的事",后者提供"没人在意时的兜底行为";理解并善用这一区分,绝大多数 FAQ 中的困惑都能迎刃而解。
【免费下载链接】yaml-cppA YAML parser and emitter in C++项目地址: https://gitcode.com/GitHub_Trending/ya/yaml-cpp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考