五星上将麦克阿瑟曾经说过“在契约测试面前,集成测试就是个弟弟“


让我们来讲一个故事


今天和女朋友吵架了,(假设你有女朋友)。

今晚又是一个人睡沙发,这天晚上,你躺在沙发上,夜不能寐

决定分享一下今天的主题——锲约测试


契约测试

什么是契约?

如果从契约产生的阶段来说,现有资料表明最早要追溯到西周时期的《周恭王三年裘卫典田契》,将契约文字刻写在器皿上,就是为了使契文中规定的内容得到多方承认、信守,“万年永宝用”。所以订立契约的本身,就是为了要信守,就是对诚信关系的一种确立。诚信,是我国所固有的一种优良传统,也是延续了几千年的一种民族美德,在中国儒家的思想体系里,是伦理道德内容中的一部分。

然而,现在不是这么美好,现实中缺少契约精神的比比皆是

契约测试?生产者?消费者?一文帮你理清楚_ide

但是,在软件测试领域,契约这把利器,又重新的利用起来

先从测试金字塔讲起


契约测试?生产者?消费者?一文帮你理清楚_软件测试_02


对于测试而言,这个金字塔是理解测试级别的最好的隐喻,这个金字塔最早出于

Mike Cohn 在他的《Succeeding with Agile》,我们从底层往上读

  1. 单元测试通常是添加到项目中最常见的测试。目标是在函数或方法级别验证代码。如果您有 sum 函数,那么您想要检查它5 + 5 = 10。通常编写和维护此类测试很容易。
/**
 * the function to test
 */


const sum = (a, b) => {
  return a + b;
};




/**
 * the unit test
 */


test("adds 5 + 5 to equal 10", () => {
  expect(sum(5, 5)).toBe(10);
});
  1. 集成测试(或系统测试)检查组件之间的接口。您可以测试整个类或服务,这通常涉及mock模拟无法在测试环境中重现的外部接口。编写集成测试有点困难,因为涉及的代码更多,而且维护成本也更高。一次测试大量代码,因此追踪问题可能需要一些时间。

契约测试?生产者?消费者?一文帮你理清楚_测试_03

3. 端到端(E2E)测试是最完整的测试,因为目标是模拟产品的最终用户。您通常需要构建一个完整的端到端环境,其中包含应用程序的所有组件(所有服务、后端存储等)。您可以使用 Postman 等工具来模拟 REST 调用,或使用 Cypress 等工具来模拟通过 Web 应用程序界面的使用情况。通常,您将编写较少的 E2E 测试,因为它们在运行时间和维护时间方面都花费大量时间。

什么是锲约测试?

但是,显而易见的出现了一个问题

虽然金字塔顶部的测试更能代表客户的体验,但它们也有一些令人痛苦的缺点。他们:

  • 很慢;由于它们遍历多个系统并且通常必须串行运行,因此每个测试可能需要几秒钟到几分钟才能完成,特别是在必须执行先决设置(例如数据准备)的情况下。
  • 难以维护;端到端测试要求所有系统在运行之前都处于正确的状态,包括正确的版本和数据。
  • 可能不可靠或不稳定:由于编排测试环境的复杂性,它们经常会失败,导致误报,从而分散团队的注意力。在许多情况下,它们会由于与任何代码更改无关的配置问题而失败。
  • 难以修复:当端到端测试失败时,由于问题的分布式和远程性质,调试问题通常很困难。
  • 规模严重;随着越来越多的团队的代码得到测试,事情变得更加复杂,测试套件的运行速度呈指数级下降,并且发布在自动化管道中被堵塞。
  • 在流程中发现错误为时已晚:由于运行此类测试套件的复杂性,在许多情况下,这些测试仅在代码提交后才在 CI 上运行 - 在许多情况下,由单独的测试团队在几天后运行。这种反馈延迟对于现代敏捷交付团队来说代价极其高昂。

所以,契约测试就是为了解决这个问题

通常具有与 e2e 集成测试相反的属性:

  • 它们运行速度很快,因为它们不需要与多个系统通信。
  • 它们更容易维护:您不需要了解整个生态系统来编写测试。
  • 它们很容易调试和修复,因为问题只出现在您测试的组件中 - 因此您通常会得到失败的行号或特定 API 端点。
  • 它们是可重复的:
  • 它们可扩展:因为每个组件都可以独立测试,所以构建管道不会随时间线性/指数增长
  • 他们在开发人员机器上本地发现错误:合约测试可以而且应该在推送代码之前在开发人员机器上运行。
  • 契约测试?生产者?消费者?一文帮你理清楚_软件测试_04

所以,契约测试时契约测试是一种软件测试方法,重点验证分布式架构中不同组件、服务或系统之间的交互。这种方法在多个服务或组件由不同的团队开发和维护的场景中非常有用,并且确保它们正确通信和协同工作至关重要。简而言之,契约测试是一种确保两个独立的系统(例如两个微服务)兼容并且可以相互通信的方法。

那么,什么是微服务架构?

面向微服务的架构与更传统的整体方法相反。您可以构建松散耦合的服务集合,而不是构建单个软件(例如在服务器上运行的应用程序)。微服务架构具有更小的代码库以及更好的灵活性和可扩展性等优势。

契约测试?生产者?消费者?一文帮你理清楚_测试_05

但微服务给测试带来了一些挑战。您可以单独测试每个服务(与集成测试一样),也可以通过端到端测试来测试整个堆栈。

不幸的是,单独测试每个服务并不能保证应用程序对用户来说能够正确运行。如果服务 A 依赖于版本 中的服务 B 的模拟1.4.0,但服务 B 正在切换到1.5.0不同的 API 实现,那么您可以在此级别中断生产而不会出现任何问题。

端到端测试需要您构建一个包含所有所需服务的完整环境,并且测试可能需要几秒或几分钟才能完成,具体取决于复杂程度。因为有很多层,所以最终可能会遇到很多问题,并且很难追踪哪些组件发生了故障。


虽然5秒测试有许多用途,但最常见的主题是:

  • 人们是否理解产品或服务?
  • 人们是否觉得他们将从页面中获益?
  • 人们是否能回忆起公司或产品的名字?

这些问题很重要,因为如果一个页面能快速且容易地传达所有这些信息,那么它就更有可能吸引到目标用户。这在设计以提高转化率和参与度为目标的改进时是一个关键因素。
特别是,这样优化落地页可以对你的成功指标产生显著影响。你可以创建一系列的设计变体,对它们进行测试,然后快速迭代以找到最佳解决方案。

契约测试?生产者?消费者?一文帮你理清楚_ide_06

这就是为什么基于契约的测试在微服务架构中如此常见。

基于契约的测试。生产者和消费者

基于契约的测试(CBT)并不是一种新的方法,但这个概念在微服务世界中很容易理解。假设您正在运行一个只有两个微服务 A 和 B 的简单系统:

契约测试?生产者?消费者?一文帮你理清楚_微服务_07

A 正在消费服务 B。A 是消费者,B 是生产者。服务之间的对话是涉及信息交换的简单 HTTP REST 调用。

A 正在请求有关用户的信息:

GET /users/julien

B 正在提供有关用户的信息:

{
    slug: "julien",
    fullname: "Julien Bras",
    twitter: "_julbrs"
}

这段对话就是一份契约。B 期望使用特定路径 ( /users/{slug}) 进行 HTTP 查询,A 期望答案为带有键slug、fullname和 的JSON 对象twitter。

契约测试?生产者?消费者?一文帮你理清楚_测试_08


CBT 背后的想法是依赖这份合约,用合约中的信息测试各方:

契约测试?生产者?消费者?一文帮你理清楚_软件测试_09


每个测试都是简单且独立的(仅涉及一项服务),您只需测试每个关系的每一方即可。此测试同样适用于复杂的关系(例如具有多个链接服务的服务或正在使用服务的 Web UI)。

契约测试是如何进行的?

在此之前,我们先来理解一下,这三个关系

  • 消费者(Consumer):对于调用,发起请求的一方。对于MQ,为接收消息的一方。
  • 提供者(Provider):对于调用,响应请求的一方。对于MQ,为生成消息的一方。
  • 契约(Contract):消费者和提供者之间的共识,是一系列交互的集合。对于HTTP调用,包括描述消费者向提供者发送什么的预期请求,以及描述消费者希望提供者返回的最小期望响应。对于消息交互,则描述消费者希望得到的最小期望消息
  • 契约测试?生产者?消费者?一文帮你理清楚_契约测试_10


契约测试主要通过模拟服务间的交互来验证一个服务是否满足与其他服务通信的“契约”。

首先,每一个服务都需要为其外部通信定义一个契约。这个契约包含了服务端需满足的请求格式和预期的响应格式。例如,如果一个服务接受特定的HTTP请求并回应JSON格式的数据,那么这个请求的URL、方法(POST, GET等)、可能包含的请求头、可能的请求体中的字段,并且定义了对应的响应码、响应头以及响应体的内容,所有这些都会在契约中进行定义。

当定义好契约后,就可以进行契约测试了。契约测试主要包括以下两个步骤。

  1. 提供者端的契约测试:提供者端的契约测试主要是检查服务是否能够按照契约的规定,正确的处理请求并返回预期的响应。在这个过程中,测试框架会模拟各种请求,然后与契约中定义的响应进行对比,看这个服务是否满足契约。如果任何一个测试请求的响应与契约中定义的响应不符, 所有的契约测试就会失败,并进一步指出不一致的地方。
  2. 消费者端的契约测试:消费者端的契约测试主要是检查服务是否能够正确的发出契约中定义的请求,并正确处理预期的响应。在这个过程中,测试框架会模拟服务端,根据契约的定义返回预设的响应,看看消费者是否能够正确处理。如果消费者没能按照契约正确处理这些响应,那么测试也会失败。

对于消费者和提供者的测试,通常会采用一些流行的契约测试工具,例如Pact, Spring Cloud Contract等。

使用这种方式,契约测试可以保证服务间的交互都是符合预期的,而不论系统是否已经部署或者处于什么样的状态,它都只关注单个的服务或者连接,而忽略了系统的其它部分。这使得我们可以在系统的初期就验证服务间的交互是否正确,避免了在部署或者系统运行期间才发现问题,提高了开发和部署时的效率和可靠性。


契约测试?生产者?消费者?一文帮你理清楚_微服务_11

我们举一个例子

让我们假设有两个服务:订单服务(Provider)和库存服务(Consumer)。库存服务的角色是在收到订单请求时减少相应的物品数量。这两个服务之间的交互会通过HTTP API进行。

在这个场景中,我们定义的“契约”能够是以下形式:当订单服务向库存服务发送一个POST请求,这个请求包含订单详情(例如,产品ID和数量),如:

POST /inventory/update
Content-Type: application/json
{
    "productId": "123",
    "quantity": 3
}

库存服务则需要返回一个200状态码,并确认减少的数量,如:

200 OK
Content-Type: application/json
{
    "productId": "123",
    "quantity": 3,
    "status": "success"
}

在这个契约定义好之后,我们就可以进行契约测试了。

在生产者(订单服务)端的契约测试,我们会模拟库存服务发送的请求,然后检查订单服务的响应是否满足契约。比如我们会构建一个请求,包含productId为"123",quantity为3,然后检查返回的响应是否是200状态码,返回的JSON是否包含productId为"123",quantity为3以及status为"success"。

在消费者(库存服务)端的契约测试,我们会模拟订单服务,发送一个包含productId为"123",quantity为3的响应,然后看库存服务是否能够正确处理这个响应。例如,库存服务需要在接收到这个响应后,减少ID为"123"的商品的库存数量3。

以Pact框架为一个例子

以下是订单服务(Provider)的契约测试样例:

from pact import Consumer, Provider
from requests.api import post


# 创建一个Pact对象。Consumer是库存服务,Provider是订单服务。
pact = Consumer('InventoryService').has_pact_with(Provider('OrderService'))


# 定义交互
pact.start_service()
pact.given(
    'A request from InventoryService for order update'
).upon_receiving(
    'A POST request for order update'
).with_request(
    method='POST',
    path='/inventory/update',
    body={
        'productId': '123',
        'quantity': 3
    }
).will_respond_with(
    status=200,
    body={
        'productId': '123',
        'quantity': 3,
        'status': 'success'
    }
)


# 契约测试
with pact:
    result = post(pact.uri, jsnotallow={'productId': '123', 'quantity': 3})


# 检查结果
assert result.json() == {'productId': '123', 'quantity': 3, 'status': 'success'}
pact.stop_service()

在上面的代码中,我们首先定义了Consumer(库存服务)跟Provider(订单服务)之间的契约。然后我们开始了Provider的模拟服务,并定义了一个交互,这个交互定义了库存服务发来的请求如何以及订单服务的响应应该是什么。最后,我们在Pact的上下文管理器中执行契约测试,发送请求并检查响应是否符合预期。如果所有检查都通过,那么我们就可以确认订单服务满足了与库存服务之间的契约。否则,我们就需要修复订单服务以满足契约。

那么,这个例子中,订单服务是如何处理库存服务发来的请求的?

通常在实际场景中的微服务体系中,订单服务会有专门的路由和处理函数来处理库存服务发来的请求。假设我们使用Flask框架并展示一个简单地处理POST请求的例子

from flask import Flask, request, jsonify


app = Flask(__name__)


# 这个字典用来存储商品的库存信息
inventory = {"123": 10}


@app.route("/inventory/update", methods=["POST"])
def update_inventory():
    # 获取请求的JSON数据
    data = request.get_json()


    # 获取商品ID和需要更新的数量
    product_id = data["productId"]
    quantity = data["quantity"]


    # 更新商品的库存信息
    inventory[product_id] -= quantity


    # 返回响应
    return jsonify({
        "productId": product_id,
        "quantity": quantity,
        "status": "success"
    })


if __name__ == "__main__":
    app.run()

在以上代码中,我定义了一个路由"/inventory/update",这个路由只接受POST请求。当订单服务接收到库存服务的请求时,会执行update_inventory函数。这个函数首先会解析请求的JSON数据获得商品的ID和需要更新的数量,然后更新库存信息。最后,返回一个包含更新后的信息的JSON数据作为响应。这就是一种可能的订单服务处理函数的实现方式。


总结

契约测试和其他测试的对比

契约测试?生产者?消费者?一文帮你理清楚_测试_12

如果您正在管理微服务应用程序,CBT 可以成为您的测试武器库的一个很好的补充。如果使用得当,它可以取代现有E2E测试的重要组成部分。

契约测试?生产者?消费者?一文帮你理清楚_测试_13


以上就是今天的全部内容,希望对大家有所帮助,也希望大家多多留言、点赞、在看、转发四连爱❤️  支持。 咱们下篇文章见,Bye~👋

契约测试?生产者?消费者?一文帮你理清楚_微服务_14

关注微信公众号【一个正经的测试】,会有持续的AI最新消息分享与学习资料,一起来学习吧