0%

在生命中的某个节点,你可能会回想起一部你和朋友都曾想看又都后悔看过的电影。或者可能你记得那些“你的团队认为找到了杀手级功能,但只是在上线之后又归于沉寂”的时刻。

好的想法在实践中可能会失败,在测试的世界中,一个普遍的好但是经常在实践中失败的主意是基于end-to-end测试周围的测试策略。

测试人员投入时间写了大量的自动化测试,包括单元测试,集成测试,和end-to-end测试,但是该策略可能大部分都投入到了用end-to-end测试来整体验证一个产品。典型情况是,这些测试模拟了真实用户场景。

理论上的End-to-End测试

虽然主要依赖于end-to-end测试是个坏点子,但却很容易说服一个理性人使其相信这个点子理论上是有意义的。

Google的十件我们已知正确的事的第一条是:”专注与用户,其他的一起以此为准”。因此,end-to-end测试专注于真实用户场景听起来是个好主意。而且,这个策略也有广泛的拥泵:

  • 开发人员,喜欢它,因为这把测试工作的大部分转交给其他人负责了
  • 经理人与决策者,喜欢它,因为模拟真实用户场景的测试可以帮助他们简单的判断失败的测试对于用户来说意味着什么。
  • 测试人员,喜欢它,因为他们经常担心遗漏一个Bug或者时写了一个没有验证真实行为的测试用例;从用户视角写测试用例可以避免前面的问题,并且可以给测试人员一种满足感。

真实的End-to-End测试

如果这个测试策略理论上听起来这么棒,那么在实践中怎么搞锉了的呢?为了讲清楚,我在下面展示了一个组合概述,该概述基于我或是其他测试人员的真实体验。在这个概述中,一个组织正在构建一个在线文档编辑的服务(比如,Google Docs)。
让我们假设这个团队已经有一些很酷的测试基础设施可用了。每个晚上:

  1. 服务的最新版本都会被构建
  2. 这个版本会被部署到团队的测试环境
  3. 所有的end-to-end测试会在这个环境上执行
  4. 一封总结了测试结果的邮件报告会被发给整个团队

随着下一个发布日期的逐步接近,为了保证较高的产品质量标准,他们仍然要求在需求完成前要保证90%的end-to-end测试用例通过。现在,还有一天就到截止日期了:

Days Left Pass% Notes
1 5% 所有end-to-end测试用例都挂了!登录服务挂了。所有测试用例都需要登录,因此所有测试都挂了。
0 4% 我们所依赖的兄弟团队,部署到测试环境一个低质量build
-1 54% 一名研发昨天搞挂了测试场景保存。半数左右的测试用例会在执行中保存文档。研发同学花了很长时间用于排查是否是前端bug。
-2 54% 确定了是个前端Bug,研发同学花了大半天的时间来排查问题的根因
-3 54% 昨天一个错误的修复提交合进了仓库。错误挺容易就被定为了,因此,今天一个正确的修复提交被合进了仓库
-4 1% 测试环境机房里发生了物理机故障
-5 84% 大部分小Bug都是被几个主要Bug引起的(比如登录挂了,保存场景挂了)。还有些小问题仍在处理中
-6 87% 应该能做到90%以上的,但是各种原因还是没做到
-7 89.54% 很接近90%了。昨天没有新的修复合进代码库,所以昨天一定有些测试是因为自身脆弱导致没通过的

分析

除了几个问题,测试还是不活了真正的bug的。

哪些是搞得比较好的

  • 影响用户的Bug在被用户发现之前定位并解决了。

哪些是搞得还不够好的

  • 团队完成交付,整整延迟了一个礼拜(而且还加了很多班)
  • 查end-to-end测试用例失败的根因很痛苦也很消耗时间
  • 其他团队的失效以及机房失效有好几天都影响了测试结果
  • 许多小Bug藏在了更大的Bug后面(大坑套小坑)
  • end-to-end测试有些时候还是比较脆弱的
  • 开发人员必须等到第二天才能搞明白一个修复是否真的有效

所以,我们现在知道end-to-end这种测试策略的问题了,我们需要调整我们测试的方式来规避这些问题。但是怎么样才是正确的搞法呢?

测试真正的价值

通常来说,一旦发现未通过的测试用例,一个测试人员的工作就完成了。报告了一个bug,然后修复这个Bug就是开发的任务了。为了确认end-to-end测试策略到底哪里不靠谱,我们需要调出这个问题边界,从第一原则的角度来重新思考问题。如果我们“专注与用户(其他一起则随之而来)”,我们必须要问我们自己:一个失败的测试对用户的好处是什么。下面是对这个问题的回答。

一个失败的测试用例对于用户没有直接好处

尽管这句声明初看起来有些令人震惊,但这是真的。如果产品正常工作,一个测试用例能判断TA(指该产品)是否正常工作吗?如果产品出问题了,一个测试用例能判断TA(指该产品)是否出问题了吗?因此,如果一个失败的测试用例对于用户来说没什么意义,那么什么对用户来说有意义呢?

对于Bug的修复,是对用户有益的

用户只对于Bug被修复感到开心。很明显,为了修复一个Bug,你必须要知道这Bug存在与哪里。为了知道Bug藏在哪,理想中你有一个测试用例来捕获这个Bug(因为如果测试用例不能捕获Bug,那么用户就会发现这些Bug)。但是在整个流程中,从测试用例失败到Bug修复,只有在最后一步(指Bug修复)才真的产生价值。

Stage Failing Test Bug Opened Bug Fixed
Value Added No No Yes

因此,为了评估一个测试策略,不能只是评估它在发现Bug方面表现如何。也必须要评价在帮助研发修复(甚至是防范)Bug方面表现的怎么样。

简历正确的反馈回路

测试创建了一个反馈回路,可以通知开发产品是否正常工作。理想的反馈回路有多个属性:

  • Fast。没有开发人员想要浪费数小时甚至数天来判断是否变更正常工作。有些时候变更不工作,没有人是完美的,并且反馈循环需要多次执行(改-测-改-测)。一个快速的反馈循环,是的修复Bug更快。如果循环足够快,开发人员就可以在讲变更检入之前就执行测试。
  • Reliable。没有开发人员想要花费数小时的时间来调试一个测试用例,结果只是发现这测试用例就是比较脆弱。脆弱的测试用例减弱了开发人员对于测试的新人,并且脆弱测试用例也经常会被忽略,即使是TA们真的发现了产品问题。
  • Isolates failures。为了修复Bug,开发需要找到引起Bug的具体代码位置。当一个产品包含数百万行代码时,且Bug可能在任何地方,好似大海捞针。

小处着眼,而不是大处着眼

那么,我们怎么才能创建理想的反馈回路呢?通过向更小处着眼,而不是更大。

单元测试

单元测试只测产品的一小部分,并且将该部分隔离之后测试。单元测试比较接近于理想的反馈回路:

  • 单元测试 Fast。我们只需要构建一个小单元来测试,且测试用例也趋向于非常小。事实上,1/10秒的执行时间,对于单元测试来说是比较慢的。
  • 单元测试 Reliable。简单的系统和小单元总体上更不容易产生脆弱性。进而,单元测试的最佳实践,特别是与hermetic测试相关的实践,可以彻底移除脆弱性。
  • 单元测试Isolates Failures。即使产品包含100W行代码,如果一个单元测试没通过,你也只需在单元测试覆盖的小单元中搜寻Bug。

书写高效的单元测试需要在如下领域磨炼出技巧:比如,依赖管理,mocking以及hermetic测试。在这我不会覆盖这些这些技巧,但是作为一个样例,可以参考提供给新Google员工阅读的How Google builds以及测试一个计时器

单元测试与end-to-end测试的对比

对于单元测试,你必须要等:首先等整个产品构建出来,然后等TA部署完毕,最后才能执行end-to-end测试。当测试用例执行时,脆弱性如影随形。即使一个测试用例发现了Bug,Bug也可能在产品的任何地方。
尽管end-to-end测试在模拟真实用户场景方面表现良好,但这个有点将很快被end-to-end反馈回路上的劣势冲淡。

1 Unit End-to-End
Fast Yes No
Reliable Yes No
Isolates Failures Yes No
模拟真实用户 No Yes

集成测试

单元测试确实有个巨大的缺点:即使每个单元都工作完好,你也不能确定他们在一起工作时是什么状态。但即使这样,你也不需要end-to-end测试。对于这种情况,可以使用一个集成测试。一个集成测试将几个单元组合起来,通常是两个单元,测试他们一起工作时的表现,以验证他们在一起工作时没问题。
如果两个单元之间的集成除了问题,且你能够写一个更小规模/更专注的集成测试来发现该问题,为什么要写一个end-to-end测试呢?如果你确实需要考虑的更广泛一点,那么你只需要“扩大一点点”来验证这些单元一起工作良好。

测试金字塔

尽管有了单元测试和集成测试之后,你还是希望有少量的end-to-end测试来验证整个系统。为了在三种测试类型中找到平衡,一图胜千言,测试金字塔表现的很到位。下面是个简化版的测试金字塔,来自于公开的Keynote:

测试金字塔

测试中最大量的内容应是金字塔底层的单元测试。当向金字塔上层移动时,测试范围变的越来越大,同时数量也应越来越少。

作为一个参考,Google通常建议70/20/10的比例分割:70%的单元测试,20%的集成测试,10%的end-to-end测试。确切的比例,每个team都会有所不同,但总体来看,应该保持金字塔的形状。要努力避免如下的反模式:

  • 融化的冰激凌/倒金字塔。主要依赖于end-to-end测试,使用很少的集成测试,甚至更少的单元测试。
  • 沙漏。大量的单元测试,然后用end-to-end测试来覆盖本应用集成测试覆盖的内容。沙漏底层有很多的单元测试,顶层有许多的end-to-end测试,但是中间缺少集成测试。

就像生活中的三角形更容易保持稳定一样,测试金字塔也可能是最稳定的测试策略。

文章翻译自

https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html

测试替身是用来在测试中取代真实对象的,很类似与电影里演员的特技替身。测试替身的所有分类通常会被称为”mocks”,但由于不同的测试替身有不同的使用场景,对TA们进行区分还是挺重要的。最重要的测试替身有stubs,mocks和fakes。
stub没有逻辑,只返回你让TA返回的值。当你需要一个对象返回一个特定值,以使待测代码到达某一确定状态时,可以使用stubs。尽管手写stubs挺简单的,但使用mock框架通常可以减少很多的样板代码。

1
2
3
4
5
6
7
8
// Pass in a stub that was created by a mocking framework.
AccessManager accessManager = new AccessManager(stubAuthenticationService);
// The user shouldn't have access when the authentication service returns false.
when(stubAuthenticationService.isAuthenticated(USER_ID)).thenReturn(false);
assertFalse(accessManager.userHasAccess(USER_ID));
// The user should have access when the authentication service returns true.
when(stubAuthenticationService.isAuthenticated(USER_ID)).thenReturn(true);
assertTrue(accessManager.userHasAccess(USER_ID));

Mock带有被调用方式的预期,如果没有通过那样方式被调用,则该测试应该失败。Mock是用来测试对象之间交互的,且在那些没有其他可见状态变化或返回值的场景下是非常合适的(比如,你的代码从磁盘读取数据,你希望保证只读一次磁盘,你可以用mock来验证该方法确实只赌了一次磁盘)。

1
2
3
4
5
6
// Pass in a mock that was created by a mocking framework.
AccessManager accessManager = new AccessManager(mockAuthenticationService);
accessManager.userHasAccess(USER_ID);
// The test should fail if accessManager.userHasAccess(USER_ID) didn't call
// mockAuthenticationService.isAuthenticated(USER_ID) or if it called it more than once.
verify(mockAuthenticationService).isAuthenticated(USER_ID);

fake不适用mock框架:它是API的一种轻量级实现,看起来就想真实实现一样,但不适合在生产环境运行(例如,一个内存数据库)。fake可以使用在那些无法使用真实实现来测试的场景(例如,如果真是实现太慢了,或者需要通过网络访问)。你不需要自己来实现fake,因为fake通常是被那些维护真实实现的人或团队维护的。
1
2
3
4
5
6
7
8
9
// Creating the fake is fast and easy.
AuthenticationService fakeAuthenticationService = new FakeAuthenticationService();
AccessManager accessManager = new AccessManager(fakeAuthenticationService);
// The user shouldn't have access since the authentication service doesn't
// know about the user.
assertFalse(accessManager.userHasAccess(USER_ID));
// The user should have access after it's added to the authentication service.
fakeAuthenticationService.addAuthenticatedUser(USER_ID);
assertTrue(accessManager.userHasAccess(USER_ID));

术语”测试替身”是Gerard Meszaros在《xUnit Test Patterns》一书中创造的。你可以在该书中找到更多关于测试替身的信息,或者该书的网站。你也可以在Martin Fowler的文章中找到对于不同测试替身的讨论。

文章翻译自:

https://testing.googleblog.com/2013/07/testing-on-toilet-know-your-test-doubles.html

单元测试主要有两种方式来验证待验证代码是否正常工作:通过测试状态和通过测试调用。二者有什么分别呢?

测试状态意味着只去验证待测代码返回的结果是否正确

1
2
3
4
5
6
7
8
public void testSortNumbers() {
NumberSorter numberSorter = new NumberSorter(quicksort, bubbleSort);
// Verify that the returned list is sorted. It doesn't matter which sorting
// algorithm is used, as long as the right result is returned.
assertEquals(
new ArrayList(1, 2, 3),
numberSorter.sortNumbers(new ArrayList(3, 1, 2)));
}

测试调用意味着要验证待测代码确实正确地调用了某些方法

1
2
3
4
5
6
7
8
9
public void testSortNumbers_quicksortIsUsed() {
// Pass in mocks to the class and call the method under test.
NumberSorter numberSorter = new NumberSorter(mockQuicksort, mockBubbleSort);
numberSorter.sortNumbers(new ArrayList(3, 1, 2));
// Verify that numberSorter.sortNumbers() used quicksort. The test should
// fail if mockQuicksort.sort() is never called or if it's called with the
// wrong arguments (e.g. if mockBubbleSort is used to sort the numbers).
verify(mockQuicksort).sort(new ArrayList(3, 1, 2));
}

第二种测试(测试调用)可能会使得测试覆盖率比较高,但TA并不能告诉你待测的排序算法是否正常工作,只能知道quicksort.sort确实被调用了。这也是为什么在大多数场景下,都想要测试状态而不是测调用。
总的来说,调用在正确性不止依赖于返回值时才需要测试。在上述的例子中,只有在“quicksort是否被调用真的很重要时(比如,如果用了其他排序算法会导致方法执行很慢)”,才应该去测试调用,否则测试调用就真的没有必要。

一些其他可能会需要测试调用的场景

  • 待测代码调用了一个方法,该方法在不同的调用顺序及调用次数下,表现完全不同,例如有副作用(例如,希望保证只会发送一封邮件),延迟(例如,想要确保只会产生确定次数的磁盘访问)或者多线程问题(比如,如果以错误的顺序访问,会导致产生死锁)。测试调用保证如果这些方法没有被正确调用,测试用例就挂。
  • 在测试UI,且UI的渲染细节从UI逻辑中抽象分离出去了(例如使用MVC或MVP)。在测试Controller/Presenter时,只会关心View的具体方法确实被调用了,而不是去关心真的渲染成了什么样,因此你可以测与View之间的调用。类似的,当测试View时,你可以测与Controller/Presenter之间的调用。

文章翻译自:

https://testing.googleblog.com/2013/03/testing-on-toilet-testing-state-vs.html

测试者的窘境

测试者的窘境
我想让我的测试跑的飞快! 但是不行,我依赖这么多的服务,我可能得fake,mock,stub这些服务才行。但这些Test Double手段有如下问题:

  • 需要一直维护这些Test Double实现
  • 当Test Double的实现与真实实现不一致时,非常容易出Bug

    如果我能有个又快又健壮的Test Double实现就好了。 —测试者
    我们团队认为我们找到了一个折中的方式来解决这个问题. 这需要一个接下来会详细介绍的工。但总体来说,我们做的是将这样的测试分成两种模式。第一个模式是对着真实的服务测试,我们将我们自己的服务启动起来,其他依赖的服务也启动起来,然后执行测试,在执行这一类测试的过程中,我们把所有在测试中产生的与我们依赖的服务产生的RPC交互写进RPC LOG。这种方式使得我们可以跑一个轻量版本的测试用例。相对于启动这么多昂贵的服务,我们要做的是启动一个dumb实现。这个dumb实现所做的事就是从RPC日志中读取,并将之前录制的RPC应答重放。你验证的代码还是可以变化的,并且测试用例中的断言还在正常的保护你的待测系统。

RpcReplay:

将真实实现的入参及出参记录为日志。
录制真实服务的日志

然后,以录制的日志为基础实现一个轻量级版本的服务,对着TA测试。
注意:当你修改了对待测系统的调用参数时,然后待测系统修改了对其他服务的调用参数时,你需要及时更新你的RPC logs。这是唯一的要求。除了会影响请
求参数的修改,其他任何时候,都可以对着RPC Log版本的测试用例做提交测试。这类测试是飞快的,因为你不需要真的启动18/32个服务。
你试图测试的代码还是不断更新的。
当你修改服务的请求时,更新RPC日志。
这些日志会被检入到代码仓库里,并且是可读的。
基于日志的轻量版本服务

这会不会比较危险?

Q: 如果真实服务修改了TA的实现怎么办?在这种情况下我们会不是是在对着一个过时的log做测试?
A: 如果一个服务修改了接口,这个修改会导致线上故障。举个例子我们依赖服务上线了Version2,但现在我们的测试用例还是对着version1的逻辑写的.因为这逻辑是录制来的,我们现在还在(测试中)使用Version1的逻辑。

关键点在于:如果一个服务上线了break了对其他服务承诺的修改,这样的修改会产生线上故障,所以如果该服务在明天要上线version2,这个修改break了你的服务请求路径,你写的关于你的服务的任何测试都没办法阻止对方做类似的上线。他们可以立刻上线,这会导致所有使用version1协议的其他服务出现故障。在这一点上,你必须要依赖其他团队,因为完备的测试自己的API并在上线前提供一个前移窗口,是他们的责任。当他们把服务升级到version2时,有两个可能:

  1. 这次升级导致了线上故障:这不是你的测试用例需要保护的场景,这是他们的测试用例需要保护的场景。
  2. 另一个可能就是下面要讲的良性修改。

良性升级服务的响应

服务修改了实现,但不会修改接口,那么这个修改是良性的。比如说他们修改了数据格式,但对于RPC的返回格式依然保持不变,或是增加了新的元数据(不会影响你的请求).
我们会跑一个后台job不断的更新日志。这个方式会使得你用的日志版Fake一直保持最新。

请求的每次执行结果会不一样吗?

十分不幸,是会不一样的。
SUT每次发出的请求不一样

只要可能就移除非确定性的行为。确定性行为容易理解,重现,debug。

有些信息确实会不一样,比如请求中带了时间戳,id,IP地址等。对于这些非确定性的字段,就需要在录制是过滤掉这些非确定性字段,而在重放时再单独设置。
录制时去掉非确定性信息
重放时增加非确定性信息

额外的好处

一个服务挂了,不会影响你的测试。因为你的提交测试(pre-commit test)不依赖于真实的服务,而是依赖于日志版Fake的。
服务挂了不影响测试

如下两张图对比了没有/有RpcReplay时的测试稳定性数据:
没有/有RpcReplay时的用例稳定性对比
而且执行速度比之前提高了50%!

文章翻译自

GTAC 2015,https://docs.google.com/presentation/d/1IF30CK1z8xzj8xZxJiE7LBQoyzK770yZuBzJR2pfHng/pub?slide=id.gd8dcf688b_0_0

代码覆盖率(也被成为测试覆盖率)统计了源代码的那些行被测试执行过了。关于测试覆盖数据的一个常见误解是:

我的源代码的代码覆盖率很高;因此,我的代码是被充分测试了的。
上面的陈述试错误的!覆盖率高是个必要条件,但不是充分条件。
充分测试的代码 ===> 高覆盖率
充分测试的代码 <=X= 高覆盖率

最常用的覆盖率数据是行覆盖率(statement coverage)。这是收集成本最低的指标,也是最符合直觉的。行覆盖测量了一行代码是否被测试执行过。行覆盖没有测量程序执行路径被覆盖的比例。

行覆盖率的限制

  • TA不考虑所有可能的数据输入。考虑下面这段代码:

    1
    int a = b / c;

    这段代码可以被b = 18, a = 6覆盖,但没有被c = 0的场景测试过。

  • 一些工具没有提供fractional coverage。例如,在下面的代码中,当条件a为真的时候,代码就已经被100%覆盖了。条件b却没被验证过。

    1
    2
    3
    if (a || b) {
    // do something
    }
  • 测试覆盖分析只能告诉你已存在的代码被覆盖的程度。TA不能告诉你应该存在的代码是否被执行过。考虑下面的代码:
    1
    2
    3
    4
    5
    6
    7
    error_code = FunctionCall();
    // return kFatalError, kRecovableError, or kSuccess
    if (error_code == kFatalError) {
    // handle fatal error, exit
    } else {
    // assume call succeeded
    }
    这段代码只处理了3个可能返回值中的两个(是个Bug)。缺少了当返回kRecoverableError时的错误回复逻辑。对于哪些只产生kFatalError和kSuccess的测试用例来说,覆盖率就已经达到了100%。对于kRecoverableError的测试用例没有增加测试覆盖率,如果只从覆盖率目标来看像是“多余”的,但是TA能暴露出来一个Bug!

因此做覆盖率分析的正确姿势是:

  1. 确定你的测试用例足够可读,先不管测试覆盖率。
    这意味着写必要的测试用例,而不只是满足测试覆盖率要求的测试用例最小集
  2. 检查测试的覆盖率结果。找到被测试遗漏的代码。也要注意预期之外的覆盖模式,通常意味着存在Bug。
  3. 增加测试用例来覆盖2中发现的遗漏。
  4. 重复执行2-3 直到再增加测试用例就效用降低为止。如果有些边界场景难以测试,考虑把代码重构以提高可测试性。

参考这篇文章: 如何错误使用测试覆盖率

参考文献

https://testing.googleblog.com/2008/03/tott-understanding-your-coverage-data.html

考虑一个复杂的web应用。实际上,其由一个服务器迷宫,每一个负责不同的任务,并且有这相互复杂的交互。每一个用户行为会游走“服务器迷宫”后返回给终端用户。我们应该怎么对这样应用写end-to-end测试用例呢?

End-To-End Test

一个end-to-end测试在Google的测试宇宙中指代:验证了整个从请求到响应间的全部服务栈。下面是一个简化的SUT(System Under Test)的示例,用以说明一个end-to-end测试用例用来验证什么。注意SUT中的Frontend Server与一个第三方服务相连,但示例中的请求是不需要这部分信息的。

SUT-1

对这么一个系统,要写一个又的end-to-end测试用例的一个挑战是避免网络访问。设计到网络访问的测试通常比只访问本地资源的测试要慢,并且访问远程服务也会因为不可决定性以及远程服务的不可用导致脆弱

Hermetic Server

在Google设计end-to-end测试的一个技巧是Hermetic Servers
什么是Hermetic Server呢?简单说就是”server in a box”。如果你可以将整个服务在一台无网络连接的机器上启动起来并保证其正常工作,那么你就拥有一个hermetic server了。广义的hermetic定义中只要是一套隔离的系统即可,本文是广义hermetic的一个特例。
为什么hermetic server是有用的呢?因为,如果你的整个SUT是由一系列的hermetic server组合而成的,那么该SUT就可以在一台机器上启动起来,直接测试;没有网络连接的必要!这个单机可以是一台物理机也可以是一台虚拟机。

设计Hermetic Servers

构建hermetic server的过程,起始于一个新服务设计阶段的早期。有一下几点值得关注:

  • 所有指向其他服务的连接,都需要再运行时以一种DI的形式注入进去,比如命令行参数。
  • 所有静态文件都被打包进了该server的二进制包里。
  • 如果服务需要访问远程存储,保证该远程存储可以用数据文件或内存实现替换掉。

满足上述需求,会使我们拥有一个高度可配置的服务,该服务也有潜力成为一个hermetic server。但广有这些特点还不够。我们需要如下完成如下几项,才能真的完成一个hermetic server:

  • 保证那些我们的测试用例不会使用的连接点,都有相应的fake实现,以确保最终我们能验证“确实没有进行交互”。
  • 提供一些工具来帮助快速灌入测试数据。
  • 提供日志工具,在请求/响应经过SUT时,trace下这些 请求/相应。

在测试中使用Hermetic Servers

让我们把上面展示过的SUT拿过来,并假设所有涉及的服务都是hermetic server。
下面是一个用户请求的end-to-end测试用例,画出来的样子:

SUT-2

该end-to-end测试做了如下几步:

  • 在一个单机上启动图中所示的整个SUT
  • 通过测试客户端想服务发送请求
  • 验证服务的响应
    一个值得注意的点是图中指向mock server连接在这个测试中是是没有必要的。如果我们想要验证这个服务,那么我们也需要提供一个对应的hermetic server。
    这个end-to-end测试更可靠,主因是TA没有使用网络连接。TA也更快,因为所有数据都在内存里或是本地磁盘里。我们可以在持续构建中跑这些而是用例,因此可以保证图中服务的任何一个发生变更时都可以执行该测试。如果测试失败了,日志模块会帮助找到问题发生在哪里。
    我们在大量的end-to-end测试中使用了hermetic server。一些比较典型的例子如下:
  • 启动测试,用以验证没有DI错误
  • 后端服务的API测试
  • 微缩benchmark性能测试
  • 前端的UI及API测试

结论

Hermetic server也有他自己的弊端。他们会导致测试的执行时间变长,因为其需要在你执行end-to-end测试时把整个SUT都启动起来。如果你的测试限制了CPU和内存资源,那么可能由于服务间复杂交互的提升hermetic server可能会导致你最终超限。你可以在内存存储中使用的数据集大小也远小于在生产环境的存储中的大小。
Hermetic server是一种非常有意义的测试工具。像所有其他工具一样,TA也需要被斟酌着用在那些合适的地方。

参考文献

https://testing.googleblog.com/2012/10/hermetic-servers.html

Background:

Test Case也是代码,但好像又跟真实逻辑中的代码有所不同,那么不同在哪里呢?有这些不同会导致评价测试用例的标准有什么独特之处呢?

本文试图回答上述两个问题:

  1. Test Case作为代码与业务逻辑代码的异同
  2. Test Case评价的标准

Test Case与业务代码的评价标准有所不同

DAMP (“Descriptive and Meaningful Phrases”) V.S. DRY(“Don’t Repeat Yourself”)

下面的测试用例遵循了DRY原则(“Don’t Repeat Yourself”),DRY鼓励代码重用而不是复制,例如通过提出helper方法或者使用循环。但这是一个好的测试用例吗?

1
2
3
4
5
6
7
8
9
10
11
12
def setUp(self):
self.users = [User('alice'), User('bob')] # This field can be reused across tests.
self.forum = Forum()

def testCanRegisterMultipleUsers(self):
self._RegisterAllUsers()
for user in self.users: # Use a for-loop to verify that all users are registered.
self.assertTrue(self.forum.HasRegisteredUser(user))

def _RegisterAllUsers(self): # This method can be reused across tests.
for user in self.users:
self.forum.Register(user)

尽管测试的主体还是很简洁的,但读者需要在脑海里做一些运算才能理解这个测试用例的意图,例如,通过从setUp()到_RegisterAllUsers()跟踪self.users的使用流程才能真正理解用例的含义。因为测试用例没有测试用例来保证了,TA需要保证人能够直观的分析其正确性,即使是这么搞会带来很多的重复代码。这也意味着尽管DRY是生产代码的最佳实践,但DRY原则是不完全适用于测试用例的编写的。

在测试用例中我们可以使用DAMP(“Desciptive and Meaningful Phrases”),DAMP强调可读性高于唯一性。尽管应用该原则会导致代码重复(例如,重复了简单代码),但这使得测试用例更容易被认为是”明显没问题”。让我们就这上面的case,用DAMP原则改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def setUp(self):
self.forum = Forum()

def testCanRegisterMultipleUsers(self):
# Create the users in the test instead of relying on users created in setUp.
user1 = User('alice')
user2 = User('bob')

# Register the users in the test instead of in a helper method, and don't use a for-loop.
self.forum.Register(user1)
self.forum.Register(user2)

# Assert each user individually instead of using a for-loop.
self.assertTrue(self.forum.HasRegisteredUser(user1))
self.assertTrue(self.forum.HasRegisteredUser(user2))

注意,DRY原则在测试中还是有其意义的;例如,使用一个helper方法来创建value object可以从测试用例的主体逻辑中移除过多的细节,从而提升清晰度。理想情况下,测试用例可以做到既可读又没有重复,但有些时候需要在二者之间做个取舍。当写测试用例时,如果碰到了需要在DRY与DAMP之间做个选择,首选DAMP

业务代码有测试用例保障,测试用例没有测试用例保障

如上文中提到的,业务代码有测试用例而测试用例没有测试用例了。这样的差异会导致二者的最终分化。
即由于业务代码有测试用例,因此TA的组织形式可以相对复杂,应用各种设计模式,采用DRY原则保证避免散弹式修改,因为TA明白,其正确性是依赖于测试用例保证的,而不是依赖人眼保证的。人在业务代码的正确性保证方面做的是没明显问题
测试用例则不然,由于没有测试用例的测试用例保障,因此其正确性完全依赖于人眼保证,即Review测试用例需要能够做到明显没问题
因此,测试用例越容易被看懂,越容易判断正确性,越好。

Test Case评价的标准

测试用例稳定性

TBD…

测试用例可读性及明显没问题

TBD…

参考文献

https://testing.googleblog.com/2019/12/testing-on-toilet-tests-too-dry-make.html

你的Calculator类是你最受欢迎的开源项目之一,拥有大量的用户。

1
2
3
4
5
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}

你也对该类做了测试,确保其正确工作。
1
2
3
4
5
public void testAdd() {
assertEquals(3, calculator.add(2, 1));
assertEquals(2, calculator.add(2, 0));
assertEquals(1, calculator.add(2, -1));
}

然而,一个很棒的库承诺只要你用使用它取代当前的add操作符,就可以给你的代码提供几倍的提效。你修改了你的代码以使用该类库:
1
2
3
4
5
6
7
8
9
public class Calculator {
private AdderFactory adderFactory;
public Calculator(AdderFactor adderFactory) { this.adderFactory = adderFactory; }
public int add(int a, int b) {
Adder adder = adderFactory.createAdder();
ReturnValue returnValue = adder.compute(new Number(a), new Number(b));
return returnValue.convertToInteger();
}
}

这是很简单的事,但是代码的测试用例怎么处理呢?由于只修改了代码的实现而用户可见行为没变,应该是没有测试用例需要修改才对。大多数情况下,测试用例专注于测试代码的public API,且代码的实现细节无需暴露给测试用例

测试与具体实现无关将使得其更易维护,因为在每次你想对实现做修改时,测试用例都无需改变。因为测试用例可以作为范例来展示你的类方法如何被使用,因此public APIs也会更容易被理解,因此即使对于实现不了解的人也可以通过阅读测试用例而明确该类该如何被使用。

也会有很多场景是需要对实现做测试的(例如,你要保证你的实现是从cache读取数据而不是从数据读取),但这应该是更不常见的场景因为在大多数情况下,测试用例是独立于实现的。

对了,实现变化时测试用例的准备可能会发生变化(例如,如果你修改了类在TA的构造器中引入一个依赖对象,那么在测试用例中构造该类时时就需要创建传入这些依赖),但只要面向用户的行为未发生改变,那么真实的测试用例内容不应需要改变。

高光评论

  • This could also be extended to UI level tests (Selenium, mobile), where UI element changes should not affect tests unless the user/system flow changes (drastically).

参考文献

https://testing.googleblog.com/2013/08/testing-on-toilet-test-behavior-not.html

这个类真的需要测试吗?

1
2
3
4
5
class UserInfoValidator {
public void validate(UserInfo info) {
if (info.getDateOfBirth().isInFuture()) { throw new ValidationException()); }
}
}

乍一看,这个类的方法包含了一些逻辑,所以可能最好测试一下。但是如果唯一的调用者如下所示,又该怎么讲呢?

1
2
3
4
5
6
7
public class UserInfoService {
private UserInfoValidator validator;
public void save(UserInfo info) {
validator.validate(info); // Throw an exception if the value is invalid.
writeToDatabase(info);
}
}

答案可能会变成:UserInfoValidator类可能不需要测试了,因为所有路径都可以通过UserInfoService被测试到。最重要的分别在于UserInfoValidator类是实现细节,而不是一个public API。

一个public API可以被多个用户使用,这些用户又可能将所有入参的任意组合传给TA的方法。你想要保证这些组合被完整的测到了,以防止使用者在使用这些API时碰到问题。public API的代表有:在代码库的另一处被使用的类,以及在整个代码库到处被使用的Utils类等。
一个实现细节类存在的价值就是来支撑public APIs,并且只被极少数调用者使用(大多数情况都只有一个)。这些类有时可以间接的做测试覆盖,如何做间接测试覆盖?通过测试调用它的public APIs。
测试实现细节类在许多场景下还是有其意义的,例如,有时这些实现细节类很复杂,亦或是很难通过public APIs测试到。当你确实要测试TA们时,TA们往往也不需要像public APIs一样的测试深度,因为有些输入永远也不会被传递给TA们(在上面的代码示例中,如果UserInfoService保证了UserInfo一定不为null,那么就没必要测试UserInfoValidator.validate在info为null时会如何表现了,由于这永远不会发生)。

实现细节类有时可以被看成私有方法,只不过恰巧作为一个独立的类存在而已,因为你一定不会想去直接测试私有方法。

你也应该尽力控制实现细节类的可见性,比如在Java中将其设置为包内私有。

过度测试实现细节的类容易导致如下问题:

  • 代码更难以维护,因为你需要频繁的修改测试用例, 比如当改变一个实现细节的类的方法签名,或是做一个简单的重构。如果测试是通过public APIs覆盖的,那么这些变更就完全不会影响到测试用例。
  • 如果只通过实现细节类来测试行为,你可能会对代码产生错误的自信,因为同样的代码路径在public APIs使用时可能并不work。并且在重构时也要花更多的心思,因为如果不是所有路径都通过public APIs来测试的,那么很难保证public API的行为在重构中不受影响。

可能的问题

Q1: 这是在反对测试金字塔理论,而鼓励更大规模的测试(集成测试,而不是单元测试)吗?
A1:个人认为本文与测试金字塔(更专注于小规模测试)并不冲突,因为用pubic APIs的方式依然可以测试代码的一小部分。只不过,用过public APIs来测多个类而不是只测一个类,理应不是什么问题。

Q2:一个public方法就是一个public API,与被多少调用者使用无关。整个关于实现细节类的讨论都没什么意义。一个public方法应该被测试,作者错误认知的源头在于,TA看了一眼源码,然后得出这样的结论:

  • [哦,这是实现细节类,我不用测TA了]
  • [哦,这个类只被另外一个方法使用了,我可以相应的调整我的测试了]
    一个待测类应该被作为一个黑盒,不要对其做任何假设,就把它当做潜在的toxic测试。永远不应该去看你要测试的类的源码。只有在你想要修改代码时,才需要去看具体实现。

A2: 关于一个类应该被作为黑盒测试,本人没有任何异议。但不是所有的类都应该被同等对待。只是因为开发者决定将一些logic放到独立的类里,并不能自然的指向这些逻辑就需要被深度测试了。
关于一个类要做什么程度的测试,不决定于TA如何被使用,也不决定于TA的实现:如果一个类只在一个地方使用,且其他地方访问不到TA,那么TA也不是public API。所以这一切与实现无关。

写在最后

上述Q2中的问题,实际上极具含金量,短短几句话道出了测试应该怎么做。博文作者对于该说法深表赞同。然而唯一的认知差距在于是否所有独立的类的public方法都需要深度测试,
作者认为,可以认为实现细节类在其契约上就清晰的表达,该类不应该被其他人使用。这样Q2就与本文没有冲突了。

总结一下测试的要点:

  • 把待测API/系统作为黑盒,只关心其公开的契约,不关心其实现
  • 过渡关心实现细节,会给后续维护带来较重负担
  • 测试用例的最佳评价标准是:只要API的语义没变,那么测试用例就可以不变

参考文献:

https://testing.googleblog.com/2015/01/testing-on-toilet-prefer-testing-public.html

由于最近在项目中碰到了由于cpu stuck引起的系统消息系统故障,想要系统性的理解分布式系统中存在的故障模式。
在”Fault-Tolerant Real-Time Systems: The Problem of Replica Determinism”书中,介绍了四种分布式系统中的failure模式。

# 一、分布式系统中存在失败模式

  1. Byzantine or arbitrary failure (拜占庭故障)
    在这种失败模式中,一个节点S可以给一部分节点发送$=true消息的同时,给另一部分节点发送$=false的消息。与此同时,节点S还可能假冒其他节点的消息,例如,对于节点Si来说$=true,但是S会以Si的身份通知其他人$=false。

  2. Authentification detectable byzantine failures (认证模式下的拜占庭故障)
    在这种失败模式中,一个节点S,只能执行自己的拜占庭故障,而无法假冒其他节点。

  3. Performance failures or Timing Failure (性能故障或称为时间故障)
    这种故障模式比较容易理解,节点S,返回的是正确的消息,不过消息到达时间,或者太早或者太晚。

  4. Ommission failures (忽略请求或不响应故障)
    在这种故障模式下,节点S,回复消息的时间是无限长。

  5. Crash failures (崩溃故障)
    在这种故障模式下,节点S,处于Ommission failures中,并且停止响应。(注:这里只是故障模式更强了,从响应时间无限长,到了停止响应。虽然效果上相同。)

  6. Fail-stop failures (失败停止故障)
    在这种故障模式下,节点S会表现出Crash failures的行为,与此同时,我们可以假设集群中任何的正常节点都可以监测到S已经失败了。

注:对于本人遇到的cpu stuck问题,看起来属于Ommission failures或Crash failures。

# 二、故障模式之间的关联

这些故障模式,将拜占庭故障定义为最严重故障,失败停止则是最轻微故障。在这些故障模式中,我们可以对故障节点假设些什么呢?对于拜占庭故障,我们什么都无法假设;对于失败停止故障,我们可以假设全部节点都可以发现该节点failed-stop了。明白了这一点之后,我们就完全可以接受,严重程度高的故障可以包含或者覆盖严重程度低的故障。形式化的定义如下:Byzantine failures ⊃ authentification detactable byzantine failures ⊃ performance failures ⊃ ommission failures ⊃ crash failures ⊃ fail-stop failures。形式化定义可能不太好理解,举个例子,Byzantine failures完全可以接到消息,不会消息来模拟ommission failures,依次类推,更为严重的故障可以模拟出轻微故障。

上述的几种故障可以分为,Value Failure(正确性故障)和Timing Failure(时间性故障)。