在过去的一两年中,我发现自己在演讲、研讨会以及与客户合作时越来越频繁地谈论契约测试。契约测试的一个承诺是它将有助于减少对长时间、慢速、昂贵的端到端测试的依赖。但在实践中,它是如何工作的呢?
总的来说,团队如何能够减少对慢且昂贵的端到端测试的依赖?我并不是说你应该通过将它们分解成更小的部分来删除所有的端到端测试,但对于许多测试来说,这可能是一个非常有用的思考练习。
在这篇文章中,来看一个ParaBank(虚构的在线银行)的端到端测试示例,并逐步将该测试拆分为更小、更专注的测试。该测试侧重于通过ParaBank网站申请贷款,并检查在给定某些输入值的情况下,屏幕上返回的响应是否符合预期。
为简单起见,让我们假设该功能的架构由三个独立组件组成:
- 图形用户界面,负责使用户能够提交贷款申请并在屏幕上查看结果
- ParaBank业务逻辑层,负责收集用户输入,将其与当前登录用户的信息相结合,并向贷款处理器发送贷款申请请求
- 贷款处理器,负责确定是否可以批准贷款申请
前两个组件属于ParaBank内部,贷款处理器是一个公共第三方服务,同时也被许多其他在线银行系统使用。
我们还假设所有组件已经编写并进行(单元或组件)测试,以便对其各自行为的更改获得快速反馈。
接下来,让我们看看具体情况:
第 0 步:编写端到端测试
在这种情况下,我过去常常会编写一些端到端测试,模拟ParaBank用户登录系统,填写贷款申请表并使用各种不同的输入值提交它,并验证屏幕上相应的结果。
我看到许多团队做同样的事情,要么是因为他们不知道更好的方法,要么是因为存在一种‘如果在UI中看不到它工作,就不能相信或信任我们的测试’的信仰。
使用像Selenium或Playwright这样的工具编写的这样一个测试可能如下所示:
[TestCase(10000, 1000, 12345, "Approved")]
[TestCase(10000, 100, 12345, "Denied")]
[TestCase(50000, 1000, 12345, "Denied")]
public void ApplyForLoan_CheckResult_ShouldEqualExpectations
(int loanAmount, int downPayment, int fromAccount, string expectedResult)
{
new LoginPage(this.driver)
.LoginAs("john", "demo");
new AccountOverviewPage(this.driver)
.SelectMenuItem("Request Loan");
var rlp = new RequestLoanPage(this.driver);
rlp.SubmitLoanRequest(loanAmount, downPayment, fromAccount);
Assert.That(rlp.GetLoanApplicationResult(), Is.EqualTo(expectedResult));
}
这个测试的范围可以像这样进行可视化:
尽管拥有这样的测试可能看起来是个好主意,但端到端测试存在一些问题,这些问题在某个时候都会遇到:
- 编写这些测试需要很长时间 - 端到端测试代码实际上是最难实现的自动化形式,这也是为什么我仍然觉得奇怪,很多人在自动化学习过程中选择使用Selenium或Playwright等工具的原因。
- 执行这些测试需要很长时间 - 启动和停止浏览器,加载网站,与第三方系统通信,所有这些都需要花费时间。
- 定位这些测试的失败需要很长时间 - 由于涉及很多组件,通常很难找到测试失败的根本原因,而且这个根本原因更可能在测试本身而不是在被测试的应用代码中找到。
此外,编写和运行这些端到端测试通常只发生在开发周期的非常后期,因为这需要所有组件和服务都可用进行测试,这与许多团队希望获得对其开发工作快速反馈的愿望相悖。
因此,我们会逐步改进这个测试,通过将其拆分成更小的部分,这样写起来更容易,运行起来更快。
第 1 步:将前端测试与其余部分分开
在当前状态下,我们的端到端测试一次验证多个事物。这通常是一个不好的主意,因为在测试失败的情况下,更难找到问题的根本原因。让我们开始通过区分以下两个方面来拆分我们的测试:
- 用户是否能在他们的浏览器中看到贷款申请结果?
- 提交的贷款申请是否被正确处理,贷款申请结果是否符合我们的期望?
当我们进行这种区分时,我们测试的范围现在看起来像这样:
我们对前端进行了单元/组件测试,并在对前端进行测试时使用mocked API调用。现在驱动我们后端测试的API调用可以使用像Postman或类似RestAssured.Net的库编写。
这明显比我们最初的端到端测试有所改进,但仍然有一些问题需要解决:
- 我们的API驱动后端测试仍涉及多个层和组件。
- 与我们之前的端到端测试相比,我们丧失了对前端(现在在隔离中测试)与ParaBank后端集成的测试。
这意味着我们还有一些工作要做!
第 2 步:移除第三方系统
我看到很多团队朝着正确方向迈出的另一步是用模拟(mock 或 stub)替换实际的第三方系统。个人而言,我更喜欢在可能的情况下对‘真实物体’进行测试,并主要使用mock来测试:
- 当您想要对‘真实物体’中仍在开发中的功能进行测试时。
- 当您想要测试‘真实物体’中很难或甚至可能无法按需触发的行为时。
- 当您无法经常设置或调用‘真实物体’以满足您的测试需求时。
在这种情况下,由于贷款处理器是一个第三方系统,让我们假设对ParaBank开发团队来说,如果只是为了定期测试ParaBank应用在贷款处理器遇到延迟或返回错误或意外消息时的表现,将贷款处理器替换为mock是有意义的。使用mock而不是真实系统或组件的更多有效原因或用例,超出了本博客文章的范围。
用模拟替代实际的贷款处理器导致我们测试的范围如下:
我们再次成功地减小了测试的范围,但不幸的是,通过解决一个问题,我们引入了另一个问题:这样一来,我们不再测试ParaBank系统与第三方贷款处理器服务之间的实际集成。在拆分集成测试难题和失去不同组件之间集成覆盖时,我们已经两次犯了同样的错误。是时候解决这个问题了。
第 3 步:测试前端与ParaBank后端之间的集成
我们可以使用契约测试来帮助我们测试ParaBank前端与其后端之间的集成。与其他类型的集成测试相比,基于契约的测试的最大优势之一是契约测试是异步的,这意味着虽然消费者和提供者在契约测试中都有各自的职责,但它们不需要部署到共享的测试环境中来交换消息。
这有助于在开发过程的早期进行集成测试,也就是说,我们可以在本地开发环境中进行软件开发时就进行集成测试。换句话说,基于契约的测试将集成测试提前到了我们开发工作的单元测试阶段。
在这种情况下,我们应该问的第一个问题是‘哪种类型的契约测试?’。由于我们处理的是ParaBank内部的消费者和提供者,并且我们希望在前端和后端之间进行丰富而详细的测试,消费者驱动的契约测试(CDCT)是一个合理的选择。
像Pact或Spring Cloud Contract这样的工具可以帮助我们在这里实施CDCT。在为前后端集成实施CDCT后,我们的测试范围如下:
第 4 步:测试后端与贷款处理器服务之间的集成
接下来,让我们继续讨论在将原始的端到端测试拆分成更小的部分时遇到问题的另一个集成。你可能首先想到的是看看CDCT是否在这里也能起作用,但有一些挑战可能会使实施变得更加困难:
- 该服务是在ParaBank之外开发的,这使得开发团队之间的沟通和交流更加困难。
- 该服务很可能是提供给其他许多在线银行系统的公共API,这使得贷款处理器开发团队更不太可能采用CDCT。
在这种情况下,双向契约测试或BDCT可能是更合适的选择。通过BDCT,消费者和提供者都生成自己的契约,然后由独立的第三方进行比较。
在Pact生态系统的情况下,这将是Pactflow。BDCT减轻了提供者一方的负担,因为他们真正需要做的就是提供他们的OpenAPI规范,这使得在这个特定的集成中更容易实现。
作为额外的好处,我们很有可能还能减少在第 2 步中模拟的情况数量。贷款处理器以预期的方式响应的‘正常路径’场景现在应该已被契约覆盖,我们只需模拟那些贷款处理器可能以未在提供者契约中描述的方式行为的情况。这些情况的示例包括延迟响应的交付和服务器端错误响应(HTTP 5xx)。
至此,我们做了什么?
当我们再次添加验证后端实现的测试时,我们贷款申请功能的最终测试拆分现在看起来像这样:
我们不再需要依赖跨团队、部门甚至公司边界的缓慢且昂贵的端到端测试。我们也不再过多关注(隐含地)测试第三方贷款处理器服务的实现,因为这从一开始就不是我们的责任。
相反,我们有一套全面的测试,涵盖了系统中各个组件的实现,同时还有基于契约的测试,用于检测当前和未来各个组件的潜在集成问题。
这些测试通常会运行得更快,可以在开发过程的更早阶段编写和运行,从而更快地获取有关软件行为的反馈。