老万故事会

彭油,Google Test 二十年反思要不要看?

~~SetUp()~~

哎彭油,Google Test(又叫那个 gtest)了解一下嘛。谷歌开源的 C++ 测试框架,用的人在谷歌外面多得很呢。你 C++ 程序会写?那你十有八九把 Google Test 都用得飞起了哦。

20 年前,我到谷歌的时候,行业里面好的 C++ 测试框架都找不着,公司的项目又像哈密瓜的籽一把一把多得数不完。我就跟老板说:老板!不如我把一个新的测试框架写出来给大家用?老板没有反对。再后来,Google Test 就有了嘛。今年(2025 年)6 月,这个项目就足足 20 岁了,我的青春小鸟一去不回来了啊!

Image

馕烤得久了总会有糊的,软件写多了总会有错的。程序员嘛,要是不想两次都踩一个坑,就只有把自己的作品经常拿出来拷打拷打,总结自己少不经事时的幼稚。这样就进步了嘛。不然呢,就只是变老了嘛。

写 Google Test 的时候我啥都不懂,摸着石头过河把自己的脚砸了。有些伤治好了,有些治不好留了个疤。今天就让大家瞧一瞧这些疤,开心开心一起进步。

我的痛可以分两大类:

一是阳关大道建好了,独木小桥还没拆。有的彭油到了河边,稀里糊涂就上了摇摇欲坠的木头桥,急死个人了嘛。

二是系统默认会干傻事儿,彭油们不费点子劲都掰不回来。

先说第一类。

大多数彭油都不喜欢做选择题。

今天穿啥子衣裳上班?晚饭是吃宫保鸡丁还是蒙古牛肉?P 站 B 站上那么多那么多电影先看哪一部?关税公式里面加哪两个希腊字母?

要是你跟我一样,每天做这些决定烦都烦死了!

工具要足够傻瓜。要是用户想用我们的工具解决一个问题,最好只提供一种合理的解法,最好其它解法一看就不靠谱。

比如把一把剑交给用户,正常人都不会有“我应该握哪头”的疑问。这说明大宝剑的设计成功了!

问题是 gtest 不是一锤子设计出来的。前前后后好几年,陆陆续续有新功能。有了新的好的嘛,按理说旧的差的就没啥理由存在了。

可是,世界上的事情往往不能按理说。你试试看删除一个旧功能。一大堆平时不显山不露水的用户就会立马跳出来:不能删!我还用着呢!

为了不跟用户的熊熊怒火正面硬刚,有的时候我就从心了。

Image

结果嘛,就是用户只看见一堆功能,有些事情吧,好像用这个也行,用那个也行。到底该用哪个?哎呦我的头疼病犯了。

再说第二类。

懒惰是程序员的美德。正因为想偷懒,他们才搞出了自动化。不然,computer 这个词今天的意思还是“打算盘的人”。

既然这个行业懒人多,我们设计工具的时候就要考虑到对懒人友好。最好拿来就能用,最好不需要改配置。

gtest 的行为有一些是不太合理的,我也想改。但是有的东西一改就会影响到老用户。他们惹不起,那就不改了吧。用户想要更合理的行为?只能主动改配置。

这样,老用户舒服了,新用户麻烦了。

接下来,我掰开了讲一讲。

~~匹配器好,断言谓词不好~~

一千个彭油有一千个测试需求。一个好的测试框架一定要允许用户自己上手扩充。

Google Test 允许用户扩展他们的测试词汇表。

最初,我的做法是让用户定义自己的谓词断言(Predicate Assertions):用户可以写一个布尔函数,用来判定它的参数是否满足某个特定条件。然后,他们可以在 EXPECT_PREDn() 这个宏里面使用这个布尔函数来判断一组数据是否满足这个条件。如果不满足,Google Test 会自动打印出所有参数,方便用户调试。

我们来看一个具体的例子。假定用户想测试两个整数是不是互质,一种做法是先写一个 MutuallyPrime(m, n) 函数来做这个判断:

// 如果 m 和 n 除了 1 以外没有公约数,返回 true。boolMutuallyPrime(int m, int n) { ... }

然后,就可以配合 EXPECT_PRED2() 使用这个函数了:

EXPECT_PRED2(MutuallyPrime, 2, 3);  // 成功EXPECT_PRED2(MutuallyPrime, 8, 6);  // 失败EXPECT_PRED2(MutuallyPrime, GetFoo(), GetBar());

要是上面最后一行测试失败了,系统会打印:

Expected: MutuallyPrime(GetFoo(), GetBar()) is true  Actual: false, whereGetFoo() is 42GetBar() is 48
问题来了:到底是 GetFoo() 算错了,还是 GetBar() 算错了,或者两个都错了?
除非这段代码是我自己昨天才写的,我也不清楚啊!
所以说,这个谓词断言功能虽然能让用户扩充他们的测试词汇,但是没有让测试代码清楚表现出用户的意图。维护测试麻烦了!

春去春又回......

一年后,我在设计 Google Mock(Google Test 的一个模块)。在调研时,我从 jMock(一个 Java mocking 框架)那里学到了匹配器的概念。茅塞顿开了呀!相见恨晚了啊!

啥子是匹配器(matcher)?跟谓词一样,它们可以判断一条数据是不是符合测试要求。除此之外,它们还有一些额外的功能:

  1. 每个匹配器都会描述自己是干啥的。

  2. 如果匹配一条数据失败,它们还会解释为啥失败了。

  3. 最后,我们还可以把几个小匹配器组合成一个大匹配器,完成更高级的测试任务。更妙的是,这个大匹配器可以借助小匹配器的帮助,描述清楚自己的功能并且解释清楚为啥某条数据不能匹配。

比如,Lt(5) 是一个简单的匹配器,它的自我描述是“小于5”。

要是想验证 GetFoo() 的结果小于5,就可以写:

EXPECT_THAT(GetFoo(), Lt(5));

要是想验证一个数组里面每个数都小于5,就可以写:

EXPECT_THAT(SomeArray(), Each(Lt(5)));

要是验证失败,你会得到一条有用的消息:

期望:SomeArray() 每个元素都小于5现实:{1, 3, 2, 6, 7}, 3号元素是6(不小于5)

这条消息里面,“每个元素都”是 Each() 提供的,“小于5”是 Lt(5) 提供的,“3号元素是6”是 Each() 提供的,“不小于5”是 Lt(5) 提供的。这些小匹配器彼此配合,不但完成了验证工作,还一起生成了清楚的报错信息,对用户帮助很大。

匹配器嘛,比谓词好得多得多。

而且,用户可以按自己的需求定义新的匹配器,科技树不会被框架锁死。

我马上就在 Google Mock 中实现了匹配器。

要是用匹配器风格改写上面这个 MutuallyPrime 的例子,看起来是这个样子的:

// 判定被测试的数据是否和 rhs 互质。MATCHER_P(IsMutuallyPrimeWith, rhs, "") { ... }...EXPECT_THAT(GetFoo(), IsMutuallyPrimeWith(GetBar()));

让我们把注意力放在最后一行。很明显 GetFoo() 是被测试的对象,而“is mutually prime with GetBar()”(与 GetBar() 互质)是我们期望测试对象有的属性。这样的代码读起来就像自然语言,清楚地表达出了作者的意图。

所以,尽管匹配器最初是为 mocking 这一特定需求设计的,但它们可以完美取代谓词断言。

谓词断言叹曰:既生瑜,何生亮!

要是能去掉 Google Test 的谓词断言功能该多么美好啊!

我悔之晚矣。只能建议用户彭油们:

  • 不要再写新的谓词断言了!从今天起,面朝大海,改成使用匹配器。

  • 写测试断言时,尽量用 EXPECT_THAT,避免其它那些花里胡哨的断言宏,比如 EXPECT_LT, EXPECT_PRED2 之流。你只需要 EXPECT_THAT。

~~构造函数好,SetUp()不好~~

Google Test 遵循的是 xUnit 的设计思想:要是一组测试案例在逻辑上是相关的,你就可以定义一个测试夹具(test fixture class)来安放它们的共享逻辑。(彭油,测试夹具跟上大刑的关系是莫有的,害怕就不要了。)

每次 gtest 要跑一个测试案例之前,都会创建一个新的夹具供这个案例使用。不同的案例不会共享同一个夹具,因为它们的状态要分开的嘛,不然就互相下绊子了嘛。

在跑测试案例的逻辑之前,gtest 会自动先初始化这个夹具。等案例逻辑跑完了,还会自动把夹具清理干净,烂摊子不留给系统。

在 gtest 里面,用户可以把夹具的初始化逻辑写在一个叫 SetUp() 的函数里面。类似地,清理夹具的逻辑要写在 TearDown() 函数里面。

以上是我的原始设计。

后来我发觉自己傻叉了,SetUp() 和 TearDown() 没有必要的嘛。反正 gtest 在创建夹具对象的时候会调用它的构造函数,用完了又会调用它的析构函数。我们把初始化逻辑放在构造函数里面,把清理逻辑放在析构函数里面,不好吗?

相比之下,构造函数和析构函数还有几个优势:

  1. 要是我们在构造函数里初始化一个成员变量,有机会把它定义成 const。大家知道,const 变量最耿直,说一不二,生下来是几,到死还是几,海枯石烂心不变。这样写的测试,好懂好改好 debug,丈母娘看了好欢喜。

  2. 有时候我们要从一个夹具类派生出一个子类。经常学 C++ 的彭油都知道,子类的构造函数/析构函数总是会自动调用父类的构造函数/析构函数,编译器都帮你做好了,不用管,根本不用管。但是!要是把逻辑放到 SetUp() 和 TearDown() 里面,写子类的彭油就一定要记得在子类的 SetUp() 和 TearDown() 里面调用父类的 SetUp() 和 TearDown()。哎呀,麻烦。

所以说,老万写 SetUp() ----多此 one set-up。

现在嘛,把 SetUp() 和 TearDown() 去掉有点晚了,太多人都在用它们了嘛。

我的建议是:不要再用 SetUp() 和 TearDown() 了。甚至可以在项目的 CI 里面添加一个静态检查,看到有人在测试代码里定义 SetUp() 和 TearDown() 就报警,看他还敢不敢!

~~4132好,1234不好~~

有没有想过,要是你定义了测试案例 1,2,3,4,系统应该按什么顺序跑它们?

啊?难道不是 1,2,3,4 吗?

确实,这么干实现容易、好理解,也不会让人意外。

但是,这么干也让一个测试案例很容易依赖于它前面的那些测试案例。

比如,案例 1 创建了一个文件,案例 2 二话不说就可以把这个文件拿来就用。

这种依赖性很糟糕----现在嘛,要理解测试案例 2 的逻辑,你还需要了解案例 1 干了些啥。

所有的测试案例都应该独立----这样,要是哪个案例失败了,我们可以单独调试它,不用每次把它前面的案例再跑一遍。要是我们需要删除一个案例,其它案例也不应该因此崩溃。

gtest 一开始是按固定顺序跑案例的。后来,为了帮助彭油们养成写独立测试案例的好习惯,我添加了一个 --gtest_shuffle 开关,让 Google Test 按随机顺序来跑。基本上,打开这个开关后,测试案例之间的隐藏依赖就很难了。

当然,gtest 的默认行为还是按固定顺序跑,因为改成随机跑很多用户的测试就通不过了嘛。

不过,我强烈建议你在跑 Google Test 测试的时候打开 --gtest_shuffle。这么说吧,所有新项目一开始就该这么设置。听我的没错。

~~有测试案例好,没有测试案例不好~~

你知不知道一种感觉叫做荒凉,

在无垠的时间的旷野上,

听到自己心跳的声音?

你试没试过测试程序里一个案例都不放,

让 Google Test 白跑一趟,

还以为自己代码没毛病?

要是找不到测试案例,Google Test 会高高兴兴告诉你太平无事。毕竟,一个失败案例都木有哇!

这......好像哪里有点不对劲?

没有测试案例,恐怕是用户不小心搞错了。做为一个刚正不阿的测试框架,gtest 理应让测试挂掉,不然用户哪来机会反省。

你可能会想:我才不会犯这种错误!其实,出这种错比很多人想象的更容易。

有的大聪明彭油在脚本中用通配符(比如“foo_*_test.cpp”)来匹配测试源文件。通配符有可能写错(比如文件名是 foo-abc-test.cpp,不是 foo_abc_test.cpp),导致啥都匹配不到。结果,测试程序里不就一个案例都没有了吗!

还有一种情况,有的彭油在编译测试程序时把链接器的参数搞错了,明明写了一堆案例,最后一个都没有用上。

痛定思痛,今年(2025 年)2 月,我给 Google Test 加了一个 --gtest_fail_if_no_test_linked 开关。打开后,gtest 终于会在找不到测试案例时报错了!

建议彭油们马上升级到最新的 gtest,然后在跑测试时把这个开关打开。

~~TearDown()~~

彭油们,你觉得 Google Test 还有哪些坑?请把答案打在留言区。

~~~~~~~~~~

老万近期文章:

~~~~~~~~~~

关注老万故事会公众号:

码字不易,呕心沥血只是希望更多人看到。如果喜欢这篇文章,请不吝三连。谢谢!🙏