探索 Godog

在本章中,我们对 BookSwap 应用程序进行了许多更改,扩展了其范围和复杂性。现在我们可以轻松地使用 Docker 容器启动和关闭应用程序,是时候将注意力转向编写 E2E 测试了。

在第 5 章《执行集成测试》中,我们讨论了如何编写 BDD 风格的测试。这种测试风格允许我们编写人类可读的测试场景,并使用 Given-When-Then 结构。这些可读的测试不仅可以作为我们项目的文档,还能让我们涉及多个利益相关者,编写真正涵盖应用程序功能的测试。

我们还探讨了 ginkgo 测试库,它允许我们在单元测试中添加 BDD 风格的断言,而 Godog (https://github.com/cucumber/godog) 是另一个用于编写 BDD 风格测试的测试库。ginkgo 主要用于单元测试,而 Godog 提供了额外的代码生成功能,非常适合用于编写集成测试和 E2E 测试。我们将学习如何使用这个出色的库进行集成和 E2E 测试。

以下是 Godog 的一些亮点:

  • 与我们之前使用的库不同,Godog 并不使用 go test 命令运行测试,而是使用 godog run 命令。此命令不仅生成测试文件,还运行已实现的测试。

  • 测试以功能文件的形式组织,这些文件描述了某个功能在特定场景下的预期行为。Godog 使用一种名为 Gherkin 的领域特定语言 ( https://cucumber.io/docs/gherkin/reference/ )。我们将在本章的其余部分探讨如何使用这种格式编写测试。

  • Godog 是一个开源库,由社区和 Cucumber 组织维护。您可以自由浏览源代码,甚至进行贡献。

与我们使用的其他依赖项一样,安装 Godog 可以通过在终端运行 go install 命令来完成:

$ go install github.com/cucumber/godog/cmd/godog@latest

现在我们已经了解了 Godog 的基本用法并成功安装,接下来我们将开始编写我们的第一个功能文件。我们先从一个简单的 BookSwap 应用程序功能文件开始:

Feature: New user signs up
  In order to use the BookSwap application
  As a new user
  I need to be able to sign up.

Background: Verify configuration
  Given the BookSwap app is up

Scenario: Sign up
  Given user details
  When sent to the users endpoint
  Then a new user profile is created

这个功能文件描述了 BookSwap 应用程序中关于新用户注册的一部分功能:

  • 该功能描述了作为新用户注册应用程序的场景。

  • 作为背景步骤,BookSwap 应用程序应该已启动。这使得我们可以在运行整个应用程序时进行 E2E 测试。

  • 完成该功能后,将提供以下功能:

    • 新用户可以创建用户资料。

    • 当他们的资料创建完成后,用户将看到他们的用户摘要并收到用户 ID,允许他们进一步与应用程序进行交互。

    • 注册后,用户可以通过其用户 ID 查看个人资料。

    • 任何后续与应用程序的交互都不在本功能的范围内。

正如我们所讨论的,功能文件基于应用程序的预期用户旅程和请求流程。功能文件应该易于阅读和理解,因此我们应当为其他功能和场景创建单独的文件,并使用非技术性语言。

在下一节中,我们将学习如何实现并运行这个功能文件。

使用 Godog 实现测试

安装 Godog 并概述了我们的第一个功能后,接下来让我们将注意力转向实现这个测试。

实现我们概述的功能测试的主要步骤如下:

  1. 创建功能文件和测试文件。

  2. 实现 BookSwap 应用程序功能的测试步骤。

  3. 运行应用程序和测试。

正如我们之前提到的,我们将使用 Godog 实现 BDD 风格的 E2E 测试,因此在运行测试之前需要确保应用程序已经启动并正常运行。然而,这并不是 Godog 的要求,我们可以使用这个易于使用的库在任何层级编写测试。

创建测试文件

如前所述,Godog 依赖于代码生成来简化开发者的工作。这个过程包括从终端复制代码并自己创建文件。让我们看看涉及的步骤。

步骤 1 – 创建功能文件

功能文件存储在 Go 项目的根目录下的 /features 目录中。由于我们在仓库中使用了项目文件夹,因此我们需要在 /chapter06/features 下创建一个文件。我们将在该目录下创建一个文件并将功能文本添加到其中:

$ mkdir chapter06/features
$ vim chapter06/features/newUserSignsUp.feature

请注意,文件的命名遵循功能名称,这样可以轻松理解该文件关联的功能。

步骤 2 – 生成步骤定义

一旦功能文件包含了我们的文本,就可以使用 Godog 生成所需的步骤。执行 godog run 命令时,终端会打印出以下生成的代码:

func aNewUserProfileIsCreated() error {
    return godog.ErrPending
}

func sentToTheUsersEndpoint() error {
    return godog.ErrPending
}

func theBookSwapAppIsUp() error {
    return godog.ErrPending
}

func userDetails() error {
    return godog.ErrPending
}

func InitializeScenario(ctx *godog.ScenarioContext) {
    ctx.Step(`^a new user profile is created$`, aNewUserProfileIsCreated)
    ctx.Step(`^sent to the users endpoint$`, sentToTheUsersEndpoint)
    ctx.Step(`^the BookSwap app is up$`, theBookSwapAppIsUp)
    ctx.Step(`^user details$`, userDetails)
}

图 6.4 展示了我们场景中的步骤顺序,以及它们发出的 HTTP 请求:

image 2025 01 04 18 12 25 268
Figure 1. Figure 6.4 – Steps and HTTP requests made in our scenario

生成的代码为我们场景中的每个步骤提供了一个函数:

  1. aNewUserProfileIsCreated:此函数向 GET /users/{id} 端点发送请求,并验证用户档案是否成功创建。它还将验证是否可以使用分配的用户 ID 成功检索该用户档案。

  2. sentToTheUsersEndpoint:此函数向 POST /users 端点发送一个 JSON 负载,并验证该端点是否返回正确的用户详情。它还将获取应用程序为新用户档案生成的用户 ID。

  3. theBookSwapAppIsUp:此函数向 GET / 端点发送请求,并验证应用程序是否返回 200 OK 状态码。在生产环境中,我们通常会暴露一个独立的 /health 端点,但我们将利用根端点来演示 BookSwap 应用。

  4. userDetails:此函数将创建一个 db.User 实例,将其序列化为 JSON 负载,并发送给 sentToTheUsersEndpoint 步骤。它还将作为我们测试断言中的期望值(want 变量)。

我们需要实现这些函数,以便调用应用程序的功能。

最终,InitializeScenario 函数将所有这些函数结合成步骤,并按字母顺序排列。当我们实现测试文件时,我们需要根据功能定义正确地排序这些步骤。

虽然生成的代码很简单,但它为我们的测试代码提供了一个框架,并处理了与 Godog 测试运行器的交互。

步骤 3 – 创建测试文件

与常规的单元测试一样,Godog 测试也位于 *_test.go 文件中,并与它们测试的包一起存在。由于我们将测试整个应用程序,因此我们将在根目录下创建一个测试文件,位于 /features 目录的同级。我们创建一个与功能名称相匹配的测试文件,并将生成的代码粘贴到其中:

$ vi /cmd/newUserAddsBook_test.go

虽然测试文件的名称不必完全匹配,但使用匹配的名称会让 Godog 能够将测试与功能进行匹配。

创建好测试文件和代码后,我们再次执行 godog run。测试运行器将把场景标记为待处理(pending):

Background: Verify configuration
Given the BookSwap app is up # newUserSignsUp_test.go:148 -> theBookSwapAppIsUp
TODO: write pending definition
Scenario: Sign up # features/newUserSignsUp.feature:9
Given user details # newUserSignsUp_test.go:152 -> userDetails
When sent to the users endpoint # newUserSignsUp_test.go:144 -> sentToTheUsersEndpoint
Then a new user profile is created # newUserSignsUp_test.go:140 -> aNewUserProfileIsCreated

方便的是,输出中还打印了每个步骤的行号,显示了我们缺少的实现部分,这样我们就可以轻松找到需要实现的部分。

实现测试步骤

现在,Godog 已经为我们方便地生成了测试步骤的框架,我们开始根据 BookSwap 应用的功能编写测试代码。然而,正如在上一节中所述,我们需要在测试步骤之间传递信息。

在 Godog 中,传递信息的方式是通过链式上下文(chained contexts)。Godog 会在测试步骤之间传递上下文,允许我们以安全的方式在步骤之间传递信息。为了实现这一点,我们需要更改测试步骤的签名,使其接受一个上下文并返回一个上下文和一个错误:

func theBookSwapAppIsUp(ctx context.Context) (context.Context, error) {
    // test step implementation
}

测试步骤接受一个上下文并返回一个上下文和错误。Godog 会在底层正确处理这些返回值:将返回的上下文链式传递给后续的测试步骤,并在遇到非 nil 错误时使测试失败。

上下文复习

context 类型是 Go 标准库的一部分,其目的是携带截止日期、取消信号以及请求范围的变量。上下文应当在函数之间传播,从而将应用层级的函数调用与请求关联起来。创建一个新的上下文需要一个父上下文,然后取消信号会在子上下文链中传播。

对于我们的目的,我们将使用上下文来携带请求范围的变量。我们将创建一个新的 contextKey 自定义类型,它将携带我们在测试步骤之间传递所需的所有变量:

// contextKey 用于在测试步骤之间传递信息。
type contextKey struct {
    UsersURL string
    User     db.User
}

在我们的例子中,我们将传播 BookSwap 应用的 UsersURL 和已创建用户的期望值。在我们的背景步骤 theBookSwapAppIsUp 中,展示了如何使用上下文将信息传递给后续步骤:

func theBookSwapAppIsUp(ctx context.Context) (context.Context, error) {
    url, err := getTestURL()
    if err != nil {
        return ctx, fmt.Errorf("incorrect config:%v", err)
    }
    resp, err := http.Get(url)
    if err != nil || resp.StatusCode != http.StatusOK {
        return ctx, fmt.Errorf("bookswap not up:%v", err)
    }
    return context.WithValue(ctx, contextKey{}, contextKey{
        UsersURL: url + "/users",
    }), nil
}

这段代码演示了一个与 REST 端点交互的步骤的实现:

  1. 我们通过调用 getTestURL 辅助函数来设置我们将要测试的环境的 URL。这个函数根据应用程序指定的环境变量构造 URL。这样,我们就能方便地配置我们的测试,以便在不同的测试环境(本地或远程)中运行。如果您希望使用默认值,可以将 BOOKSWAP_BASE_URL 环境变量设置为 http://localhost,并将 BOOKSWAP_PORT 环境变量设置为 3000。

  2. 我们使用 http.Get 方法与定义的 URL 进行交互,并保存错误和响应。我们在之前的章节中已经熟悉了 net/http 库,测试中的使用与之前没有区别。

  3. 如果出现错误或状态码不是 200 OK,我们返回一个错误。这将使该步骤失败并结束测试。

  4. 最后,在成功的情况下,我们使用 context.WithValue 函数从 ctx 参数值创建一个子上下文,并传递一个填充了 UsersURLcontextKey。在后续步骤中,我们可以使用这个 URL 来进行请求。

另一项我们需要对生成的测试步骤进行的更改是重新排序步骤,以确保它们按照正确的顺序执行。这一步骤在没有使用 Godog 的情况下可能不太直观,但如果忘记了这一点,你的测试将失败,方便追踪错误位置。

运行测试套件

现在,一切实现完成后,是时候开始测试了。首先,我们需要记得启动 BookSwap 应用程序,可以使用以下命令运行:

docker compose -f docker-compose.book-swap.chapter06.yml up --build

除非你已经更改了配置,否则这将把应用程序暴露在 http://localhost:3000 URL 上。你可以通过执行一个 curl 命令来轻松验证应用程序是否在运行:

$ curl --location --request GET 'http://localhost:3000'
{"message":"Welcome to the BookSwap service!"}

如果你看到欢迎响应,则说明应用程序已启动并正确连接到数据库。

应用程序启动后,我们通过以下命令执行测试:

$ cd chapter06 && godog run

终端输出如下所示:

Feature: New user signs up
  In order to use the BookSwap application
  As a new user
  I need to be able to sign up.

  Background: Verify configuration
    Given the BookSwap app is up          # newUserSignsUp_test.go:23 -> theBookSwapAppIsUp
  Scenario: Sign up
    Given user details                    # newUserSignsUp_test.go:35 -> userDetails
    When sent to the users endpoint       # newUserSignsUp_test.go:50 -> sentToTheUsersEndpoint
    Then a new user profile is created    # newUserSignsUp_test.go:84 -> aNewUserProfileIsCreated

1 scenarios (1 passed)
4 steps (4 passed)
11.876996ms

从终端输出中可以看到,Godog 运行了一个场景,且四个步骤全部通过。你也可以使用 go test 命令运行测试,如果你不想安装 Godog CLI,但这不会格式化测试结果,如前面的输出所示。

我们成功地为扩展了持久化存储的 BookSwap 应用编写并运行了第一个 E2E 测试。这个测试是使用 Godog 开源测试库编写的,它允许我们编写易于阅读的 BDD 风格测试。我们离成为 Go 测试专家已经不远了。