契约测试:挑战、担忧和实践案例
当谈到契约测试时,一些软件工程师和技术领导者可能会有顾虑。转向这种方法可能看起来令人望而生畏,产生怀疑是正常的。让我们解决一些常见的担忧,并探索契约测试涉及的实际内容。
关于契约测试的误解
契约测试是否增加了不必要的复杂性?**第一种反应是:“这会让一切变得更复杂。”似乎通过添加契约测试,我们在增加保持测试运行的额外工作量。但实际情况正好相反。契约测试可以看作是分散了验证责任,从而减轻了端到端测试的压力。
想象你有一个相互关联的服务网络。如果没有契约测试,所有潜在问题都必须在E2E测试中捕捉,这使得测试变得沉重且耗时。有了契约测试,这些集成失败会在早期被捕捉到,远在它们到达E2E测试阶段之前。
契约测试是否难以编写?**另一个担忧是编写契约测试是一项艰巨的任务,需要额外的努力。起初可能看起来很复杂,尤其是你需要理解消费者和提供者的角色。但事实上,借助像Pact这样的工具,这个过程变得更加简单。这些工具有助于自动化契约的创建和验证,使测试过程更加顺畅且高效。
例如,使用 Pact,你可以为消费者和提供者创建测试。以下是它在实际场景中的工作方式:
// Teste de contrato para um consumidor frontend que consome dados de perfil de usuário
@Pact(consumer = "UserProfileFrontend")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("User with ID 123 exists")
.uponReceiving("A request to retrieve user profile details")
.path("/api/users/123")
.method("GET")
.willRespondWith()
.status(200)
.body("{\"id\": 123, \"name\": \"Alice\", \"email\": \"alice@example.com\", \"status\": \"ACTIVE\"}")
.toPact();
}
@Test
@PactTestFor(providerName = "UserProfileAPI", port = "8080")
public void testGetUserProfilePact() {
WebClient webClient = WebClient.create("http://localhost:8080");
UserProfile response = webClient.get()
.uri("/api/users/123")
.retrieve()
.bodyToMono(UserProfile.class)
.block();
assertNotNull(response);
assertEquals(123, response.getId());
assertEquals("Alice", response.getName());
assertEquals("alice@example.com", response.getEmail());
assertEquals("ACTIVE", response.getStatus());
}
在这里,测试正在验证消费者(在本例中是前端)是否从提供者(用户API)接收到正确的数据。此契约将由提供者验证,以确保API返回正确的信息。
契约测试在小型生态系统中不必要? 有些工程师认为,如果微服务生态系统很小,契约测试是一种不必要的奢侈。但这是目光短浅的看法。随着系统的增长,缺乏契约可能导致通信失败,这些失败只能在后期阶段(如E2E测试,甚至在生产中)被发现。
即使在小型环境中,从一开始引入契约测试也是有益的。这不仅建立了良好的实践,还为更有组织且安全的增长奠定了基础。
常见的关于契约测试的担忧
- 需要维护消费者和提供者的契约: 这是一个最常见的担忧,需要维护契约的双方:消费者和提供者。是的,这确实意味着会有额外的工作。然而,维护工作可以高度自动化并集成到CI/CD管道中。真正的好处在于契约测试带来的可见性。当服务发生变更时,契约帮助快速识别哪些消费者会受到影响,从而便于团队之间的协调。
- 担心失去灵活性: 另一个担忧是,契约测试可能会限制创新或快速变更的能力。然而,契约的设计允许在可接受的范围内保留灵活性。新的契约可以在旧版本依然可用的情况下引入,直到所有消费者准备好迁移。这允许服务在不造成中断的情况下演进。
- 担心初期的开销: 在一个已经上线的系统中实施契约测试看起来是一项巨大的任务。很多人更愿意推迟到“更合适的时间”,但这通常永远不会到来。事实是,契约测试不需要一次性全部实现。它们可以逐步引入,从最关键的服务或新功能开始。
为了更好地说明这一点,考虑一个API消费者的示例:
// Teste de contrato para um consumidor API que processa pedidos de compra
@Pact(consumer = "OrderProcessingService")
public RequestResponsePact createOrderProcessingPact(PactDslWithProvider builder) {
return builder
.given("Product with ID 456 is available in stock")
.uponReceiving("A request to place an order for a product")
.path("/api/orders")
.method("POST")
.body("{\"productId\": 456, \"quantity\": 3, \"userId\": 789}")
.willRespondWith()
.status(201)
.body("{\"orderId\": 1010, \"status\": \"CONFIRMED\", \"estimatedDelivery\": \"2024-09-15\"}")
.toPact();
}
@Test
@PactTestFor(providerName = "OrderAPIProvider", port = "8080")
public void testCreateOrderPact() {
WebClient webClient = WebClient.create("http://localhost:8080");
OrderResponse response = webClient.post()
.uri("/api/orders")
.bodyValue(new OrderRequest(456, 3, 789))
.retrieve()
.bodyToMono(OrderResponse.class)
.block();
assertNotNull(response);
assertEquals(1010, response.getOrderId());
assertEquals("CONFIRMED", response.getStatus());
assertEquals("2024-09-15", response.getEstimatedDelivery());
}
在第二个例子中,我们有一个订单处理服务,该服务通过API创建新订单。契约确保在为库存中的产品创建订单时,API将返回“确认”的状态和预期的送达日期,从而验证交易的完整性。
克服最初的担忧,契约测试提供了不可否认的优势。它们允许更早地发现问题,促进团队之间的沟通,并减少E2E测试的负担。随着时间的推移,它们可以成为开发周期中不可或缺且有价值的一部分。
显然,采用契约测试需要一定的初期投入,但这种投资将在稳定性、可靠性以及系统安全扩展能力方面带来回报。对于那些仍然犹豫不决的人,最佳做法是从关键服务开始逐步推进,并随着团队对这一实践的信心提升,逐步扩展。
也许你更偏向多种测试策略结合的方式。
结合契约测试和验收测试的策略
当我们谈到确保复杂系统的质量时,我们需要的是既高效又全面的多种测试策略。对于Nubank来说,随着公司的发展,这一挑战变得显而易见,他们意识到过度依赖E2E测试已成为一个主要瓶颈。对此,Nubank采用了契约测试和验收测试相结合的策略,事实证明,这种方法更为有效且可扩展。
在文章《为什么我们放弃了端到端测试套件:Nubank如何通过契约和验收测试策略,成功扩展至1000多名工程师》中,Nubank详细说明了他们面临的依赖E2E测试的问题。随着覆盖大量端到端场景的测试套件的扩展,他们开始注意到一系列问题:
- 测试执行的缓慢: 随着代码库的扩大,E2E测试的执行时间越来越长。这减慢了反馈周期,影响了团队的敏捷性。
- 结果的可靠性不足: 许多端到端测试不稳定,导致不一致的失败和大量的假阳性结果,降低了团队对测试套件的信心。
- 高维护成本: 维护E2E测试套件是一项劳动密集且昂贵的任务,尤其是当微服务数量增加,服务之间的交互变得更加复杂时。
面对这些问题(我们在本文中已经讨论过),Nubank决定将策略转向结合契约测试和验收测试。
什么是验收测试?
验收测试旨在验证系统或功能是否满足业务需求和利益相关者的期望。它们专注于通过模拟现实使用场景,验证软件是否满足预定义的验收标准,确保一切正常运作。
验收测试的结构通常包括以下元素:
- 验收标准: 这些标准是与产品负责人、业务分析师和软件工程师共同定义的,明确规定了需要验证的内容,确保功能被视为完整并准备好交付。
- 测试环境: 验收测试是在尽可能接近生产环境的环境中进行的。这确保了测试结果能够代表系统的实际行为。
- 测试场景: 每个验收标准会被转化为一个或多个测试场景,描述用户与系统的逐步交互。测试场景可能包括不同的变量,如用户类型、权限或特定条件。
- 执行与验证: 测试场景将被执行,并将结果与验收标准进行比较,以确定功能是否正确实现。
例如,在一个航班预订系统中,验收测试可能确保在选择航班并应用优惠券后,折扣会被正确应用,座位已被预订,且用户收到了确认。这些测试对于确保业务规则被遵守并且最终产品符合利益相关者的期望至关重要。
结合的策略
这种结合策略也可以应用于其他场景。想象一下你正在处理一个管理优惠券的微服务,如我们之前讨论的那样。通过采用契约测试、验收测试和E2E测试的组合,你可以:
- 契约测试: 确保优惠券服务API与其他服务(如认证或支付服务)正确协作,验证所有必要的参数是否存在且格式正确。
- 验收测试: 验证主要的业务规则是否得到遵守,例如确保优惠券不能应用于已完成的购买,或者只有经过身份验证的用户才能应用优惠券。
- 端到端测试: 验证从产品选择到支付确认的完整购买流程,确保流程中的所有步骤都按预期工作。
Nubank减少对端到端测试的依赖并采用契约测试和验收测试的结合策略,这对于高效扩展代码库和工程团队至关重要。这一举措不仅提高了测试效率,还在公司持续增长的过程中,帮助他们保持了软件质量。
对于面临类似挑战的金融公司或其他组织,结论是:结合测试策略可以提供更有效的方式来确保软件质量,减少瓶颈,并保持持续交付价值的敏捷性。
结论
在微服务环境中,端到端测试的问题是否严重到我们应该建议完全移除它们?答案并不简单,正如我们在本文中所讨论的那样,这并不是在抨击E2E策略或认为它已经过时。相反,我们强调的是,当这种方法被滥用或过度使用时,特别是在复杂架构中,可能会引入显著的挑战。
E2E测试在开发过程中有其重要地位,因为它们提供了对系统各个组件如何协作实现业务流程的全面视图。然而,问题在于当它们被视为确保质量的最终解决方案时。如同我们通过知名工程师的意见以及Nubank的成功案例所见,仅仅依赖E2E测试并不会自动带来更高的质量或信心。
我们对端到端测试所面临的挑战进行了反思:
- 复杂性和成本: E2E测试本质上是复杂且昂贵的维护任务。它们涉及多个依赖关系,通常会因为“不稳定测试”而遭受不一致的失败,这不仅消耗时间和资源,还会影响开发团队的敏捷性。
- 反馈缓慢: 过度依赖E2E测试可能会导致反馈周期变长。团队不能快速获得对代码更改的响应,而是必须等待数小时,直到整个测试套件执行完毕。这种延迟可能会影响团队的快速迭代能力,进而影响持续交付价值的能力。
- 信任与质量: 颠倒测试金字塔,过度依赖E2E测试,可能会产生一种虚假的安全感。尽管这些测试可以验证系统整体的行为,但它们并不总是能够捕捉到微服务之间的集成问题,这些问题可以通过契约测试或单元测试更轻松地发现。
- 聚焦业务场景: E2E测试应保留用于验证关键的业务流程,而不是涵盖所有可能的场景。通过将E2E测试聚焦于关键测试场景,并将集成和基本行为的检查交给契约测试和单元测试,我们可以实现更高效且更不容易出现瓶颈的测试策略。
反思问题:
- 你的团队是否有效地使用了E2E测试,还是它们已经成为交付价值的瓶颈?
- 如何在单元测试、契约测试和E2E测试之间分配负担,以便获得快速的反馈,同时保持质量而不牺牲敏捷性?
- 是否有机会采用Nubank的实践,结合契约测试和E2E测试来实现更顺畅、更可靠的开发周期?
端到端测试本身并不是问题,问题在于它们在复杂系统(如微服务中)的应用方式及其过度负担。它们是一种强大的工具,但像任何工具一样,使用它们需要谨慎并与其他测试方法相平衡。通过整合契约测试和单元测试,并将E2E测试保留给真正关键的业务场景,我们可以在保持开发敏捷性的同时,确保最终产品的健壮性和可靠性。
如何在你的架构中应用端到端测试的决定取决于你所面临的具体上下文和挑战。重要的是反思系统的目标、业务需求和团队结构,以定义最适合你现实情况的测试策略。答案并不是完全消除E2E测试,而是谨慎且智能地使用它们,作为一个平衡测试策略的一部分。
这篇文章相当深入和详尽。在未来的一篇帖子中,我们将继续探讨以下主题:契约测试与端到端测试:效率与性能。期待与你再次见面!👨🏻💻