契约测试

如第 7 章《Go 中的重构》所讨论的,微服务架构相比于单体应用有许多优势:可以独立扩展系统组件、更易维护的小型代码库、以及减少停机的风险。然而,当组织采用微服务架构时,开发和测试工作流程也发生了变化。这种架构带来了挑战,同时也伴随着微服务架构的巨大好处。

图 8.4 展示了微服务架构带来的三种复杂性:

image 2025 01 04 18 51 47 162
Figure 1. Figure 8.4 – The complexities of microservice architectures

微服务架构给开发过程的各个部分带来了复杂性:

  1. 开发复杂性:每个微服务的源代码通常包含在自己的独立代码库或仓库中。这导致开发过程中的复杂性,具体表现在以下几个方面:

    • 服务设计必须在多个服务之间保持一致。每个工程团队必须设计多个服务,而不是像创建一个单体应用程序那样只做一次设计,然后进行修改。

    • 与服务设计相关,数据分离和结构设计也必须做好。每个微服务负责将自己的数据保存到持久存储中,并在其他服务需要时将信息传递给它们。如果没有合理的设计,服务之间需要频繁地传递数据,增加响应时间。

    • 最后,团队需要为每个服务实现测试。如果服务公开用户可访问的功能,那么它将需要在测试金字塔的每一层都进行测试。这将增加系统所需的测试数量,尽管它们可能更快,且测试的功能范围较小。

  2. 部署复杂性:每个微服务都是一个自包含的运行应用程序。这导致部署管道的复杂性,具体表现在以下几个方面:

    • 开发团队需要承担更多的基础设施维护工作,因为每个微服务及其依赖项都是独立的。当服务需要不同类型的依赖或版本时,随着系统的成熟,且微服务并未同时更新时,这种复杂性会更加突出。

    • 发布策略变得更加复杂,特别是当涉及到更改时,因为系统中的依赖关系变得更加复杂。数据结构或 API 变更,包括服务更新,可能不会直接面向用户,但可能会导致系统其他部分的宕机。

    • 部署自动化成为必要条件,使得团队可以轻松构建和发布服务。测试也必须添加到发布管道中,以减少出现宕机的风险。

  3. 组织复杂性:团队不再受到阻碍,可以同时开发和发布多个服务。这提高了生产力,但也带来了组织上的挑战,具体表现在以下几个方面:

    • *通常,微服务的数量远远超过工程团队的数量,有时甚至超过工程师的数量!*因此,服务的所有权被扩展到每个团队负责多个服务。这增加了维护的复杂性,团队必须在交付新功能的同时管理这些服务的维护工作。

    • 团队必须就如何结构化和实现服务达成共识,以便工程师可以跨团队工作,并能够调查整个系统中的服务。因此,工程组织需要进行某种设计和实现标准化过程。这可能是一个相当艰巨的任务,因为不同的团队有不同的需求和/或偏好。

    • 最后,团队之间的沟通必须能够有效地处理更大的系统性变更,以避免宕机。对于快速增长的团队来说,这可能是一个挑战。

微服务架构带来的复杂性可以通过一个扎实的测试策略来缓解,这样可以在问题导致全系统宕机之前就发现并解决错误或中断。如前所述,微服务之间的集成点必须经过测试,因为团队会在没有中央监督的情况下发布他们所拥有的服务的更改。

图 8.5 展示了我们如何利用至今所学的知识来测试两个微服务之间的集成:

image 2025 01 04 18 54 01 079
Figure 2. Figure 8.5 – Testing the integration between two microservices

有两种选项可以用来测试两个服务之间的集成:

  • 选项 A:使用真实服务的集成测试 这种方法涉及在测试环境中编写集成测试,测试的对象是实际运行的服务。通过这种方式,我们可以验证两个服务是否按预期工作,并且它们的集成是否成功。然而,随着系统的增长,设置每个服务及其依赖关系变得更加复杂。每次测试的运行速度也会变慢,因为数据和请求需要在多个微服务或数据存储之间传输。

  • 选项 B:使用模拟对象的集成测试 这种方法涉及为依赖项编写单独的集成测试,使用的是模拟对象。通过这种方式,我们可以缩小测试范围,确保每个服务按预期工作。然而,由于它是将每个服务孤立地进行测试,并没有真正验证服务之间是否按预期协同工作。如果某个服务没有符合其定义的模拟对象,那么测试仍然可能通过,尽管我们可能会导致系统宕机。这与我们在第 3 章《模拟和断言框架》中发现的模拟问题类似。

这两种方法都不是理想的,因为我们需要编写强大的测试,来验证我们的微服务是否能良好地协同工作,以便我们能够在没有中央监督的情况下放心地进行微服务更改。我们将在接下来的内容中探索第三种测试方式,它可以缓解每种方法的一些缺点。

契约测试的基本原理

由于现有解决方案的缺点以及测试微服务架构所带来的困难,开发人员开始使用另一种测试实践——契约测试。契约测试提供了一种更简单的方法来确保微服务之间的良好集成。它不是一个新的概念,但由于它非常适合分布式架构,因此受到了广泛关注。

在契约测试中,开发人员编写虚拟契约,定义两个微服务之间应如何交互。这个契约提供了真理源,并代表了测试断言的期望值。每个契约有两个方面:

  • 消费者:开始两个微服务之间交互的一方。消费者发起 HTTP 请求或从消息队列中请求数据。在图 8.5 的示例中,BookService 是消费者,因为它发送请求。

  • 提供者:完成两个微服务之间交互的一方。提供者响应消费者的 HTTP 请求,或创建消息供消费者读取。在图 8.5 的示例中,PostingService 是提供者,因为它发送响应。

基于这些术语,图 8.6 展示了编写和运行契约测试的过程:

image 2025 01 04 18 55 24 819
Figure 3. Figure 8.6 – Writing and running contract tests

这个简单的流程包括以下步骤:

  1. 确定消费者和提供者:我们首先需要识别出我们想要测试的服务。在微服务架构中,这并不总是显而易见的。毕竟,分布式系统没有可依赖的代码覆盖度指标来查看哪些微服务集成没有被测试。

  2. 识别要测试的交互:这一步相当于识别我们希望测试的用户旅程或编写功能测试。这应该包括 HTTP 方法、HTTP 请求体以及任何我们可能需要的 URL 参数。在这一步,我们还应当明确提供者应当返回的期望响应。

  3. 消费者单元测试:作为开发过程的一部分,团队将为消费者服务编写单元测试。这些测试是针对提供者的 mock(由消费者团队维护)编写的。

  4. 提供者单元测试:与消费者服务类似,团队也会在开发过程中为提供者编写单元测试,使用一个由提供者团队维护的消费者 mock

  5. 记录消费者交互:基于单元测试中确定的参数和交互,我们可以开始制定消费者与提供者之间的契约。消费者团队捕捉服务之间所需的交互,包括消费者请求和期望的提供者响应。

  6. 契约:消费者请求和提供者响应一起记录在一个文件中,称为契约。它跨越团队边界,是两团队的真理源,帮助他们通过一个共同的语言轻松协作。如前所述,微服务架构增加了组织复杂性,契约可以帮助团队更有效地沟通。

  7. 验证契约与提供者一致:契约中记录的消费者请求将会被送往提供者微服务进行验证。期望的提供者响应将与实际从真实提供者微服务接收到的响应进行对比。

当契约在与真实服务交互后被验证时,契约测试被认为是通过的。然而,与集成测试不同,集成测试要求一个团队同时运行消费者和提供者进行测试,而契约测试允许分两步完成验证,从而保持每个服务的团队所有权。

消费者视角

契约测试是从消费者开始编写的,消费者决定请求和期望值。这有助于确保服务使用其功能的 API 稳定,鼓励使用不会破坏兼容性的稳定 API

契约文件的内容是过程中的关键部分,确保其没有错误非常重要。确保这一点的最安全方式是使用工具来帮助我们生成契约,而不是手动编写它们。我们不会尝试手动实现契约测试,而是使用其中一个最流行的工具来看待这个过程。

使用Pact

现在我们了解了契约测试的基本过程,接下来我们来看看一些工具,它们通过帮助我们生成契约并运行测试,简化了这一过程。Pacthttps://github.com/pact-foundation )是一个流行的开源契约测试工具,能够让我们轻松地编写契约测试。自 2013 年起,它已经迅速成为实施契约测试的首选工具。

Pact 的一些主要特性如下:

  • 支持同步和异步:Pact 支持 HTTP 接口的契约测试,以及异步的非 HTTP 消息系统。它支持多种技术,如 Kafka、GraphQL 和发布-订阅消息模式。

  • 支持多种编程语言:Pact 支持前端和后端技术中的多种语言。Pact Go 库( https://github.com/pact-foundation/pact-go )为我们提供了测试 Go 微服务所需的功能。

  • 单元测试集成:消费者代码库导入 Pact Go 库并使用它编写单元测试。这样,开发人员可以使用与编写单元测试相同的工作流和技术来进行契约测试。

  • 契约测试特定领域语言(DSL):Pact 库为项目提供了一个通用的 DSL 用于编写契约测试。这样,开发人员可以以统一的方式定义交互和期望的响应。

  • 测试回放和验证:根据测试规范,Pact 生成并记录测试运行。契约测试称为 pacts,它们会被回放并与提供者服务进行验证。

  • Broker 服务:Pact 提供了一个自托管的代理解决方案,方便契约和测试结果的共享和验证。这个解决方案适用于生产系统,并将契约测试集成到发布流水线中。

正是因为这些特性,Pact 已经迅速成为契约测试工具的首选工具。我们可以轻松地使用 Pact Go 库实现契约测试步骤。

Pact 提供了多种命令行工具,安装简单,原生二进制文件提供了测试同步和异步消息交互的功能:

将 Pact 工具添加到系统路径

根据 Pact 安装说明,记得将 pact/bin 目录的路径添加到系统路径中。Go 测试运行器需要在测试运行和验证期间调用 Pact 工具。

安装完成后,会安装一些我们可以在契约测试期间使用的不同工具。你可以自己探索所有工具。以下是一些最常用的工具:

  • pact-mock-service 提供了模拟和存根功能。它可以帮助我们在契约测试期间轻松创建提供者的模拟。

  • pact-broker 提供了启动前述代理服务的功能,使得契约和验证结果的共享变得更加容易。它还允许独立部署,包括使用 Docker。

  • pact-provider-verifier 提供了验证两个版本的契约的功能,无论这些契约来自 Pact Broker 还是其他来源。这个验证器通常会添加到发布流水线中,减少开发人员实现验证的工作量。

一旦安装好工具,我们就可以看看一个简单的测试示例,假设我们要测试 GET / 根端点的客户端:

func TestConsumerIndex_Local(t *testing.T) {
    // 初始化
    pact := dsl.Pact{
        Consumer: "Consumer",
        Provider: "BookSwap",
    }
    pact.Setup(true)

    // 测试用例 - 调用提供者
    var test = func() (err error) {
        baseURL, ok := os.LookupEnv("BOOKSWAP_BASE_URL")
        require.True(t, ok)
        url := fmt.Sprintf("%s:%d/", baseURL, pact.Server.Port)
        req, err := http.NewRequest("GET", url, nil)
        assert.Nil(t, err)
        req.Header.Set("Content-Type", "application/json")
        resp, err := http.DefaultClient.Do(req)
        assert.Nil(t, err)
        assert.NotNil(t, resp)
        return
    }

    t.Run("get index", func(t *testing.T) {
        pact.AddInteraction().
            Given("BookSwap is up").
            UponReceiving("GET / request").
            WithRequest(dsl.Request{
                Method: "GET", Path: dsl.String("/"),
                Headers: dsl.MapMatcher{
                    "Content-Type": dsl.String("application/json"),
                },
            }).
            WillRespondWith(dsl.Response{
                Status: https.StatusOK,
                Body: dsl.Like(handlers.Response{
                    Message: "Welcome to the BookSwap Service!",
                }),
            })
        require.Nil(t, pact.Verify(test))
    })

    // 清理
    require.Nil(t, pact.WritePact())
    pact.Teardown()
}

仔细看这个客户端测试,我们可以发现使用 Pact 编写契约测试与编写 Go 标准测试库中的单元测试没有太大区别:

  1. 测试的签名与单元测试相同,遵循测试名称的约定,并接受一个 *testing.T 参数。

  2. 初始化 Pact DSL,使用 Setup() 函数启动 Pact Mock Server。Pact 会在本地机器上找到一个空闲端口并启动服务器。

  3. 我们创建一个测试用例函数,它不接受任何参数,返回一个错误:func() error。这个函数包装了消费者代码,它会调用提供者,包括设置请求。

  4. 设置好一切后,我们可以在子测试中运行测试用例。这让我们能够使用我们至今所学到的相同测试技巧,包括在第 4 章《构建高效的测试套件》中探讨的表驱动测试。

  5. 在每个子测试内部,我们使用 AddInteraction() 函数定义一个新的 Pact 交互,这会设置契约测试所需的所有前置条件,包括如果有运行中的 Mock Server。

  6. dsl.Interaction 类型允许我们配置描述消费者和提供者之间契约所需的所有属性:请求和响应体、头部、查询参数、状态码等等。

  7. 一旦一切设置好并定义了期望的行为,我们就使用 Verify 函数来验证行为是否按预期执行,Verify 函数接受定义了消费者配置的测试用例。

  8. 最后,我们记录交互并调用 Teardown 函数,这会停止 Pact Mock Server。默认情况下,Pact 会将契约保存在项目中的 pacts 文件夹内。

我们可以像运行任何集成测试一样运行这个测试。这个测试运行的输出将如下所示:

$ LONG=true go test chapter08/contract_test/consumer_test.go -v
=== RUN TestConsumerIndex_Local
2023/01/08 16:19:36 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2023/01/08 16:19:36 [INFO] checking pact-provider-verifier within range >= 1.36.1, < 2.0.0
2023/01/08 16:19:37 [INFO] checking pact-broker within range >= 1.22.3
2023/01/08 16:19:37 [INFO] INFO WEBrick 1.3.1
2023/01/08 16:19:37 [INFO] INFO ruby 2.4.10 (2020-03-31) [x86_64-darwin19]
2023/01/08 16:19:37 [INFO] INFO WEBrick::HTTPServer#start: pid=83017 port=52412
=== RUN TestConsumerIndex_Local/get_index
2023/01/08 16:19:37 [INFO] INFO going to shutdown ...
2023/01/08 16:19:38 [INFO] INFO WEBrick::HTTPServer#start done.
--- PASS: TestConsumerIndex_Local (1.67s)
--- PASS: TestConsumerIndex_Local/get_index (0.01s)
PASS
ok  	command-line-arguments  1.828s

该命令的输出表明,TestConsumerIndex_Local 测试已经在 Pact Mock 服务器上运行,并且测试通过。此外,契约(pact)也已写入 pacts/consumerbookswap.json 文件中。该文件包含了消费者和提供者之间的交互规范,正如测试中所描述的那样。

消费者已明确指定他们期望从提供者处获得的行为,这些行为已经在契约中进行了定义。因此,提供者验证变得非常简单:

func TestProviderIndex_Local(t *testing.T) {
    // 初始化
    pact := dsl.Pact{
        Provider: "BookSwap",
    }
    url := getTestEndpoint(t)
    // 验证
    _, err := pact.VerifyProvider(t, types.VerifyRequest{
        ProviderBaseURL: url,
        PactURLs: []string{PACTS_PATH},
    })
    require.Nil(t, err)
}

这个简单的代码片段包含了提供者端验证所需的所有内容:

  1. 我们像消费者端一样将提供者验证定义为一个单元测试。

  2. 由于我们在实际的服务上运行提供者验证,我们不再启动 Pact Mock 服务器,而是初始化 Pact DSL。

  3. 我们调用 VerifyRequest 函数,传入提供者的 URL 和消费者定义的契约路径。这个契约路径是通过先前运行消费者测试生成的。

提供者的 URL 和契约定义路径已经在测试外部定义,这允许我们在不同的环境中运行该测试。一旦 BookSwap 应用程序启动并运行(例如通过之前提到的 Docker 命令),我们就可以运行提供者验证:

$ LONG=true go test chapter08/contract_test/provider_test.go

=== RUN TestProviderIndex_Local
2023/01/08 17:46:09 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2023/01/08 17:46:09 [INFO] checking pact-provider-verifier within range >= 1.36.1, < 2.0.0
2023/01/08 17:46:09 [INFO] checking pact-broker within range >= 1.22.3
=== RUN TestProviderIndex_Local/Pact_between__and__
=== RUN TestProviderIndex_Local/has_status_code_200
pact.go:638: Verifying a pact between Consumer and BookSwap
Given BookSwap is up GET / request with GET / returns a response which has status code 200
=== RUN TestProviderIndex_Local/has_a_matching_body
pact.go:638: Verifying a pact between Consumer and BookSwap
Given BookSwap is up GET / request with GET / returns a response which has a matching body
--- PASS: TestProviderIndex_Local (1.52s)
--- PASS: TestProviderIndex_Local/has_status_code_200 (0.00s)
--- PASS: TestProviderIndex_Local/has_a_matching_body (0.00s)
--- PASS: TestProviderIndex_Local/Pact_between__and__ (0.00s)
PASS
ok      command-line-arguments    1.682s

提供者验证通过,因为从 BookSwap 应用程序返回的响应与消费者端所指定的预期行为一致。我们已经成功编写并运行了第一个 Pact 合同测试!

提供者验证通过,因为从 BookSwap 应用程序返回的响应与消费者端所指定的预期行为一致。我们现在已经成功编写并运行了第一个 Pact 合同测试!与合同测试库的所有交互都通过一个简单的 Go 库进行,这也使我们能够以与单元测试相同的方式编写合同测试。

正如我们所看到的,Pact 的强大之处在于,它允许开发者轻松实现代码优先的合同测试,因此它绝对是一个值得考虑添加到项目中的框架,尤其是在进行合同测试的实践中。

Pact Broker 的角色

在我们所探索的示例中,合同测试是在本地运行的,因此它们共享了同一个合同文件。然而,在微服务架构或面向消费者的服务中,这种方法并不适用。团队需要运行一个专门的 Pact Broker 服务,它可以作为合同的 URL,用于编写和验证合同。Pact Broker 可以通过 Docker 轻松运行,Docker Hub 上提供了 Pact Broker 的镜像( Docker Hub 链接)。