在测试单体应用时,我们通常会进行单元测试、集成测试,有时还可能用到端到端测试,目标是尽可能地构建一个完善的测试金字塔。但是在测试微服务方面,情况就没有那么简单了。主要区别在于微服务架构中的通信。目前最佳测试微服务的方式是契约测试(Contract Tests)。
单体应用 vs 微服务
模块化单体应用的一个优势(某些情况下可能是劣势)在于,它在单一进程内运行。各个模块之间可以通过本地函数调用来进行通信,不需要依赖网络来实现业务功能。因此,针对这些模块的单元测试或集成测试是在它们的公共 API 层面进行的。更改模块的 API 会导致编译错误,例如当我们更改方法的签名时。因此,在单体架构中破坏模块间的通信契约是很困难的。
当涉及到微服务时,情况就比较复杂了。微服务需要通过网络交流才能实现业务功能,因此不能再依赖编译器。微服务模块是独立运作且分布式的。它们是两个不同的应用程序,很可能拥有不同的测试和部署流程。
如何在产品上线前确保各服务能够协调运作?
集成测试
首先我们可能会想到集成测试。这个答案在一定程度上是正确的。但在进一步讨论之前,我想先明确一下我对集成测试的看法。不同的人可能对此有不同的理解。在这里,我所说的集成测试是指验证系统中各个独立开发的部分是否能够顺利协同工作,并按照需求成功实现功能的测试测试方式。这可能包括:
- 验证公开的 API(例如 REST API)
- 验证与其他服务的通信
- 验证生成和消费消息
- 验证与数据库的集成
集成测试通常在隔离环境中运行,也就是说在执行测试时,不需要运行服务。实际上,我们甚至不需要连接网络就可以完成集成测试。
集成测试是检验服务是否能与系统的其他部分(如数据库、其他服务等)正确协同运行的好方法,主要目标应该是验证业务功能是否按预期工作。可实际上,集成测试经常被用于测试服务之间的通信契约。这种方法有一些缺点,让我们来探究一下。
假设我们要测试订单和支付这两个服务之间的通信。支付服务(payment-service)暴露了一个 REST API,允许发起支付。订单服务(order-service)负责创建订单,它会调用支付服务为该订单创建支付。为了测试两者间的通信,至少需要实现两个集成测试集合:
- 验证 API 是否满足需求。这个验证过程是为了验证服务是否对某个请求返回了预期的输出,包括对路径、请求头部和主体结构的验证。这些测试必须在支付服务中实现,应模拟订单服务调用 API 的过程(见代码 1)。
- 验证订单服务是否正确调用了支付服务的 API。这是为了检查 HTTP 客户端是否正确调用了支付服务。这类测试需要在订单服务中实现,涉及对支付服务的存根(见代码 2,存根用于模拟或替代真实组件的行为)。
在支付服务中,我们使用下面的集成测试对暴露的 API 进行测试。该测试脚本由 Spock 和 RestAssured 编写。
//代码1:支付服务 API 的集成测试
def "should initiate payment"() {
given:
//跳过
def request =
"""
{
"accountId": "$accountId",
"orderDescription": "My first order",
"quota": 128.5,
"paymentType": "TRANSFER_ONLINE",
"dueDate": "$dueDate"
}
"""
expect:
given()
.contentType(APPLICATION_JSON_VALUE)
.accept(APPLICATION_JSON_VALUE)
.and()
.body(request)
.when()
.put("/payment/%s".formatted(paymentId))
.then()
.assertThat()
.statusCode(200)
.body("status", equalTo("FINISHED"))
.body("paymentExternalId", equalTo("ext-%s".formatted(paymentId)))
}
该测试验证了 API 是否按支付服务团队的设计标准正常运行。主要目的是确认 API 能够处理规定的请求并生成预期的反馈。此外,该测试还应覆盖重要的业务逻辑操作(即使在测试过程中有些检查被略过)。
与此同时,负责订单服务的客户端团队也进行了订单创建流程的集成测试,这些测试同样采用了 Spock 框架,并通过 WireMock 来模拟支付服务的行为。
// 代码2:订单创建流程的集成测试,对支付服务进行存根
def "should create order and initiate payment"() {
given:
//skipped
def expectedRequest =
"""
{
"accountId": "$accountId",
"quota": $quota,
"paymentType": "TRANSFER_ONLINE",
"dueDate": "$dueDate"
}
"""
def paymentServiceResponse =
"""
{
"paymentExternalId": "ext-$orderId",
"status": "FINISHED"
}
"""
and:
wiremockServer.stubFor(put(urlMatching("/payment/([a-fA-F0-9-]*)"))
.withRequestBody(equalToJson(expectedRequest))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withHeader("Accept", "application/json")
.withBody(paymentServiceResponse)
)
)
expect:
sut.createOrder(accountId, orderId, quota).paymentProcessed() == true
}
这项测试利用了支付服务的模拟实现,以核实订单创建过程的逻辑正确性。在这个过程中,它基于对被调用服务运作方式的预设(即存根)进行操作。
但这样的测试方法存在什么问题呢?其实,只要负责各服务的团队对对方的工作机制了如指掌,这些测试通常能够顺利执行。他们需要对预期发出的请求和预期接收的响应有清晰的认识。在理想状态下,若双方无需修改他们之间的接口契约,测试就会更加顺畅。
这种情况使得服务的正常运行过度依赖于人员之间的交流和专业知识。比如,支付服务的一个团队成员发现响应数据中的字段命名不一致,他打算把“status”字段改为“paymentStatus”。这样的更改本身很简单,也容易调整集成测试以确保测试通过,从而推进项目进入生产阶段。但如果客户端代码没有相应的调整,那么服务之间的通信就会出现问题。一旦这个变更的服务部署到生产环境,通信就会中断,导致服务不可用,进而使公司遭受经济损失。
为了向客户端有效传达 API 中计划的破坏性更改,支付服务团队首先需要清楚地了解他们的客户端是谁。更进一步,他们还需要判断这次更改是否会对客户端产生影响。这就要求支付服务团队必须深入了解客户端是如何应用他们的 API 的。但仅凭现有的集成测试是无法获得这些信息的。因为集成测试仅仅是基于对接口契约的一些假设,并不能保证客户端在实际生产环境中的使用方式与此相符。
这种测试方法的一个显著弊端是导致了工作量的重复。为了确保两个服务之间的通信无误,我们需要分别在服务提供方和服务消费方实现两套测试集。通常情况下,这些测试代码会分别存放在客户端和服务端的代码库里。更棘手的问题是,这两端的测试并不是实时同步的。这意味着,如果客户端团队对接口契约进行了更改,这种变动可能无法及时反映到服务端的代码中,反之亦然。
(图片 1.两个服务的集成测试存储在不同的库中,并不同步)
最后,值得一提的是,API的集成测试通常都比较枯燥且重复性高。在测试过程中,首先会执行目标API,接着检查其是否返回了预期的结果。这种检验通常需要逐个字段进行,涵盖响应中的所有字段。
那么,有没有更高效的测试方法呢?本文后续部分将会对此进行深入探讨。
端到端测试
首先,让我阐明一下所谓的端到端测试。端到端测试的目的是确保整个系统的正常运作。这类测试在真实的服务实例上进行,不涉及隔离环境。这些测试通常在被称为“暂存环境”的系统上执行,主要用于核实关键的业务场景是否能够达到预期的运作效果。
端到端测试能够应对先前提及的问题,但它们也有自身的局限性。其中一个主要问题是,执行这类测试时需要所有微服务都已部署并处于运行状态。这样的环境搭建和维护难度较大,且成本较高,因为它既需要相应的基础设施,又耗费时间。如果我们的目标是检验两个服务之间的接口契约,那么就无法进行孤立测试。相反,我们不得不依赖于部署了这些代码的基础设施来进行测试。这种方式不仅操作复杂,而且与微服务倡导的松耦合、独立部署原则相悖。
契约测试
契约测试是一种介于集成测试和端到端测试之间的测试方法。它旨在独立验证两个服务间通信的准确性。其核心假设是:如果服务的消费者和生产者分别根据相同的契约进行独立测试,那么它们应该能够正确地相互通信。这正是契约测试的关键理念。这里有一个共享契约,它规定了服务间的交互方式,无论是同步还是异步。消费者和生产者的测试都建立在这个契约的基础上。这与传统的集成测试有所不同,后者通常涉及两套独立的测试集,且缺乏有效的自动同步机制。
在具体实现上,契约通常是一份文档,用DSL(领域特定语言)来描述服务间的通信过程。这种契约的表达方式取决于选用的框架,可以采用多种语言和格式,比如Java、Groovy、JSON或YAML等。下面提供了一个使用Java(基于Pact框架)编写的契约示例:
builder
.given("test PUT)
.uponReceiving(“PUT REQUEST")
.path(“/payment”)
.headers(expectedHeaders)
.method(“PUT”)
.willRespondWith()
.status(200)
.headers(headers)
.body(“{”accepted”: true}")
生产者和消费者?
契约测试中有两个概念:生产者和消费者。生产者是指那些提供 API 接口的服务。在基于消息的通信模式中,生产者负责发布消息。而消费者则是指那些使用生产者提供的 API 接口或消费其发布的消息的服务。
在本文中,我把提供 API 接口的服务称为生产者,而把使用这些接口的服务称为消费者。
契约测试的优势在哪里?
接下来,我们来探讨它是如何解决集成测试中遇到的问题的。
首先,集成测试中存在的一个问题是,一方在不通知另一方的情况下违背了接口契约。这种情况在集成测试中是很难出现的,,因为存在一个统一的、共享的契约定义。例如,如果生产者(支付服务团队)修改了响应数据,这一更改必须体现在契约文档中。如果没有做到这一点,他们的测试就会失败。契约的改动同样会影响到消费者方的测试,如果这种更改不兼容以往的接口,消费者的测试很可能也会失败。这就迫使双方基于契约的更新来相互通报变更情况。
因为契约中详细记录了所有通信的细节,生产方团队(例如支付服务)能够清楚地了解谁在使用其API,以及使用的方式。这些契约通常存放在生产方的代码仓库(或专门的契约仓库中)。当多个客户端使用同一个API时,每个客户端与服务端的集成都对应一个独立的契约(契约是针对每个客户端单独定义的)。因此,生产方团队可以通过这些契约轻松识别出哪些客户端在调用它们的服务。例如,如果我们计划更改或删除响应数据中的某个字段,只需查看与所有客户端达成的契约,就可以判断该字段是否正在被使用,从而在 API 的开发过程中保持足够的灵活性。
根据使用的测试框架的不同,我们可能不需要手动维护任何测试用例或模拟对象(stubs)。生产方的测试可以直接从契约中生成,无需我们亲自编写。这些测试确保 API 的实现与契约中的定义相符。与此同时,消费方的测试则利用由同一契约生成的模拟对象,确保双方在通信及其测试上保持一致。这样一来,还减少了我们需要手工编写的代码量。
简而言之,采用这种方法,我们主要维护的就是契约本身。契约详细阐述了服务间如何进行通信。无论是生产方还是消费方的测试,都是基于契约中记录的规则来执行的。这种做法让我们对两边的正确实现和有效运作有了充分的信心。
谁来负责契约?
在契约测试中,存在一个契约定义的集中点。这就引出了一个问题:谁应该负责契约的制定和维护?根据涉及的服务(及其团队)之间的关系,通常有两种测试方法:
消费者驱动的契约测试
当生产方是一个下游服务,需要尽量满足消费方的需求时,就会采用消费者驱动的测试策略。在这种情况下,是消费方来指导通信的契约。他们定义了API应该如何设计。当然,这种协议需要经过生产方团队的审核、讨论和确认。这通常是通过消费方团队向契约的代码仓库提交拉取请求来实现的,然后由生产方团队进行审查,并最终实施。
这种方法提供了一些显著优势:
由于契约已经明确并得到确认,消费方团队无需等待生产方完成功能的实现。相反,他们可以依据契约生成模拟对象,从而进行服务集成的测试。
生产方清楚地了解其API的使用者及使用方式
在这种模式下,可以采用以下步骤来协调消费方和生产方团队的工作流程:
- 比如,负责订单服务的团队(即消费方)正在开发一个新功能,这需要与支付服务(即生产方)进行集成。在进行测试驱动开发时,他们发现需要一个支付服务API的模拟对象。
- 双方团队对接口契约进行讨论并用DSL进行定义,然后将其提交至支付服务的代码仓库。
- 接着,根据契约生成生产方API的测试代码。由于相应的功能尚未实现,这些测试暂时无法通过。
- 这些契约和测试代码被推送到代码仓库,并可能创建一个拉取请求,供生产方团队后续处理。
- 与此同时,支付服务的模拟对象和测试代码也被生成,并可能上传到如Artifactory这样的工件库中。
- 这样,消费方团队就可以利用这些模拟对象来完成自己的测试工作。
这说明,即使生产方的实现尚未完成,消费方也可以继续进行自己的开发工作。当然,除非生产方完成了实现,否则这个解决方案无法部署到生产环境。但这并不会阻碍消费方的进展。
在最后阶段,生产方团队需要按照契约规定来实现API。只要所有测试都通过,服务就可以部署到生产环境。这样一来,消费方的HTTP客户端和生产方的API都是基于同一份契约开发和测试的。这确保了服务在实际生产环境中能够顺利沟通。
但这里也存在一个挑战。这种方法只有在所有消费方都接受这一契约规则的前提下才有效。如果有任何一个消费方不遵守契约规则,整个理念可能就会受到影响。通常,可以通过要求消费方在与我们的服务集成之前必须创建并同意契约来解决这个问题,这可以通过技术手段实施,比如限制访问我们的服务。
生产者驱动契约测试
另一种情况是,我们的服务可能被多个未知方所使用。在这种场景下,我们无法确切知道谁会使用我们的 API,以及他们将如何使用。当我们不希望客户端(即消费者)对我们的 API 产生影响时,也会采取这种策略。在这个模型中,生产方是一个上游服务。它负责定义 API 的契约,并指导消费方如何进行集成。消费方可以将这些契约作为文档来参考。同时,他们还可以利用这些契约来测试与生产方的集成,而无需将代码部署到实际服务器上。
契约测试的陷阱
我经常发现人们在错误的场合过度使用契约测试,我自己也曾有过这样的经历。这里的过度使用是指在本应进行单元测试或集成测试的场合下使用契约测试。契约测试的适用场景是测试那些进行同步或异步通信的服务的契约,而不是用来检验业务逻辑。
以支付服务的 API 为例,假设其响应中包含一个状态字段,根据不同场景,它的值可能是 FINISHED、IN_PROGRESS 或 FAILED。但这个字段的具体值实际上是业务流程的内部逻辑,并不适合通过契约测试来验证,而更适合通过单元测试来进行。我们不应该为这个字段的每个可能值都编写一个契约测试场景,而只需要确保这个字段存在,并可能包含上述任一值即可。
相对地,如果特定请求的响应不同,比如额外增加了一个字段或在错误请求情况下返回400 HTTP状态,这些就应该通过契约测试来验证,因为它们涉及到特定的通信情况。
更进一步,如果有 3 种不同情景都会返回 400 错误码,但响应内容仅在消息文本上有所区别,那么针对这种情况只需要创建一个契约测试场景就足够了。
总结
本文首先指出了在微服务架构中进行通信测试所面临的挑战,并探讨了两种常用的测试方法:集成测试和端到端测试。随后,介绍了契约测试作为应对这些挑战的一种有效策略。
契约测试是一种允许在隔离环境中独立测试服务之间的通信(包括同步和异步)的方法。其核心是围绕契约展开的,契约是服务间共同商定并记录下的通信规则。它是定义通信契约的集中点,契约一旦更改,相关各方无需进行同步调整。无论是生产者还是消费者的测试都基于这份契约。这保证了,只要测试通过,服务在生产环境中就能够顺畅配合。
文章接着阐释了消费者驱动和生产者驱动两种方法的区别以及它们各自的适用场景。最后还提供了一个关于如何避免过度使用契约测试的实用建议。
AREX 文档:https://arextest.com/zh-Hans/docs/intro/
AREX 官网:https://arextest.com/
AREX GitHub:https://github.com/arextest
AREX 官方 QQ 交流群:656108079