五要素测试
五要素测试
在我90年代刚开始参加开发工作时,对于开发人员来说,开发自己的自动化测试并不流行。正常情况下,大公司依赖于手工测试的测试组,或者自动化软件的专家。小公司则更依赖于代码审查,开发阶段后的月度“集成”。。。或者更常见的:希望它能正常工作。
但是时代变了。现今,大多数团队开发自动化测试脚本已经是软件开发者的正常工作的一部分。代码基线的修改在没有经过至少一些自动化测试用例的检验之前并不算完事,而一般的,这些测试用例由修改这部分代码的开发者开发。这种方式解放了专门的测试人员,如果公司里有这个岗位的话,让他们能专注于更有价值的工作,比如探索测试。
如同大多数变迁,这种测试方式来的悄无声息,且在现在看来理所当然。在90年代后期,开发人员写的测试脚本会显的很奇怪,当时很难想象,在20年后,非开发人员写的测试脚本会显的很奇怪。但是,现在已经是这样了。
欢迎来到未来
飞行汽车!个人喷气背包!悬浮滑板!开发测试脚本从“新奇的活动”成为了“支持代码变更的一部分工作”,就如同开会、发邮件或者偷懒!
但是就像开发、发邮件和发呆一样,我们写测试仅仅是为了完成任务,而不管它是否有用。在实行了一段时间后,你会听到类似这样的事:
- 测试很[慢|不可靠|不可预期]。
- 我们没有达到要求的测试覆盖率,但我们没有时间去更新。
- 开发测试用例仅仅是让一个故事花费至少两倍时间 – 但没有很有用的理由。
- 故事开发完了。我必须写测试用例了。
为了解决这些问题并且让测试真正对我们起作用,我们需要重新审视最初驱使我们开发测试用例的深层原因。让人惊奇的是,没有谁把这些原因写下来过。可能是大家都假设我们清楚原因是什么,在我给大家讲了很多次后,我决定把它写成博客并让大家自己去看。现在你在看的就是了。
五要素
有5个实际的原因让我们开发测试脚本。不管我们是否意识到,我们的个人测试哲学取决于我们如何衡量这些原因的相对重要程度。许多人考虑要素1和要素2 – 它们是开发测试脚本的标准原因,并且经常被提起。但是我们关于测试的争论,不论是团队内部还是互联网上的,常常是源于对于要素3,4和5的理解不同。
首先我们把每个要素单独整理一遍,然后,来讨论一些混合的例子,我们将思考当你决定如何测试你的代码时如何将他们组合起来。
好的测试可以。。。
让我们一起来看看每一个要素的细节。
1. 确保代码可以正确的工作
最直观的感觉,我们大多数人开发测试脚本是为了让我们对于新代码有信心:添加或修改的内容是以我们希望它们的方式工作的。回想大学时代,我的编程作业是用shell脚本写的。我从来没有交过脚本,因为那时候,测试的唯一目标就是验证。毕竟,我们的计算机老师只关心功能的输出是否是他们期望的。
2. 避免未来的回归
直接的验证对于小型的编程作业已经足够了,但我们大多数人工作于更大型,更复杂的代码基线,并且有其他人也在同时工作于其上。
这种情况下,你为你的代码编写的自动化测试脚本成为了验证系统各个不同部分的“套件”或者集合的一部分。更新代码,然后运行套件并且看到所有测试都能通过,让你对于你的修改不会导致搞坏整个应用的其它部分这事儿有信心。这能避免“回归”,这个奇妙的词表示:曾经它能正常工作,但现在不行了。
我们的测试成为套件的一部分,这样在将来某个时候,其他的开发者能对于他们不会不小心搞坏我们的功能有信心。
3. 文档化代码行为
“程序必须是写给人读的,同时,仅仅是顺便,让机器可以执行” -- Hal Abelson
代码是交流 – 首先是与其他开发者;然后是与某台电脑。既然你的自动化测试脚本也是代码,它们也应该是交流,同时你可以更进一步,将它明确的设计成它们所测试的代码的外部文档。
当然,在你写代码时有许多种方式文档化你的意图:
- 长格式,比如写在wiki或README里
- 写在代码注释中
- 程序元素名,如变量名,函数名或类名
测试脚本经常会被忽视其也是文档化的一种形式,但对于开发伙伴来说,它们可以比上述任何方式更有用。首先,它们可以被执行 – 所以它们一定不会过时。其次,它们通常是最简单的演示方式(更胜于产品文档)来告诉别人:你期望代码如何被使用,当其遭遇边界条件时会发生什么,为什么那个看起来很奇怪的比特位要搞成那样。
4. 提供设计指导
毫无疑问,最有争议的关于测试主张的断言是测试引领了更好的软件设计(测试驱动)。大多数解释着眼于软件设计理论,当你打开编辑器后,很难翻译成我要干啥。其它的一些文章甚至根本没有尝试解释。相反,他们让你单凭信仰去接受:“编写测试,过段时间后,你的代码将变得比你不曾写测试更好!”
这与我的经验相符,但我不会让你单凭信仰来接受它。当我第一次从我同事那儿听到这个主意时,简直是灵光一现,让我领会为什么它能起作用:
设计一段代码的接口就像是在走钢丝,一边是特定性需求(解决你当前的问题),而另一边则是普遍性问题(解决更普遍的此类问题,以便在其它某个地方重用此代码)。特定性代码一般对于当前更简单,但在将来更难扩展。普遍性代码通常会在当前增加更多复杂性但不会仅限于解决当前问题,也就是说在以后更易于扩展。
要学习如何在这个范围中为你的代码找到平衡点是个很麻烦,很让人头大,很难习得的技能。然而,你的测试可以在事实上帮到你,以一种很具体的方式。
比如你将在为某个类添加一个方法。完全可以假设,你之所以这么干是因为你希望在另外的某个地方调用这个方法。下面是你的新方法,它将某个后台任务加进队列,而这个任务要给用户发送邮件。
class User
# ... other stuff ...
def send_password_reset_email
email = UserEmails.password_reset.new(primary_email, full_name)
BackgroundJobs.enqueue(email, :send)
end
# ... more stuff ...
end
使用这段代码的地方是这个新方法的主要客户。下面是主要客户 – 当用户从客户端请求密码重置时,作为API调用返回的方法。
class PasswordResetController
# ... other stuff ...
def create
Auditor.record_reset_request(current_user)
current_user.send_password_reset_email # this line is new
end
# ... more stuff ...
end
当你编写调用此方法的测试时,你给它找了次要客户,在这里方法在一个不同的上下文被调用。下面是你对新方法的单元测试。
describe User do
# ... other tests ...
describe("#send_password_reset_email") do
it("enqueues a job") do
expect(BackgroundJobs).to_receive(:enqueue)
User.new.send_password_reset_email
end
end
# ... more tests ...
end
以编写测试的方式让代码在两个不同的上下文中使用,意味着你在比你主要客户的特定需求略微更普遍一点的方式创建了代码。这一点普遍性可能几乎无法察觉,但它很重要,它已经不再是特定的 – 用另外的话说就是,你不会在特定性这条路上走的太远,构造出意义晦涩,无法重用的代码。
久而久之,这种技术让你的代码与特定性问题逐渐解绑,使之更可能扩展代码基线去适应团队希望的方向。
5. 支持重构
在软件中唯一不变的就是改变,所以我们经常希望写出可以在来了新需求后,很方便扩展的代码。重构是在不改变代码外在功能的基础上,清理和改变代码的组织结构。当你重构时,你需要测试来确保你在移动某段代码时不会搞坏任何事。
需要长期合并修改的代码基线,必须有测试套件以支持重构,否则开发速度(甚至增加了开发者的情况下)将不可阻挡的下降。所以你需要在你的代码基线上有不同等级的自动化测试(因此你可以在不同的接口下重构),以便你能用于断言功能未被改变。
如何使用此列表
OK!我们有了要素列表了。现在就剩下关于充分利用这些要素的问题了,是吗?
嗯,不是的。这是不可能的。一般来说,先选一款,然后再持续优化该要素,这种方式甚至并没什么用。哪个要素更重要在你代码基线的不同部分并不相同,甚至在同一代码部分的不同时期都有可能不一样。这并不是一个 to-do list;这是讨论测试策略的框架。当你在看一个Pull Request的测试或是在代码审查时,想想它支持何种要素,以及不支持何种要素。然后你可以就那些要素对测试进行讨论 – 它们是否合适?是在这里对文档做更多的优化,还是等将来做重构?
我们传统讨论测试的方式大部分是基于道德和羞耻心,同时并不是真的很有用。例如,“你需要写一个集成测试,因为它是业界的最佳实践”是道德选项。潜台词是,其他人都这么干了,它一定是有内在价值的,如果你不愿意这么干,那你一定是个糟糕的开发者。
测试没有内在价值。测试对你工程的价值仅仅在于其支持一个或多个上述的五要素。
并且记住,总的来说,单个测试,甚至一个测试套件不能完全支持所有的五要素。它们必定会有些差别。下面是一些要素组合的例子,来详细说明下我的意思。
例 1: 单元测试和重构
“在软件行业,任何问题的答案都是:看情况。” -- Sandi Metz
对某个类的全面单元测试很好的完成了 3.开发文档,但使其难于对该接口5.重构。要这么干,你需要编写一组更高一级的测试(在使用这个类的地方)以对输出结果断言,这样你可以确信功能没有改变,哪怕你重命名方法或者移动代码片段。面向 2. 回归 的单元测试通常不会很全面,因此在重构时比较容易,但它们可能会没有足够文档。
但是就像 Sandi 说的那样 – 看情况。 如果你的类是公共API的一部分,并且极少改变,以叙述性的结构(旨在阅读)完成的全面的 3. 开发文档 可能是你事实上的基本目标。因为接口并不会经常改变,因此这些测试在复杂重构这一要素上并不重要。
在另一方面,如果这是个内部类,你可能更希望选择更少全面测试的面向 2. 回归 的单元测试,以便可以减少错误引入,不用太多考虑叙述性结构。这些特点支持更好的 5.重构 胜过面向文档化的测试,并且仍然可以服务于 4. 设计指导 以及轻量的 3. 开发文档,哪怕重点是在其它地方。
你永远不会是挑选一个要素。你永远是在要素之间取一个平衡。很难准确的描述出你取的平衡,特别是在如果你有一些编写测试的经验时。然而值得去做,因为你将发现在不知不觉间,测试向着某个要素优化,然而这个要素在其它情况下比较重要,而非此处。知道结果时你就可以简单的简化(甚至剔除)这些测试。
关于单元测试的讨论就是一个很好的例子。我曾经看到过有版本化API的系统有(合适的)面向文档的测试。然后没有去仔细思考,他们应用了类似内部结构的测试策略,强迫开发人员对所有代码编写全面的单元测试。这使得重构相当困难 – 哪怕是推测可以允许的内部重构。而只要他们以要素的方式考虑过,他们就能够发现他们需要调整测试策略,从内部结构化测试转变为允许支持重构。
例 2:集成测试,回归测试,和文档化
当测试套件的运行时间变的很长时,作为 2. 避免回归 的作用就会降低,因为开发者们不太会愿意需要长时间运行的测试套件。我的个人阀值在大约10分钟左右 – 超过这个阀值,我会想办法来加速套件。
顶层的集成测试在你们开发完一个功能后,能有效 1. 证明代码正常工作,并且在之后,能 2. 以文档描述你的app如何工作,但是却运行十分缓慢。在运行一个测试套件时通过需要花费很长时间。一旦功能被开发完成,需求将从 1. 证明它能工作 转变为 2. 避免回归,你可以经常的快速开发一些运行缓慢的集成测试。例如,对于一个使用JavaScript功能的WEB应用,对其的集成测试经常会写成独立的后端测试和JavsScript单元测试相结合的方式。你必须仔细确认你有相同的覆盖率,但如果测试套件的运行时间很重要,这是可行的。
但即使你有几乎一致的覆盖率,这样做仍然有其负面影响:如果你没有顶层的集成测试,很难仅仅通过阅读测试脚本准确了解某功能被期待以何种方式运行。你的测试套件 3. 文档化功能 的效果被减弱,因为这种情况下,无法只看某一个地方,你需要从多个方面去零散的搜集信息碎片。
从这点上看,你可以仔细考虑选择以另一种方式开始对于顶层功能的文档化描述 – 截图,写wiki,等等 – 如果降低套件运行时间的价值超过以集成测试方式文档化功能的价值。
这很复杂 o_O
是的。测试,是非常复杂的,因为测试构建了代码与工作其上的团队之间的技术上的交谈。由于你团队的需求时常会变 – 因为业务在变,个人需求在变,或(大多数时候)两者同时变 – 你需要的测试类型随之也改变。将你的测试当成活的文档,而不是以往迭代留下的过时的纪念品。从现在开始,让测试真正为你发挥作用,而非某本书,或某位思想领袖,或甚至你的老板,告诉你必须有测试。