_ 全栈开发人员 _ 的特点是能够从头到尾交付并发布一个特性。教程和书籍常常侧重于搭建全栈开发环境和让测试能够进行所需要的“管件(plumbing)”(我综合运用了Angular、Rails、Bootstrap 和Postgres)。但对于如何贯穿整个Web 开发栈进行应用程序测试,却常常缺少指导。让我们深入研究下这篇文章。我们将学习如何充分利用端到端测试,包括对测试什么以及如何保证那些测试的可靠性和可维护性进行指导。我们还将谈及单元测试以及它们在端到端测试策略中的作用。但首先,我们要理解编写测试的根本目的。
从根本上讲,测试是为了确保应用程序的行为符合开发者的意愿。它们是自动化的脚本,执行代码并检查其行为是否符合预期。测试编写得越好,就越可以依赖它们为部署把关。如果测试不充分,就需要一个QA 团队或者发布有缺陷的软件(两者均意味着用户获得价值的速度比理想情况慢许多)。如果测试充分,就可以自信而快速地发布,不需要批准或者像QA 那样缓慢的人工过程。
对于编写的测试,还必须权衡未来的可维护性。应用程序会变,因此测试也会变。在理想情况下,测试的修改与软件的修改是成正比的。如果你修改了一条错误信息,那么你不会希望大量重写测试套件。但是,如果你彻底地修改了一个用户流程,那么可以预料,将有大量的测试需要重写。
实际上,这意味着你无法将所有测试都作为端到端的全面集成测试,但是你也不能只进行少得可怜的单元测试。这就关乎如何达成那种平衡。
测试的类型
测试的种类很多,但对于本文而言,我们就谈论两类:端到端测试和单元测试。
_ 端到端测试 _ 模拟用户行为。在 Web 应用程序中,他们会启动服务器,打开浏览器,到处点击,断言浏览器中发生了特定的事情,让我们相信功能可以正常运行。这些测试会给我们巨大的信心,但是它们缓慢而脆弱,并且同用户界面紧密地耦合在了一起。
_ 单元测试 _ 根据代码单元的公共 API 运行它们。这些测试需要创建一个类的实例,使用特定的输入调用它的方法,断言被调用的方法达到了预期的效果(通常是返回了预期的输出)。这些测试快速而稳定,并且不会同系统的其他部分紧密地耦合在一起。不过,它们无法让你相信整个系统可以正常运行——只是测试过的代码单元可以正常运行。
构建一项特性的任务就是要在两类测试之间找到恰当的平衡点。如果端到端测试太多,那么未来修改应用程序就会痛苦而缓慢。如果太少,那么一些不易觉察的缺陷就会进入到生产环境,即使快速测试套件的代码覆盖率为 100%。
从用户体验入手
你的软件是向某个用户提供服务,因此,那个用户应该推动你的工作。我不建议使用测试来设计用户体验,因此,要在编写测试之前弄清楚用户将如何使用软件(要么通过试验性代码,要么同一名设计师一起工作)。一旦弄清楚了,就可以开始工作了。
在理想情况下,你将为用户体验的某个部分创建端到端的测试,并编写代码让其通过测试。在编写那些代码的时候,你会创建单元测试,具体化需要创建或修改(通常是后者)的代码的规范。
问题是,编写没有用户界面工件(HTML)可供参考的、端到端的失败测试很难。这是因为,大部分端到端测试的形式都是:
- 找到页面上的某个元素;
- 通过某种方式同它交互;
- 证实交互成功;
- 重复上述过程直到测试结束。
这意味着,围绕要发生交互的用户界面元素(DOM 对象),你需要有一些规范。当把以 JavaScript 为基础的交互设计考虑在内时,如果不实际地构建界面,至少是部分地构建,就更难测试了。
为此,要让一个粗略的 UI 轮廓在浏览器中运行起来。使用预先准备好的数据,并且不需要考虑备选流程——一次专注于一件事。它运行起来以后,就可以编写测试了。
在这样做的时候,有两点需要考虑:这个特性需要测试吗?如果需要,该如何测试?
测试什么
虽然在编程上没有愉快路径,但用户经历的代码路径要比代码的可能路径少许多。例如,当用户购买一款产品,根据用户地址、选择的发货方式或者以前的购买历史,我们可能会用不同的方式处理订单。在所有情况下,用户的体验都是一样的,这样, 在用户看来,流程只有一个。
这时,你的目标是测试所有的用户流程。你需要一个测试套件,模拟一个用户做你想要并希望他做的事,并断言你想要提供给该用户的所有体验都工作正常。
假如你已经知道要测试什么,那应该如何进行呢?
如何进行端到端测试
如果修改了一个流程,那么就要修改那个流程的测试。由于端到端测试模拟用户活动,所以不需要为想要断言的每件事情都编写一个测试。如果用户应该在结算界面上看到三段重要的信息,就不需要编写三个测试——一个测试检查所有三段信息就足够了。因此,当修改一个现有的用户体验时,要找一个现有的、可以改进的测试。
否则,就需要一个新的测试。记住,你的目标是模拟用户要做的事情。务必要对如何组织测试中的导航和行为开诚布公。用户_ 真地_ 会直接导航到某些深层链接吗?或者他们会点击某个公用的开始页面从而到达他们需要到达的地方吗?
这很难做,尤其是通常要使用最少的标记实现该功能。测试需要定位特定的DOM 元素同其交互,而准确找到你想要同其交互的元素并不总是很简单(或者可能)。你需要“标识(signpost)”。
标识是专门插入DOM 中用于定位感兴趣的元素的。要尽早确定这些标识如何发挥作用。_ 不应该_ 使用原本用于样式化的CSS 类来定位DOM 元素。这样做意味着前端开发人员改变类名就会破坏测试。也不应该使用被JavaScript 代码使用的CSS 类或数据属性(比如前缀为js- 的类)。这会带来同样的破坏。
使用前缀为test- 的CSS 类或者前缀为data-test- 的属性是两种常用的技术:
<section class="component dark test-checkout-confirmation"> <!-- ... --> </section> <!-- 或者 --> <section class="component dark" data-test-checkout-confirmation> <!-- ... --> </section>
这可能看上去让人不舒服……也确实是。但是,与将测试耦合到内容或者展示类相比,这就不那么令人讨厌了。这里,你需要寻求一种平衡——不要盲目地使用 data-test 属性标记每个元素。例如,如果你想点击一个购买特定产品的按钮,那么你真正需要的只是定位某个包含那款产品及购买按钮的元素。
<article data-test-product="1234"> <!-- a ton of markup --> <input type="submit" name="Purchase" value="Purchase"> </article> <article data-test-product="5678"> <!-- a ton of markup --> <input type="submit" name="Purchase" value="Purchase"> </article>
添加 data-test-product 属性后,你就能够使用一个像[data-test-product='1234'] input[type='submit']
这样的 CSS 选择器定位产品 1234 的购买按钮了。
这意味着你必须修改只为测试而存在的标记,就是说,为了获得你提供给他们的用户体验,用户要下载一些他们不需要的字节。这是一种平衡,但比糟糕的测试覆盖率(对用户的伤害远远超过了 HTML 中多一些额外的字节)要好。只是得恰到好处。
当页面上有改变页面内容而又不重新加载的交互(换句话说,使用 JavaScript)时,这项技术就更加重要了。
处理交互
当每次点击都重新加载页面时,端到端测试更可靠,因为底层工具知道要等待一个页面重新加载。当用户交互只是改变 DOM 时,难度就大了,因为工具不知道什么“事情”正在发生,也就无法“等待事情完成”。
当测试需要同一个不会根据用户动作重新加载的页面交互时,就需要一种方法能够在开始断言发生了什么之前等待 DOM 操作完成。如果不等待,那么如果测试开始断言时 DOM 还没有更新,测试就会无谓地失败。
就像在标记中使用标识定位要操作的 DOM 元素一样,我们也可以把它们用在这里。任何新增或变化的标记都应该有某种在交互失败或没有发生的情况下不会出现的标识。换句话说,你不必为了等待 DOM 事件而在测试中进行休眠调用——DOM 中应该包含可供测试显式等待的标识。
例如,假设我们想要测试一个动作为用户生成了一条成功的消息。假设实现方法是发出一个 AJAX 请求,当调用结束时向 DOM 中插入一条消息。一个基本的实现可以像下面这样做:
function purchase(productId) { $.post( "/products/", { "id": productId } ).done(function() { $(".header").html( "<div class='alert-success'>Your order was placed</div>"); }).fail(function() { $(".header").html( "<div class='alert-failure'>There was a problem</div>"); });
你可以通过配置让测试等待一个使用了 CSS 类 alert-success 的元素出现,然后断言它的内容。这意味着,如果页面需要任何其他使用那个类的元素,那么测试就会不可靠或被破坏。虽然你可以将其限制在 HTML 头里,但这只是缓兵之计。
作为替代,可以使用 data-test- 属性:
function purchase(productId) { $.post( "/products/", { "id": productId } ).done(function() { $(".header").html( "<div data-test-purchase-successful class='alert-success'>Your order was placed</div>"); }).fail(function() { $(".header").html( "<div data-test-purchase-failed class='alert-failure'>There was a problem</div>"); });
虽然这增加了标记的字节,但它让你可以编写一个能够不受某些视觉变化影响的可靠测试。只要页面流程是在一次成功的购买后显示一条消息,那么可视化实现就可以修改而又不破坏测试。这是你想要的,这是一种权衡。你也可以牺牲掉这份自信,创建最小最起码的标记,但当显示效果变化时,你要么花时间修复测试,被迫手动 QA,要么就发布没有经过充分测试的软件。
如今的端到端测试工具,如 Capybara ,包含你需要的所有功能。它提供了方法,可以在继续测试过程之前等待 DOM 元素出现,断言页面特定部分的内容,同表单元素交互。大多数其他 Web 应用程序栈都提供了类似的工具。不管怎样,你可以将测试库与像 PhantomJS 这样的无界面浏览器结合,从而使端到端测试出奇地快速可靠。
还有一点值得注意,就是在一个分布式的环境中如何完成这项工作。
当“应用”多于一个
当对单个整体系统进行测试时,上述技术就完全够用了。然而,如果是对一个较为分散的系统进行测试,情况就要复杂些了。假设你正致力于一个面向客户的应用程序,但它必须从另一个系统获取库存数据。你如何为此编写一个测试呢?
首先,记住你在测试什么。端到端测试是测试用户交互。这意味着,端到端测试不用负责断言远程服务的功能,也不用负责断言应用程序正确地消费了那个远程服务。
测试服务消费的最佳方式是使用“消费者驱动的契约( consumer-driven contracts )”,这是一种单元测试的形式(至少在这篇博文中我所做的宽泛界定中是这样)。
对于在端到端测试中如何模拟远程服务,至此仍然没有定论。你可以搭建该服务的一个实际版本,但这并不是很好。你最终不得不管理那个服务的内部数据存储以及它所依赖的服务。那会使复杂性迅速增加,难以管理。
一个常见的选择是使用一个 HTTP 层的模拟系统。在 Ruby 中, VCR 是一款具备这种功能的工具。你录制同真实服务交互以建立 HTTP 协议往返的过程,在随后运行测试时,模拟系统会回放录制好的交互,而不必使用网络。如果单元测试覆盖了服务的正确消费,那么这对于端到端测试就会很有效。
另一个选择是搭建一个经过简化的模拟服务,该服务返回预先准备好的数据。应用会像平常一样进行 HTTP 调用,但调用的是一个预先准备好、只向应用返回静态已知数据的服务。这需要提前做些配置,但对简单的服务交互很有效。如果应用程序需要在服务中存储状态,并有一个漫长的往返“对话”,那么这项技术就要难一些了。
我的建议是首先尝试模拟 HTTP,因为那既简单又快捷。
现在,我们知道在端到端测试中测试什么以及如何测试,那么单元测试呢?
单元测试
回想一下,对于什么应该进行端到端的测试,我们的标准是用户流程。其思想是,虽然整个系统有许多可能的逻辑流程,但能对用户体验产生影响的要少很多。单元测试就是要测试那些逻辑流程的剩余部分。
这让我们可以快速可靠地断言系统大部分功能的正确行为。换句话说,虽然我们可以使用端到端测试断言整个系统中每个可能的流程,但那没有必要,而且会非常缓慢和脆弱。
例如,假设一个结算功能有两个用户流程:一个是购买成功,一个是购买失败,用户必须重试。那会有两个端到端测试。让我们进一步假设,后台有如下可能性:
- 客户的信用卡正确扣款;
- 与客户银行的通信存在问题,但我们想假装它是成功的,并在稍后扣款;
- 客户的信用卡被拒绝;
- 客户的信用卡过期。
这是四个流程,所以我们希望有四个单元测试可以断言其中每一种情况都得到了正确处理。是的,会有重复覆盖。在端到端测试中,我们可能会创建成功扣款和拒绝两个测试来处理该功能的两个用户流程,因此,当编写单元测试时,我们的覆盖率就会超过理论上的需要。
再一次,这是一种权衡,但重要的是,单元测试可以很好地覆盖你的类。这就允许它们改变位置、用途,而且更容易修改。
关于如何编写单元测试,有许多许多的理论,远远超出了我们这里的讨论范围。我的建议是采用一种对你有用同时也容易跟别人解释的技术,并一直使用。
对于单元测试,最困难的部分是决定代码设计要在多大程度上为测试考虑。这就类似我们如何为了测试向 HTML 中增加属性和其他标识——那些工件只是因为我们要测试而存在。在编写单元测试时,你会面临同样的选择。
例如,假设Purchaser
类实现了信用卡扣款代码。假设它将使用第三方提供的AwesomePayments
进行实际地扣款。
class Purchaser def charge(purchase) AwesomePayments.charge(purchase.customer.id,purchase.amount) rescue => ex try_again_later(purchase.id) end # ... end
上述代码清晰易懂,在不需要单元测试的情况下,这可能是最理想的设计了。然而,为了让测试更简单,我们可能想控制AwesomePayments
的实例:
class Purchaser def initialize(awesome_payments = AwesomePayments) @awesome_payments = awesome_payments end def charge(purchase) @awesome_payments.charge(purchase.customer.id,purchase.amount) rescue => ex try_again_later(purchase.id) end end
现在,就可以在测试时传入AwesomePayments
的模拟实现,从而更好地控制测试。测试已经影响了我们的设计(虽然这里的影响比较小)。你甚至可以说,这个类就是更好的代码。但情况并非总是如此。
我会使用同你处理端到端测试一样的标准:做让生活更轻松的事,但不要做过头,务必要恰到好处。
小结
从头到尾实现一个特性的能力取决于从头到尾测试它的能力。由QA 团队或客户测试代码的反馈循环存在极大的危害。即使有QA 团队,他们也不应该找到Bug,如果要想快速发布软件,就不会介意编写用户行为的端到端测试。
关于作者
David Copeland是一名程序员和作家。他近日出版了《Rails、Angular、Postgres 和Bootstrap》一书,同时,他还是《高级软件工程师》和《开发经典命令行程序 (Ruby 实现)》这两本书的作者。他有超过18 年的专业开发经验,从在LivingSocial 管理高性能、高流量的系统,或者在Opower 构建工程师团队,到大大小小的工作咨询。目前,他是时尚创业公司Stitch Fix 工程部门的负责人,该公司构建了一个将会改变零售购物体验的平台。
查看英文原文: Full Stack Testing: Balancing Unit and End-to-End Tests
评论