ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++单元测试中的Mock实战:用gMock隔离依赖与提升可测试性

C++单元测试中的Mock实战:用gMock隔离依赖与提升可测试性 从给一个“下载器”类写单元测试开始说起吧。这类类对象往往依赖网络库、磁盘读写、甚至是系统时间如果你真的在单测里发起HTTP请求那测试就变成了“原谅我不厚道地笑了”现场——CI不稳定、跑得慢、失败了还不知道是代码错了还是网络抽风。这个场景正是C类对象单元测试中Mock最典型的需求来源把外部依赖替换成可控的替身让被测类只面对逻辑本身。这篇文章面向的是已经写过基础C代码、会用Google Test或者至少能搭起一个测试工程的开发者目标是帮你把Mock用得真正顺手不是背语法而是搞懂它解决的核心问题是什么、应该怎么设计代码来配合Mock、以及遇到“跑了半天EXPECT_CALL就是不生效”这种问题时该从哪里入手排查。全文都会围绕类对象单测这个主题展开代码重点放在gMock上毕竟这套组合在C生态里用得最多、资料也最好找。1. 类对象单元测试为什么绕不开Mock1.1 你写单测时真正遇到的障碍是什么很多人第一次给类写单元测试写了一个测试函数调用被测类的方法然后断言返回值。写着写着就开始别扭了被测类里new了一个数据库连接对象、构造时发了个网络请求、某个成员函数调用了系统时间函数、或者内部持有另一个业务类的实例这个实例又依赖一堆复杂初始化条件。这时候你会发现明明是在“单元”测试结果测着测着变成了“集成”测试。数据库连不上、外网不可用于是、接口返回数据变了你的测试就废了。问题的本质不在于测试代码写得不对而在于被测类对象和外部世界耦合得太紧。从设计角度说C类的成员函数如果直接调用具体依赖类的实例或者直接调用平台API那这个类在测试环境里就没办法剥离外部因素。Mock解决的核心痛点就是这种耦合它不改变被测类的代码而是通过一个可替换的“替身对象”把外部依赖隔离掉让被测类只能和替身交互你则完全控制这个替身的行为。1.2 Mock、Stub和Fake的区别必须搞清楚很多人把Mock和Stub混着说其实它们有明确分工。Stub是“测试替身”的一种它只负责返回预先设定好的数据不关心自己“被没被调用”。比如你测一个类它内部有个方法用来读配置你用Stub返回固定配置就算测试过程中这个方法一次都没被调用测试也能通过因为Stub不会去记录调用信息。Fake更接近一个简化版真实实现比如测试内存数据库来代替真实数据库它有真实逻辑只是更轻量。而Mock核心特征在于“行为验证”它不仅提供替代返回值还会记录“某个方法有没有被调用、以什么参数调用、调用了几次”如果实际调用方式和预期不一致Mock会直接让测试失败。放到C类对象场景里这意味着Mock适合验证的是“某个对象和另一个对象的交互是否正确”而不仅仅是“计算结果是否正确”。比如一个订单服务类内部依赖一个支付网关类你去断言订单服务返回的结果并不能真正确认它有没有向支付网关发送正确的订单金额和回调地址用Mock就能精确验证这类交互行为。1.3 什么场景必须用Mock什么场景别硬用不是所有单元测试都需要Mock。我见过一些同学为了显得专业连一个简单计算器类都非要把加法和减法Mock掉这纯属自找麻烦。适合用Mock的典型场景是这些被测类依赖网络通信、数据库访问、消息队列、文件系统等外部资源被测类依赖另一个不由你控制的第三方SDK这个SDK在测试环境下很难初始化或很难稳定复现返回值被测类依赖系统时间、随机数、线程调度等不定因素被测类与另一个复杂业务类协作你只关心当前类的逻辑不想把整个链路都跑起来不适合用Mock的情况也很明显被测对象本身是纯逻辑、纯算法类直接用真实对象测试最简单的被测对象是简单的实体类/POJO类就是存一些字段、提供getter/setterMock这种类毫无意义数据结构的容器类比如自己封装了一个队列、栈真实操作和断言即可类对象单测里最常见的错误就是“过度Mock”把被测类自己的内部方法也Mock了结果测了个寂寞。记住一条原则Mock是用来替换“依赖”的不是用来替换“被测对象自身逻辑”的。2. Mock工具选型与基础用法以Google Mock为例2.1 为什么选gMock而不是手动写桩类C生态里做Mock目前主流方案就是Google MockgMock。它是Google Test配套的Mock库你的工程已经在用Google Test那直接用gMock是最省事的不需要额外引入第二个测试框架CMake集成也简单。市面上也有其他选择比如HippoMocks、Trompeloeil、FakeIt各有特点但gMock的社区资料最齐全出了坑一搜就有答案。更重要的是gMock提供了一套比较完整的“行为设定 调用验证”机制EXPECT_CALL设定预期WillOnce/WillRepeatedly确定返回值匹配器Matcher用来做参数匹配还有NiceMock/StrictMock这种“性格”设置。如果你尝试自己写桩类比如手动定义一个假的HTTPClient类写上几个方法的空实现返回固定数据那很快会面临一个问题你发现每个需要测试的类都要手写一个假类不同测试对返回值的要求还不一样假类越写越大最后变成了一个粗糙模拟系统测试代码比业务代码还难维护。gMock的价值就在于把这些通用机制都做好了你只需要描述“这个方法应该怎么被调用、被调用后返回什么”。2.2 从一个最小可运行示例开始先看一个非常典型的类对象单测场景一个Downloader类通过构造函数接收一个HttpClient对象Downloader调用HttpClient的Get方法来下载文件内容。如果用真实HttpClient每次单测都要联网所以我们Mock掉HttpClient。先定义接口和被测类// http_client.h #pragma once #include string class HttpClient { public: virtual ~HttpClient() default; virtual std::string Get(const std::string url) 0; virtual int GetStatusCode(const std::string url) 0; };// downloader.h #pragma once #include string #include http_client.h class Downloader { public: explicit Downloader(HttpClient* client) : client_(client) {} bool Download(const std::string url, std::string* content) { int status client_-GetStatusCode(url); if (status ! 200) { return false; } *content client_-Get(url); return !content-empty(); } private: HttpClient* client_; };现在写测试。gMock要求通过宏来声明Mock类// mock_http_client.h #pragma once #include http_client.h #include gmock/gmock.h class MockHttpClient : public HttpClient { public: MOCK_METHOD(std::string, Get, (const std::string url), (override)); MOCK_METHOD(int, GetStatusCode, (const std::string url), (override)); };测试里这样用#include gtest/gtest.h #include gmock/gmock.h #include mock_http_client.h #include downloader.h using ::testing::Return; using ::testing::_; TEST(DownloaderTest, DownloadSuccessWhenStatusCodeIs200) { MockHttpClient mock_client; Downloader downloader(mock_client); EXPECT_CALL(mock_client, GetStatusCode(_)).WillOnce(Return(200)); EXPECT_CALL(mock_client, Get(_)).WillOnce(Return(htmlok/html)); std::string content; bool result downloader.Download(http://example.com, content); EXPECT_TRUE(result); EXPECT_EQ(content, htmlok/html); }这段代码看着简单但实际上把gMock几个核心概念都带出来了MOCK_METHOD申明Mock方法EXPECT_CALL设置预期调用次数和返回值_是一个通配匹配器表示“任何参数都行”。2.3 核心语法逐层拆解MOCK_METHOD、EXPECT_CALL和匹配器MOCK_METHOD的语法如下这是新版写法老版本是MOCK_METHOD1这种带数字的写法新版统一不同参数数量好记多了MOCK_METHOD(返回类型, 方法名, (参数列表), (限定符列表));第四个参数里写的override表示这个Mock方法覆写了基类的虚函数如果是const成员函数就要写成(const, override)。还有noexcept、ref等限定符可以在这里加。关键是Mock方法必须和基类虚函数的签名完全一致否则编译期可能报错或者行为不符合预期。EXPECT_CALL是一条完整语句它分三个部分第一部分指定Mock对象和方法比如mock_client, GetStatusCode第二部分是参数匹配器列表比如(::testing::_)、(::testing::Eq(http://example.com))用来限定“这个方法被调用时必须使用什么参数”第三部分是行为设置比如WillOnce(Return(200))表示第一次调用返回200WillRepeatedly(Return(0))表示之后任意次调用都返回0除了指定返回值的Return还常用SetArgReferee用来修改引用参数的值、ThrowException抛异常、Invoke调用自定义函数等。参数匹配器里最常用的我整理成一张表匹配器含义示例_匹配任何参数EXPECT_CALL(obj, Foo(_))Eq(x)参数等于xEXPECT_CALL(obj, Foo(Eq(42)))Ne(x)参数不等于xEXPECT_CALL(obj, Foo(Ne(0)))Gt/Ge/Lt/Le(x)大于/大于等于/小于/小于等于xEXPECT_CALL(obj, Foo(Gt(0)))AllOf(m1, m2)参数同时满足m1和m2EXPECT_CALL(obj, Foo(AllOf(Gt(0), Le(100))))AnyOf(m1, m2)参数满足m1或m2EXPECT_CALL(obj, Foo(AnyOf(Eq(a), Eq(b))))HasSubstr(s)字符串包含子串sEXPECT_CALL(obj, Foo(HasSubstr(error)))实际用的时候最常见的是“_加返回值”组合因为很多情况下测试只关心“有没有调用”不关心具体参数值。但如果你想验证“某个对象收到的是不是正确构造的参数”那就必须用具体匹配器。比如订单类测试里你希望验证Mock支付网关的charge方法被调用时传入的金额是100那就写成EXPECT_CALL(mock_pay, charge(Eq(100), _))这样能抓出“业务代码把金额传错”的隐性bug。2.4 NiceMock、StrictMock和NaggyMock该怎么选gMock对Mock对象调用了一个“没被EXPECT_CALL”的方法时默认行为是输出一个警告但不会导致测试失败。这类Mock对象叫NaggyMock它其实是默认的“性格”。另外两个是NiceMock和StrictMock。NiceMock调用未设置预期的方法时完全安静不输出警告也不失败StrictMock调用未设置预期的方法时直接让测试失败NaggyMock输出警告但不失败就是默认行为看起来StrictMock最严格是不是应该全用这个我个人在实际项目里的建议是默认用NaggyMock只在特定测试文件里用NiceMock和StrictMock。理由很简单StrictMock一旦遇到未预期的合法调用就直接失败这在大型类上会很痛苦因为你可能只关注当前测试路径上的一两个方法其他方法碰巧也被调用了全都失败很容易把真正要验证的错误淹没在报错里。NiceMock适合那种“我知道这个类依赖比较多但本次测试只关心其中一条路径”的情况其他调用就让它安静。当你真正需要严格约束交互验证“这个对象不应该调用某个方法”时再上StrictMock也不迟。写测试代码时我的经验是“先宽松后收紧”调试阶段用NiceMock让测试先跑通确认逻辑正确后再把Mock换回默认行为甚至StrictMock做到最终测试对一个测试点相关的交互有明确约束。3. 改造代码才能Mock可测试性设计与依赖注入实战3.1 一个好设计是Mock的前提写Mock最怕遇到一种情况被测类的依赖对象根本没办法替换。比如被测类里直接new了一个具体对象class OrderService { public: bool CreateOrder(const OrderData data) { PaymentGateway gateway; // 硬编码依赖 return gateway.Charge(data.payment, data.amount); } };你这个测试怎么写PaymentGateway是具体类没有virtual接口gMock根本没法生成Mock对象。就算PaymentGateway本身有一些虚方法你Mock了它也没办法塞进OrderService里因为OrderService内部自己new了一个。这种代码被称为“不可测试性代码”它不是写错而是根本没有为测试预留替换点。想让Mock发挥价值就必须先做可测试性设计改造。类对象环境中最核心的手段就是面向接口编程和依赖注入。3.2 依赖注入的三种常见姿势对比依赖注入的目标很明确让“依赖对象”从外部传入而不是在被测类内部创建。第一种是构造函数注入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; };这种方式灵活测试时只在需要时设置依赖但缺点是无法保证所有依赖都被设置一旦漏设运行时可能收到空指针。设置逻辑分散也让人的记忆负担变重。第三种是工厂函数注入也就是在被测类内部通过一个工厂函数创建依赖对象测试时把工厂函数replace掉。这种方式适合对象的创建本身有复杂逻辑的场景比较重一般用不上。就C类对象单测而言我强烈推荐构造函数注入作为默认方案。它最直白、最不容易出错而且配合默认参数还能保持现有调用方代码不变。3.3 实战改造从不可测到可测的完整过程看一个更实际的例子。你有这样一个用户数据同步类它直接调用了外部SDK// 原始代码 class UserSync { public: bool Sync(UserInfo user) { CloudSyncSDK sdk; sdk.Login(config...); std::string result sdk.Upload(user.name, user.data); if (result.find(success) std::string::npos) { return false; } return true; } };问题有几个CloudSyncSDK是具体类没法Mock测试要跑就会真实调用SDK登录和上传可能污染线上数据。改造第一步把SDK封装成接口class ICloudSyncSDK { public: virtual ~ICloudSyncSDK() default; virtual void Login(const std::string config) 0; virtual std::string Upload(const std::string name, const std::string data) 0; };改造第二步让真实SDK实现这个接口或者写一个适配器类实现接口并调用真实的CloudSyncSDKclass RealCloudSyncSDK : public ICloudSyncSDK { public: void Login(const std::string config) override { CloudSyncSDK sdk; sdk.Login(config); } std::string Upload(const std::string name, const std::string data) override { CloudSyncSDK sdk; return sdk.Upload(name, data); } };改造第三步把UserSync改为通过构造函数接收接口class UserSync { public: explicit UserSync(ICloudSyncSDK* sdk) : sdk_(sdk) {} bool Sync(UserInfo user) { sdk_-Login(config...); std::string result sdk_-Upload(user.name, user.data); return result.find(success) ! std::string::npos; } private: ICloudSyncSDK* sdk_; };测试就好写多了class MockCloudSyncSDK : public ICloudSyncSDK { public: MOCK_METHOD(void, Login, (const std::string config), (override)); MOCK_METHOD(std::string, Upload, (const std::string name, const std::string data), (override)); }; TEST(UserSyncTest, SyncFailsWhenUploadResultNotSuccess) { MockCloudSyncSDK mock_sdk; UserSync sync(mock_sdk); EXPECT_CALL(mock_sdk, Login(_)).Times(1); EXPECT_CALL(mock_sdk, Upload(_, _)).WillOnce(Return(failure)); UserInfo user; user.name test; user.data data; EXPECT_FALSE(sync.Sync(user)); }这个改造的实质是把“类直接创建依赖”变成“类持有一个依赖接口”外部把具体实现传进来。接口隔离解耦依赖注入控制替换可测试性就这样建立起来了。3.4 面向接口编程的“度”别把每个类都抽一遍有一种矫枉过正的做法把所有类都先抽成接口再来个BImpl、CImpl哪怕这个类根本没有任何替换需求。这种设计会让工程类数量爆炸阅读代码时要跳很多层白白增加维护成本。我的建议是可以从“替换动机”出发来决定是否抽象接口这个类是否依赖外部资源网络、DB、时间、随机数这个类是否有真实且不同的实现会同时存在这个类是否被其他类以复杂协作方式使用如果三个问题都是“否”就保持具体类即可。如果只有一个“是”就值得考虑接口隔离。还有一个稍微“轻”一点的方案只把需要Mock的方法做成虚函数不用抽象出独立接口class Logger { public: virtual ~Logger() default; void Log(const std::string msg) { // 真实实现 } virtual bool IsEnabled() { return true; } };测试时可以继承Logger重写IsEnabled方法。但这种方式的问题在于Mock的替换点不够清晰一个类里虚方法和普通方法混在一起读代码时难以判断哪些是设计上的扩展点、哪些只是为测试准备的。如果有两个以上方法需要替换我建议还是抽接口更清爽。4. 常见问题与排查技巧实录4.1 EXPECT_CALL明明写了却不生效查哪里这是新手最容易卡住的问题也是最容易怀疑人生的时刻。写好的EXPECT_CALL运行测试时却告诉你“unexpected call”。常见原因有这么几个第一个原因是对象实例不匹配。测试里构造了MockHttpClient mock_client但被测类接收的是另一个指针比如被测类内部拷贝了指针或者你传入了一个新创建的Mock对象那么EXPECT_CALL设置的预期就绑定在被测对象内部的实例上实际调用也发生在这个实例上两者不匹配gMock自然认为这是“unexpected call”。第二个原因是Mock方法签名和基类不一致。比如基类是const成员函数Mock方法忘了写const那么Mock类就生成了一个新方法而非覆写基类方法。被测类调用的是基类虚函数走的还是原逻辑EXPECT_CALL自然不会被命中。排查时先用override关键字确保方法签名一致编译期能帮你抓出一部分错误。第三个原因是被测类持有的是一个值拷贝而不是指针或引用。场景通常是这样的构造函数参数是HttpClient不是HttpClient*传进去时发生拷贝Mock对象和被测类内部的对象是两个独立实例。EXPECT_CALL绑定了外部Mock对象而实际调用发生在拷贝出的实例上。排查这类问题可以先在EXPECT_CALL命中或不命中的方法里加一个断点日志确认运行时到底走进哪条路径再把被测类中依赖指针改为引用类型避免无意的拷贝最后核对EXPECT_CALL里对象的生命周期确保Mock对象在被测类销毁之前仍然存活。4.2 Uninteresting mock function call警告与析构时崩溃有时候你设置了一个EXPECT_CALL但被测代码还调用了同一个Mock对象的另一个方法这个方法没被EXPECT_CALL。此时默认的NaggyMock会输出类似“GMOCK WARNING: Uninteresting mock function call”的警告但测试不会立刻失败。问题在于很多人忽略了警告到Mock对象析构时gMock发现还有已经设置的预期没有满足会输出“EXPECT_CALL failed”并导致测试失败。为什么会产生这种奇怪的“延迟失败”因为gMock的预期检查通常在Mock对象析构时进行它会检查每条EXPECT_CALL的Times是否达到预期没达到就算失败。解决办法也是分几步走如果那个方法本来就不关心把它对应的EXPECT_CALL改成NiceMock整体处理或者在那些不想关心的调用上设置EXPECT_CALL(...).Times(AnyNumber())表示允许任意次数调用如果预期设置正确只是调用路径没走到那就检查为什么没走到这才是真正要发现的bug如果要强制约束所有非预期调用用StrictMock这样错误会在第一时间暴露而不是拖到析构我的习惯是先声明Mock对象时就用NiceMockNiceMockMockHttpClient mock_client;把测试关注点收窄到核心交互上写稳之后再逐步收紧。4.3 私有方法、非虚方法、普通成员函数到底能不能Mock直接给结论不能。至少gMock不能直接Mock它们原因在于Mock的本质是继承和多态它只能拦截通过虚函数表分派的调用。如果一个方法不是虚函数编译期间调用点直接绑定到具体地址Mock对象根本无法改变这个分发路径。那怎么办最实用的是重构被测类把私有方法的逻辑提取到一个公开的虚接口中或者抽成独立类再注入。另一种思路是放弃Mock直接测它暴露的公共方法虽然依赖没隔离但至少逻辑覆盖到了。如果被测类和外部依赖纠缠在一起还动不动就真实连网、真读写文件那说明问题不在测试代码而在于设计——需要用上一部分讲到的依赖注入重构。这里有个值得提的点有人会用模板和编译期技术来“绕过”虚函数限制比如把依赖作为模板参数传入在测试时传入一个替代类。这在C里确实可行而且有些场景下是更好的方案但代价是模板代码的可读性和编译错误信息都会变差对团队协作不太友好。除非你已经对模板元编程很熟练且需要极致性能否则我建议优先用虚接口方案。4.4 调用顺序验证与更精细的交互校验单元测试经常要验证“先做什么、后做什么”。比如上传文件前必须校验权限权限校验失败就不能执行上传。gMock给了一个很好的工具InSequenceusing ::testing::InSequence; TEST(UserSyncTest, UploadCalledAfterPermissionCheck) { MockCloudSyncSDK mock_sdk; UserSync sync(mock_sdk); InSequence seq; EXPECT_CALL(mock_sdk, Login(_)).Times(1); EXPECT_CALL(mock_sdk, Upload(_, _)).Times(1); }InSequence要求后面这些EXPECT_CALL之间的调用顺序必须完全一致否则测试失败。要注意它只管“这些预设之间相对顺序”不强制要求它们之间没有其他调用。如果连“A调用时B还没被调用过”这种绝对约束也要验证需要用Times(0)配合或者用Invoke高级场景记录调用历史。除此之外还有一个容易被忽略的武器Times。不仅仅可以写Times(1)、Times(2)还可以写Times(AtLeast(1))、Times(AtMost(3))、Times(Between(2, 4))。对于“这个方法至少要被调用一次但具体几次不确定”的场景这些匹配器非常有用。4.5 常见错误速查表症状原因处理方案unexpected callMock对象和被测对象不是同一实例或方法签名不一致检查指针传递、修正签名、确认生命周期析构时EXPECT_CALL failed设置了预期但实际调用没发生或提前回归逻辑检查调用路径、检查Times设置大量warning输出非预期调用触发NaggyMock警告改为NiceMock或补上Times(AnyNumber())编译报错“is not a member”MOCK_METHOD写错签名或遗漏override核对基类签名、加override被测方法里调用了真实依赖依赖不是虚函数/接口重构被测代码构造函数注入接口测试因为IO/网络不稳定失败依赖没被隔离把外部依赖抽象成接口后Mock调用顺序错乱导致问题多个EXPECT_CALL被乱序触发用InSequence或配合Times约束这表看着简单但每一条背后都是实实在在踩过的坑。尤其是“Mock对象和被测对象不是同一实例”这条我在项目里见过不下五次多数是因为构造函数传值而不是传引用或者被测类内部又对指针做了浅拷贝/深拷贝操作。排查这类问题的通用技巧是在测试入口打日志输出Mock对象的地址和被测类内部保存的地址一对比就清楚了。写在最后Mock是一面镜子照出设计的质量回头看一下Mock能不能用好其实不仅仅是选择工具和写语法的问题。一个类写好之后如果单测上Mock特别吃力那大概率是这一类在依赖管理上出了问题——耦合太多、替换点不够、层次不清。把Mock用好的过程往往就是把这个类设计理顺的过程。我个人在实际项目中有个体会Mock工具本身能帮你抓到很多交互层的bug但它改变不了糟糕的代码结构。真正的收获其实来自因为要Mock而不得不做的可测试性设计接口抽象、构造函数注入、单一职责。这些设计最终受益的不只是测试还包括代码的可读性和可维护性。所以如果有哪一天的测试因为Mock太麻烦而让你想“换一个框架解决”先想一想是不是应该先回头看看被测类的依赖长什么样。
返回列表