测试五要素

本文翻译自 https://www.devmynd.com/blog/five-factor-testing/

原作者:Sarah Mei

译者:@nixzhu


在90年代后期,我初次做开发工作时,开发者们通常都不写自动测试。那时,大公司依赖测试组做手动测试,或者使用复杂(且昂贵)的自动测试软件。而小公司则更多地依赖代码审查,“开发”后会经过数月的“集成”,或者干脆什么都不做,全凭运气。

但时代改变了。今时今日,在大多数团队里,编写自动测试已成为开发者工作的一部分。对代码库的改动需要至少搭配一些自动测试(通常由改动代码的开发者编写)来检验,不然改动不会被认为是完成的。这解放了专门的测试人员,(在有他们的公司里)让他们专注于更有价值的活动,例如探索性测试

与其他转变一样,它发生时悄无声息,完成后就很显眼。在90年代后期,写测试的开发者会显得奇怪。难以想象,20年后,不写测试的开发者反倒显得奇怪。但我们就是走到了今天。

欢迎来到未来

飞车!个人喷气背包!悬浮滑板!写测试已从“小说活动”变成“另一件我们所做的支持代码改动的事情”,就像会议、电邮或用Slack交流。

哎~

但就像会议、电邮和Slack,有时我们写测试只是机械地去做而已,不管它是否真的有用。这样一段时间后,你会听到如下论述:

  • “测试太[慢|片面|不可预料]。”
  • “我们的测试没有覆盖我们所需要的,但我们没时间去更新了。”
  • “写测试只是花了两倍的时间讲故事,而且是为了无用的理由。”
  • “故事结束了。我只是必须写测试。”

要修正这些问题,且让测试(以及一个测试过程)真正服务于我们,那我们就需要重新连接那原初的驱使我们编写测试的潜在需要。令人讶异的是,居然没人写下它们。也许它只是假设我们都知道,但在我为人们列出它们足够多次以后,我想,我应该写一篇博客,以后直接发给他们就好了。那就开始吧。

五个要素

我们编写测试有五个原因。不论我们是否意识到,我们的个人测试哲学构都建于我们如何判断这些原因的相对重要性。许多人认为要素1和2是编写测试的标准原因,并经常谈论。但我们关于测试的争论,不论是团队内部还是互联网上,经常来自于我们对要素3、4、5未阐明的理解差异。

我们先独立检查每个要素,然后看看具体的例子。我们将考虑,在我们决定如何测试代码时,它们会如何结合在一起。

好的测试能……

1. 验证代码的正确性

2. 防止将来的回归

3. 记录代码的行为

4. 提供设计指导

5. 支持重构

让我们分别看看它们的细节。

1. 验证代码的正确性

在最直接的意义上,我们中的大多数写测试的目的就是增强我们增加或改动代码的自信,确保代码的行为与我们预想的一致。在读大学时,我写了一些shell脚本来操练我的编码作业。我从没有将其上交,因为在那时,这些测试脚本的唯一目的就是验证而已。毕竟,我的计算机科学教授只在乎我作业代码的输出是否与他们想要的一致。

2. 防止将来的回归

立即验证对小的编码作业来说完全足够,但我们中的大多数都工作于更大更复杂的代码库,而代码库中还有其他人在同时工作。

在此状况下,你为你的代码编写的自动测试将成为“套装(测试集)”或集合的一部分,其中的测试将验证系统的不同部分。做出一个改变,然后运行这个测试集并看到所有测试通过,这将给你自信:改动没有破坏程序的其他部分。这能防止“回归”,一个华丽的词汇用于描述那些“过去工作,但现在不再工作的东西”。

一旦我们的测试成为套装的一部分,那们在将来,其他开发者也会有这样的信心:他们不会偶然破坏我们的东西。

3. 记录代码的行为

“程序是写给人读的,只是顺便让机器执行。”——Hal Abelson

代码即是沟通——主要是和其他开发者,其次才是和计算机。由于你的自动测试也是代码,它们同样也是沟通,并且你可以进一步明确地将它们设计为被测试代码的外部文档。

当然,已有许多方式可以记录你的意图:

  • 长文,例如wiki或在README中
  • 代码中的注释
  • 程序元素(例如变量、函数以及类)的名字

测试通常被忽视为文档的一种形式,但对将来的开发者来说,它们其实比上述方式更有用。首先,它们是可执行的——因此它们不会过期。再者,比起文档,它们通常更容易演示你希望代码被如何使用,遇到边界情况时会发生什么,以及为何这个奇怪的东西是这样的。

4. 提供设计指导

毫无疑问,测试倡导者的最有争议的宣说是“测试引导出更好的软件设计”。大部分我见过的关于这个观点的解释都基于软件测试理论,但它们很难被翻译为你坐在编辑器前要做的事。其它来源则根本不尝试解释,相反,他们要求你将其作为信仰:“编写测试,经过一段时间,你的代码会变得比你不写测试时要好!”

这符合我的经验,但我不会要求你信仰它。在Pivotal Labs时,我第一次从一个同事那里听到这个观点,对我来说,就像一个灯泡亮了,我开始了解它为何有效:

为一段代码设计接口是一种特殊的走钢索,需要在特定性(解决你当前的问题)和通用性(解决更一般的问题类别,同时注意重用代码到别处)之间做权衡。特定代码通常在当前更简单,但之后很难进化。通用代码通常需要在目前就添加一些复杂性,虽然这并不是解决当前问题所必须的,但回报就是之后更容易地改进。

学会从当前正在处理的代码中挑选正确的位置实在是一个模糊且难以获得的技能。然而,你的测试实际上可以帮到你,而且是以非常具体的方式。

假如你要给一个类添加一个方法。你之所以要这样做是因为你打算在某个地方调用这个方法。下面是你的新方法,它入队一个后台任务以发送电邮给用户。

  1. class User
  2. # ... other stuff ...
  3. def send_password_reset_email
  4. email = UserEmails.password_reset.new(primary_email, full_name)
  5. BackgroundJobs.enqueue(email, :send)
  6. end
  7. # ... more stuff ...
  8. end

使用此方法的代码将是它的主要客户。即,当用户从客户端请求一个密码重置操作,作为一个API调用结束后所调用的方法。

  1. class PasswordResetController
  2. # ... other stuff ...
  3. def create
  4. Auditor.record_reset_request(current_user)
  5. current_user.send_password_reset_email # this line is new
  6. end
  7. # ... more stuff ...
  8. end

当你编写测试调用那个方法,你就给了它次要客户,方法将在不同的上下文中被使用。如下单元测试:

  1. describe User do
  2. # ... other tests ...
  3. describe("#send_password_reset_email") do
  4. it("enqueues a job") do
  5. expect(BackgroundJobs).to_receive(:enqueue)
  6. User.new.send_password_reset_email
  7. end
  8. end
  9. # ... more tests ...
  10. end

通过编写测试,在两个上下文中使用代码,就意味着在主要客户之外获得了一定的通用性。它也许难以察觉,但很重要的是,它不投机,换句话说,你不用冒着风险岔开太远,去构建那些你可能还不会用到的通用性。

随着时间推移,这个技术避免你的代码太专注特定问题,代码库更容易朝着团队希望的方向进化。

5. 支持重构

软件里不变的就是改变,所以经常在新需求到来时,我们希望编写的代码能直接进化。重构是这样一个过程:清理并改变代码组织,而不改变它内部的功能。当你重构时,你需要测试来确保你移动代码时没有破坏任何其他东西。

一个代码库要长期地吸收变化,那就必须要有一个测试集来支持重构,不然开发效率(即使增加开发者)将不可避免地下降。所以你在不同层面都需要自动测试(这样你可以在不同的接口下重构)来确保功能没有被破坏。

如何使用此列表

好了!我们有了重构要素的列表。现在只要最大化执行它们就行了,对吧?

然而…不。这是不可能的。通常“挑选一个最喜欢的”要素并总是为它优化并没有多少作用。越重要的要素越会引起代码库的各个部分变化,甚至是同一个部分在不同时期变化。所以这不是一个to-do list,而是一个讨论测试策略的框架。当你观察一个pull request的测试或进行代码审查时,想想它支持哪些要素,不支持哪些。然后就能以这些要素为术语来讨论测试,它们是正确的那个吗?再优化一下文档是不是更好,而不是将来再重构?

我们讨论测试的传统方式主要基于道德和耻辱,但并不太管用。例如,“你需要写一个集成测试,因为这是业界最佳实践”就是一个道德论证。它的潜台词是每个人都这么做,那么一定有其固有价值,所以如果你不这么做,那你一定是个糟糕的开发者。

没有哪个测试具有固有价值。一个测试只有在它支持这五个要素的一个或多个时才具有价值。

记住,单独的测试,甚至是测试集,总的来说,若没有完全支持这五个要素,那它们一定有某些不对劲的地方。下面是一些例子,用一些要素的集合,来说明我的意思。

例子1:单元测试和重构

“软件中每个问题的答案都是‘依赖’。”——Sandi Metz

为一个类做全面的单元测试很符合要素3.开发者文档,但它也让要素5.重构变得困难。要做到这一点,你需要一个测试集应对每一个类被使用的地方以断言输出,这样你才能确保功能没有被改变,甚至是重命名方法或移动代码片段。面向要素2.回归的单元测试通常有更少的全面性,因此易于重构,但它们可能没有足够的文档。

但就像Sandi说的,看“依赖”。如果你的类是公开API的一部分,很少会改变,那么全面的具有叙事结构(例如从头读到尾)的要素3.开发者文档就可能成为你的主要目标。因此接口不怎么改变,那因测试导致重构变复杂也就不那么重要了。

另一方面,如果是一个内部类,那可能不太全面的面向要素2.回归的单元测试更好,确保主要地方没有错误,对叙事组织更少担忧。这些比面向文档的测试更支持要素5.重构,也能作为要素4.设计指导和轻量化要素3.开发者文档,即使重点在别的地方。

测试五要素 - 图1

你不是选择一个要素。而是在它们之间寻求平衡。可能很难准确地确定平衡点,特别是你只有一些编写测试的经验。但这值得去做,因为你会发现测试会无意识地优化某个在其他地方来说很重要的要素,而不是这里。你可经常简化(或清除)这些测试以作为结果。

这些关于单元测试的讨论是绝佳的例子。我见过一些系统有着定好版本的公开API,有着(适当的)面向文档的测试。但没有经过思考,他们就在内部结构上也执行同样的测试策略,强制开发为所有东西都编写综合单元测试。这让重构变得异常困难,甚至是内部重构,它原本因该是被允许的。若他们曾经考虑过这些要素的术语,那他们就能看到他们需要为内部结构倾斜他们的策略,以取得更好的重构支持。

例子2:集成测试,回归,以及文档

随着测试集的运行时间变长,要素2.预防回归的效用就会下降,因为开发者可能不会运行很长的测试集。我的个人阈值是大约10分钟,超过它,我就会开始寻找方法来加速测试集。

顶层的集成测试,对要素1.证明代码工作在开发过程中来说非常好,而要素3.记录应用如何工作则相反,运行起来很慢。它们经常占用运行测试的大部分时间。一旦特性写完,那就需要从要素1.证明代码工作切换到要素2.防止回归,你可以经常重写缓慢的集成测试为更快的形式。例如,一个使用JavaScript功能的web应用的集成测试,通常都能被重写为独立后端端点测试和JavaScript单元测试的组合。

即使是有同样的覆盖率,这样进行转换一样有其缺点:如果你没有一个顶层的集成测试,你很难通过单元测试搞清楚这个特性到底如何工作。你的测试集的工具就失去了要素3.文档化功能,因为现在没办法在一个地方看到全部,它们都分散了。

在这种情况下,你可决定开始以另一种形式来文档化顶层功能,如截图,或wiki等(如果感觉降低测试集的运行时间要超过集成测试文档化功能的价值)。

这很复杂o_0

是的。测试,它本身,就很复杂,因为测试是一种技术社会结构,用于支持代码和团队的协同工作。若你的团队的需求会随着时间修改(由于商业变化、个人变化、或两种都有),那你的测试也要跟着改变。将你的测试当作活着的文档,而不是过去冲刺所留下的僵死残余物。考虑它们的实际效用,就现在,而不是一本书、一个思想领袖、甚至你的老板说你“必须”要有它们。

广告:最近写了一个用于从JSON生成Swift模型的Mac应用,叫做CuteBaby,它已支持生成Swift 4 Codable模型,且有丰富的自定义功能。如果你要迁移项目到Swift 4,也许用得到,欢迎购买!


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog