zuixin 2019-12-24
原文:https://www.zhihu.com/question/27313846/answer/130954707
工作中写C++,不敢自称大神,也来斗胆分享(安利)一下经常使用的单元测试框架。
大家都对Google的C++ Style很熟悉了,但除了Coding Style之外,Google还有自己的单元测试框架:gtest (Google Test)和gmock (Google Mock)。
简介gtest的英文Unit Testing C++ with Google Test - ReSharper C++ Blog,英文好的骚年可以直接食用,如果大家确实很需要,我也可以抽空翻译一个。
相关的GitHub链接:google/googletest 可以阅读源码,check out下来使用。
在Google工作,尤其是写C++的程序员,常常离不开写单元测试。所幸的是,Google提供了很成熟,也很实用的单元测试框架gtest和gmock。
gmock是gtest的一部分,也可以说是gtest中比较advanced的topic。至于使用场景(避免在单元测试代码中向production服务发送rpc啦,读取production的资源啦,绕过一些production环境中必须的权限来测试代码的逻辑啦)会在后面举例说明。
正式开始写长一点的C++程序之后,就会逐渐萌发出写unit test来保证代码正确性的需求。例如,一开始写的单元测试可能是这样的。有个函数实现加法:
int Add(int x, int y);
我们最开始会使用assert
来做一些基本的测试:
assert(2 == Add(1, 1)); // 正常的用例 assert(1 != Add(1, 1)); // 异常的用例 assert(sum > Add(num1, num2)); // 溢出异常?如果num1和num2都是正整数的前提下,sum应该blablabla
其实在我们一开始学习写单元测试的时候,老师或者有人生经验的资深码农就已经告诉了我们写单元测试的一些原则:
工作了一些年头,我觉得单元测试起到的最重要作用其实是:让人在修改代码之后能感到安心,踏实(单元测试跑过之后能比用飘柔更自信)。只要跑一把单元测试,就能自动化验证程序逻辑的正确性,而无需在提交代码之前提心吊胆、担心会漏掉什么情况没有处理或者自己新加入的逻辑制造了bug。
其实,写单元测试是很繁琐的。因为要考虑的琐碎的东西其实很多,有时候为了方便,可能还要修改原来已经写好的接口(没错,我菜,我没有先写测试再写实现,我不fashion,我没贯彻test driven development原则,我活该_(:з」∠)_)。
所以,为了能在写单测的时候可以偷点懒,也为了代码读起来舒服一点,gtest框架提供了很多宏:
bool IsAI(const string& name); assert (false == IsAI("vczh")); // 不Fashion! EXPECT_TRUE(IsAI("Chen meng meng")); // Fashion! 而且有比较直白的英语描述,可读性up
诸如此类的还有:
EXPECT_NE(男朋友爱你, 随时时刻都知道你在想什么); // 真理,男朋友爱你真的不等于他就是你肚子里的蛔虫,想要他买Chanel包包给你做礼物而不是劝你多喝热水的话还是得开口直说! EXPECT_GT(x, y); // x > y EXPECT_EQ("光头能对空", GanSi("黄旭东")); EXPECT_THAT(actual_proto_message, EqualsTo(expected_proto_message)); // EXPECT_THAT(value, matcher). 可以比较一些复杂一点的数据结构例如proto buf,vector里的内容: vector<int> numlist1; vector<int> numlist2; .... // In test EXPECT_THAT(numlist1, ::testing::ContainerEq(numlist2));
相反,如果写成这样就显示不出我们fashion的一面了:
for (size_t i = 0; i < numlist1.size(); ++i) { assert(numlist1[i] == numlist[2]); }
如果numlist1
和numlist2
里的元素的顺序是乱的,上面那种写法就不可行了。
有人问,你这样写也只不过是看起来好看罢了,还要多打几个字符,没什么必要吧?这正是为什么我们要使用gtest这个框架。因为框架给我们做了额外的事。试想下,如果测试挂了:
ai.cc:233 assert("钱赞企永不为奴", GanSi("黄旭东"));
core dump里也就只告诉你测试挂在了ai.cc
这个文件的第233行罢了。但如何你使用的是:EXPECT_EQ("钱赞企永不为奴", GanSi(“黄旭东”));
,gtest就会告诉你:
[RUN] XieXingTest.GanSi SC2_test.cc:233 Failure Value Of: GanSi(“黄旭东”) Actual: "光头能对空" Expected: “ 钱赞企永不为奴” [Failed] ....
又例如:
高清无码,4K全彩错误提示,你不仅知道哪个test case挂了,你还能知道当前输出是啥,就不用再慢慢打log看了。
框架帮我们做了一些琐碎的事情,我们就能提高工作效率,实现work life balance
而且在使用上,使用gtest也很方便快捷,只需要(不准确,只是大概这样):
#include <gtest/gtest.h> // 当然偷懒也要include头文件,按照基本【哔~】 TEST(XieXing, GanSi) { EXPECT_EQ(...); EXPECT_NE(...); ... } // 在Google里,在BUILD文件(类似Make)中链接一个额外的gtest main的库,还能省掉手写main函数,轻松加愉快 int main(int ac, char* av[]) { testing::InitGoogleTest(&ac, av); return RUN_ALL_TESTS(); }
简单说说GMock
有些代码,总会有些复杂的依赖。例如使用了某框架的API来发送RPC请求,读取云上的资源BigTable或者需要连接到某个服务请求一些权限获得一个token等等。
这些逻辑会产生一些IO和网络上的开销,在production中使用无可厚非,但如果每个人写代码跑单元测试都要调用这些逻辑,从而又产生很多没什么实际意义的开销(例如发送一些dummy的RPC请求),这对生产环境的资源会造成一定量的浪费。
例如有个类,叫TableManager,他的功能包括在云端创建一张数据表,获得一些表的属性,支持对表进行查找。一些代码作为client使用了这个类,在云端创建表和存取一些数据。但单元测试代码并不希望真的去读写那些在生产环境里的资源,这时候就需要为TableManager类创建一个对应的Mock类来“绕过”生产环境了。
class TableManager { public: // 创建一个名为“table_name"的表,成功就返回true。如果table_name不合法,该表已经存在(这个方法已经被调用过了)或者TableManager本身没有在这个方法调用之前获得合法的token,创建表的请求被拒绝,这个方法就会返回false。 virtual bool CreateTable(const string& table_name); private: // 这个方法会发送一个RPC请求到服务端,根据some_params的内容获得一个token,这样客户端代码在调用这个类的其他关于操作table的方法的时候,才能获得授权,对table资源进行操作。 virtual void GetAuthorizeToken(const IDParams& some_params); // 如果token串可用(非空)就返回true。但在测试环境下并没有真的去获取token,所以token一定是空的,这样CreateTable无论如何都不会返回true了,所以一会儿要做点tiny work。 virtual bool IsTokenReady(); string token_; };
我们不想真的去访问production(生产环境)里的资源,这时我们就可以先创建一个Mock类:
#include "gmock/gmock.h" // 使用 Google Mock. class MockTableManager : public TableManager { public: MOCK_METHOD1(GetAuthorizeToken, string(const IDParams& some_params)); // 有一个参数的方法 MOCK_METHOD1(CreateTable, bool(const string& table_name)); MOCK_METHOD0(IsTokenReady, bool()); // 0个参数的方法,METHOD0 };
一个使用TableManager的类的方法在准备数据,当数据全部准备之后,就会创建一张新表:
bool ClientClass::ProcessData(const string& src_path, const string& table_name, const string& id) { // Prepare data from src_path ... // When preparation is done, store the data to the table. IDParams params; params.set_id(id); ... // Fill other fields table_manager_->CreateTable(table_name); }
在单元测试里面,测试用例就可以这么写
TEST_F(ClientClassTest, ProcessData) { // 测试ClientClass里的ProcessData方法 // 初始化工作...创建一个mock对象,把该对象的指针传给ClientClass初始化ClientClass. MockTableManager mock_table_manager; ClientClass client(&mock_table_manager); // 正片开始了!我们知道ProcessData方法会调用CreateTable,而CreateTable会先调用GetAuthorizeToken去获得一个token,然后用IsTokenReady来验证一下token是否可用,再做爱做的 事情,嗯嗯。 EXPECT_CALL(mock_table_manager, GetAuthorization(params)).Times(AtLeast(1)); // 在接下来的测试里,GetAuthorization至少被调用一次。 EXPECT_CALL(mock_table_manager, IsTokenReady()).WillRepeatedly(Return(true)); // 由于并没有真正发rpc请求获取token,token将是空的,但为了测试,经研究决定,就让你“Ready(成为美利坚合众国大总统,咦!?)”。 EXPECT_CALL(mock_table_manager, CreateTable("valid_table_name")).WillOnce(Return(true)).WillRepeatedly(Return(false)); // 在这个case里,第一次调用CreateTable肯定是成功的,因为我们从未创建表,操作会成功,当再调用一次就会返回失败,因为理论上这张表已经创建过了。 // 准备好了,开车! EXPECT_TRUE(client_class_obj.ProcessData("dummy_path", "valid_table_name", "1024_good_man_whole_life_safe")); // 没问题,这里测试的期望结果是true,因为是第一次创建表,应该会成功。换言之如果测试在这里失败了,就该看看有没有bug了。 // 假设我们作死再调用一次,这时候应该失败,因为相同更多表已经创建过了。 EXPECT_FALSE(client_class_obj.ProcessData("dummy_path", "valid_table_name", "1024_good_man_whole_life_safe"); // 如果实际上ProcessData返回的是true,那就得怀疑一下人生了,因为跟我们想好的不一样啊!说好的“valid_table_name”只能被创建一次呢!?这种bug出现的原因有很多,一些时候可能纯粹是因为手抖都copy了一行CreateTable。 }
Mock了TableManager类之后,我们既能测试代码的功能,又能避免浪费生产环境的资源,是不是有点小愉快?
Google内部有很多功能丰富的开发工具和库,而且文档齐全,经得起岁月的考验,很多优秀的工具和框架都已经开源(例如GTest框架,Protocol Buffers,GRPC等等),实在是居家旅行,杀人越货,开矿推塔,仁义无双,优势很大,吃肉人族,上天入地的必备佳品。
作为一只菜鸡,东西写得不是很好,就做一点微小的工作,分享给大家,谢谢各位 :D