通过集成测试补充单元测试

单元测试是小型、快速的测试,用于验证单个组件的行为。在 Go 中,待测试单元(UUT)通常是包,这些包暴露的 API 可以供这些快速测试进行验证。这些独立的单元组合在一起形成组件,组件是系统中可识别的部分。通常,组件有明确的职责,并提供一组相关的功能。组件的单元协同工作以实现组件的功能。

工程师在开发阶段非常依赖单元测试,它们是 TDD(测试驱动开发)的一个重要支柱,在 TDD 中,测试代码需要与实现代码一起编写。然而,单元测试也有一些局限性,这使得测试金字塔中其余的测试变得至关重要。因此,作为 TDD 实践者,我们不能仅仅专注于单元测试。

单元测试的局限性

由于其局限性,使用单元测试验证功能在工程界一直是一个辩论的话题。图 5.1 总结了单元测试的优缺点:

image 2025 01 04 17 20 58 318
Figure 1. Figure 5.1 – Advantages and disadvantages of unit testing

单元测试的优点:

  • 支持重构:单元测试使重构代码变得更加容易,因为它们可以快速验证现有功能。它们降低了修改代码时的风险,从而避免破坏现有功能。

  • 早期发现 bug:单元测试在开发阶段验证实现,在与现有产品集成并进行端到端测试之前,确保 bug 不会传播到其他团队或被意外发布。早期发现 bug 还可以缩短开发时间并减少项目成本。

  • 更容易调试:当测试的范围有限时,检测和修复错误变得更加容易。由于待测试单元(UUT)与其依赖项是分离测试的,我们知道任何失败的测试都是由测试设置或 UUT 的实现引起的。

  • 更好的代码设计:设计不良的代码很难编写测试,它可以提醒开发人员在何处需要重写或重构代码。实际上,单元测试促进了更好的代码设计,因为它们将测试的关注点带入了开发阶段。

  • 与实现并行的文档:单元测试作为组件功能和行为的详细文档。由于测试与代码一起存在于 Go 中,开发人员可以直接访问它,而无需使用其他文档系统。

单元测试的缺点:

  • 增加代码量:单元测试增加了开发人员必须编写的代码量。对于需要原型设计或没有明确要求的任务来说,这尤其成问题。开发人员不希望编写大量代码,然后随着实现的变化而需要修改这些代码。

  • 增加重构工作量:虽然单元测试确保重构不会破坏任何现有功能,从而避免回归,但如果需求发生变化,测试本身也必须进行重构。这会增加重构的成本。

  • 难以识别真实场景:随着代码库的增长和功能的复杂化,测试组件的所有执行路径将变得困难,甚至不可能。然而,由于单元测试是基于代码而不是用户需求编写的,因此开发人员可能很难识别哪些场景是现实的并应予以覆盖。

  • 测试用户界面(UI)的困难:使用单元测试测试 UI 是困难的。通常,单元测试验证的是业务逻辑,因为传统上没有可用于 UI 验证的库。

集成测试是对单元测试的良好补充,它们解决了单元测试的一些缺点和局限性。接下来,我们将学习如何为我们的 Go 包实现和运行集成测试。

单元测试被认为是良好的实践

尽管单元测试存在一些缺点,但社区的共识是,它们应作为开发实践的一部分。理解它们的局限性有助于我们明确系统验证过程中需要覆盖的其他测试需求。

实现集成测试

集成测试和端到端测试经常被交替使用,但它们在测试金字塔中各自有不同的范围和目的。图 5.2 描述了测试金字塔,并突出了集成测试与端到端测试之间在范围和速度上的差异:

image 2025 01 04 17 25 35 368
Figure 2. Figure 5.2 – The distinction between integration and end-to-end tests

集成测试和端到端测试之间的速度差异是由于它们所涵盖的功能不同:

  • 集成测试 覆盖一个或多个组件,确保各个组件作为一个整体能够良好地协作。虽然特定组件的逻辑通过单元测试进行验证,但集成测试的目的是在组件之间的接口处验证它们的协同工作条件。

  • 端到端测试 模拟用户对系统的使用。这些测试需要启动被测试系统的所有服务和依赖项。然后,使用辅助框架编写模拟用户行为的测试。这些测试验证系统在现实条件下是否正常工作。

那么,既然端到端测试涵盖的功能比集成测试更多,并且可以自动化,为什么我们还需要实施集成测试呢?图 5.3 描述了端到端测试的一些缺点以及集成测试如何解决这些问题:

image 2025 01 04 17 26 04 846
Figure 3. Figure 5.3 – Challenges of end-to-end tests

测试金字塔中的所有测试都是相辅相成的,互相弥补彼此的不足。特别是,集成测试和端到端测试是协同工作的:

  • 通常,端到端测试在开发过程的最后阶段进行,系统相对稳定并可以进行端到端的测试。另一方面,集成测试可以在各个独立组件准备好后尽早执行,这样可以缩短反馈周期,帮助开发人员更早发现项目中的 bug。

  • 由于端到端测试需要更多的设置和资源,它们执行起来较慢,可能还会比较昂贵。因此,工程师可能只在发布时运行它们,而不是在每次提交代码时执行。而集成测试则需要更少的设置,因此它们运行更快、更便宜。集成测试通常包含在代码提交检查中。

  • 如前所述,端到端测试的重点是验证用户在现实场景中的使用流程和体验。另一方面,集成测试专注于验证内部和外部模块的集成,涵盖各种场景,例如负面测试和部分故障等。这些情况在端到端测试中很难设置,因为端到端测试需要配置整个系统。

集成测试的实现与单元测试相同

我们使用相同的机制来编写集成测试。我们使用初始化函数、模拟和表格测试来编写测试,只是这些测试的范围更大。此外,集成测试的测试签名与单元测试相同。

集成测试的设置比单元测试稍微复杂一些,因为需要配置和启动多个组件,其中一些可能是外部组件。图 5.4 展示了我们可能使用的技术和配置的典型示例:

image 2025 01 04 17 28 43 208
Figure 4. Figure 5.4 – Example configuration of integration tests

集成测试中需要配置的各个部分如下:

  • 被测试组件 部分初始化。被测试组件比单元测试组件(UUT)大,但它仍然是自包含的,并且在一个模块内定义。集成测试的范围是确保多个单元能够按预期工作,但它们始终包含在被测试的单个模块内。

  • 如果需要,我们初始化 数据库组件,并提供包含其中的测试数据的种子/起始位置。由于数据库较为复杂,通常不会模拟数据库,而是会在被测试组件启动之前启动并填充数据。数据库的启动位置通常指定为 SQL 文件或 JSON 文件。

  • Docker 使得将真实组件配置在一起变得更容易,通常用于系统配置。在本章的“使用 Docker 启动和销毁环境”部分,我们将详细了解如何利用 Docker 的强大功能。

  • 被测试组件通常需要依赖项才能正确启动和运行。这些依赖项可能是项目内部的,也可能是组织的外部依赖项,例如第三方服务。对于这些外部依赖项,我们将进行模拟,从而允许我们在各种输入和条件下测试我们的组件。

让我们看一个针对 BookSwap 应用程序的集成测试示例,这是我们在第 4 章《构建高效的测试套件》中介绍的应用程序。我们将为 GET / 端点编写一个集成测试,该端点将返回一个欢迎信息和可用书籍的列表。通过这个示例,我们还可以进一步探讨如何测试 Web 应用程序。

处理此请求的 HTTP 处理器相对简单:

// Handler 包含处理器及其所有依赖项。
type Handler struct {
    bs *db.BookService
    us *db.UserService
}

// Index 被 HTTP GET / 调用。
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
    // 发送 HTTP 状态和一个硬编码的消息
    resp := &Response{
        Message: "欢迎来到 BookSwap 服务!",
        Books: h.bs.List(),
    }
    writeResponse(w, http.StatusOK, resp)
}

Handler 的实现突出了以下几个实现细节:

  • 我们创建了一个自定义的 Handler 类型,并为其提供了所有必需的依赖项。在 BookSwap 应用程序中,我们保存了一个 BookService 实例和一个 UserService 实例。

  • 处理器为它服务的每个端点创建了一个方法。我们创建了一个处理器方法,它接受 ResponseWriterRequest 参数。这种签名是典型的 http.HandlerFunc,它是一个适配器,允许使用 Go 函数作为 HTTP 处理器。

  • 我们调用了 BookServiceList 函数来获取书籍列表,并构造了一个响应。然后,这个自定义的响应被写入 ResponseWriter,这使得我们可以轻松地将 Go 结构体反序列化为 HTTP 响应。

我们的处理器代码设置非常直接,并且与您将为 HTTP 响应编写的代码类似。那么,如何进行测试呢?我们可以单元测试 BookService,确保它能够正常工作,但我们还需要测试处理器构造的响应是否符合预期。

现在,是时候编写我们的第一个集成测试了。

Go 标准库提供了 httptest 包( https://pkg.go.dev/net/http/httptest ),该包使我们能够轻松测试 HTTP 处理程序和客户端。这个包包含以下功能:

  • 使用 httptest.Server 类型启动一个特定的 http.HandlerFunc 服务器。

  • 使用 httptest.NewRequest 函数创建传递给处理程序的传入请求。

  • 使用 httptest.ResponseRecorder 类型记录响应,以便在测试代码中进行断言。该记录器符合 http.ResponseWriter 类型,并且可以在处理程序代码中替代使用。

下面是一个简单的集成测试示例,测试我们的 GET / HTTP 处理程序:

func TestIndexIntegration(t *testing.T) {
    // Arrange
    book := db.Book{
        ID:     uuid.New().String(),
        Name:   "我的第一次集成测试",
        Status: db.Available.String(),
    }
    bs := db.NewBookService([]db.Book{book}, nil)
    h := handlers.NewHandler(bs, nil)
    svr := httptest.NewServer(http.HandlerFunc(h.Index))
    defer svr.Close()

    // Act
    r, err := http.Get(svr.URL)

    // Assert
    require.Nil(t, err)
    assert.Equal(t, http.StatusOK, r.StatusCode)
    body, err := io.ReadAll(r.Body)
    r.Body.Close()
    require.Nil(t, err)
    var resp handlers.Response
    err = json.Unmarshal(body, &resp)
    require.Nil(t, err)
    assert.Equal(t, 1, len(resp.Books))
    assert.Contains(t, resp.Books, book)
}

TestIndexIntegration 测试相对简单,因为它不需要复杂的请求构建或响应验证:

  1. 测试的签名和其他单元测试一样,采用 Test 前缀,并接受一个类型为 *testing.T 的参数。

  2. 接下来,我们创建一个包含单本书籍的 BookService 实例,作为测试的起始数据。测试的目的是确保 BookService 与其处理程序集成,并返回预期的响应。

  3. 我们使用实例化的 BookService 创建一个新的处理程序。然后,我们将该处理程序传递给 httptest.NewServer 函数,这样就会创建并启动一个服务器实例来处理我们的请求。我们使用 defer 关键字在测试结束时关闭该服务器。这是测试的 Arrange(安排)部分。

  4. 测试的 Act(执行)部分非常简单。我们使用 http.Get 方法访问服务器的 URL。这是客户端将使用的相同方法,且测试并未意识到它正在调用一个特殊的、模拟的服务器。

  5. 最后,在测试的 Assert(断言)部分,我们对响应和可能出现的错误进行断言。我们验证没有错误返回,并且响应状态码是 200 OK。

  6. 然后,我们读取响应体并将其解组为自定义的响应类型。这使我们可以更方便地验证响应内容,但我们也可以将响应体的内容作为字符串进行验证。

  7. 最后一个断言验证在 Arrange 部分创建的书籍实例是否包含在自定义响应中。测试结束后,defer 调用的服务器 Close 函数会被执行,清理测试中设置的服务器资源。

httptest 包使我们能够无缝地验证 HTTP 处理程序的行为,并使用与客户端相同的库和函数进行集成测试。这使得我们能够编写强大的集成测试。

运行集成测试

集成测试可以像我们之前运行的其他单元测试一样运行——使用 go test 命令:

$ go test -run TestIndexIntegration ./chapter05/handlers -v
=== RUN TestIndexIntegration
--- PASS: TestIndexIntegration (1.712s)
PASS
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter05/handlers 1.712s

由于该集成测试的签名与单元测试相同,因此它能够成功运行。然而,请注意,在我的机器上,这个集成测试大约需要 2 秒钟才能完成。这个时间是一次特定测试运行的测量,甚至有时候,运行这样一个简单的 GET 请求,可能高达 4 秒钟。当某个应用的集成测试数量增加时,它们可能会显著拖慢我们的测试套件的运行速度,即使我们使用了 t.Parallel() 来并行运行测试,正如我们在第 4 章《构建高效的测试套件》中所学习的那样。

因此,最好将单元测试和较慢的集成测试分开。我们可以在所有提交时运行单元测试,而在代码发布时运行集成测试。尽管没有内置的完美方法来标识哪些是集成测试,但我们可以探索几种方法。

短模式

go test 命令有一个内置的 -short 标志,可以通过 testing.Short() 函数来访问。这个标志允许我们通过在测试代码中添加一个小的代码片段来标记长时间运行的测试,以跳过它们:

func TestIndexIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping TestIndexIntegration in short mode.")
    }
    // testing code continues
}

t.Skip 方法将确保在短模式下跳过这个长时间运行的测试。我们可以通过在测试命令中添加 –short 标志来运行短模式:

$ go test -run TestIndexIntegration ./chapter05/handlers -v -short
=== RUN TestIndexIntegration
handlers_test.go:19: Skipping TestIndexIntegration in short mode.
--- SKIP: TestIndexIntegration (0.00s)
PASS

如预期所示,长时间运行的测试被跳过。

这种方法的主要缺点是,它需要用户具有特殊的知识来实现快速运行的测试套件,而这本应是默认行为。没有内置的 –long 标志,来执行所有(包括长时间运行的)测试。

命名约定

另一种方法是使用命名约定,这不需要在任何测试中添加特殊的代码功能。例如,您可以与团队达成一致,约定单元测试以 Unit 后缀结尾,集成测试以 Integration 后缀结尾。根据文件的长度和内容,我们可以创建单元测试和集成测试的独立文件。单元测试和集成测试可以使用专用的测试包,名称以 _test 结尾,从而保持源代码和测试代码之间的依赖关系分离。

然后,我们可以使用 –run 标志,正如我们在第 2 章《单元测试基础》中所探讨的那样,指示测试运行器根据名称运行某些子集的测试。我们可以使用以下命令运行所有单元测试:

go test -run Unit ./...

该命令会递归遍历文件夹,查找包含 Unit 的任何测试。类似地,可以使用以下命令运行集成测试:

go test -v -run Integration ./...

不幸的是,这种方法与短模式有相同的主要缺点——在没有 –run 标志的情况下运行默认的 go test 命令,会导致所有的测试运行,包括较慢的集成测试。

环境变量

最后一种选择是创建一个环境变量,来弥补缺乏相应标志的不足。我们仍然需要在测试中添加一个简短的代码片段来验证此环境变量:

func TestIndexIntegration(t *testing.T) {
    if os.Getenv("LONG") == "" {
        t.Skip("Skipping TestIndexIntegration in short mode.")
    }
    // testing code continues
}

我们使用 os.Getenv 方法来读取环境变量,如果没有定义该变量,它将返回空值。如果该变量为空,我们就跳过集成测试,从而允许我们的测试套件在默认情况下只运行快速的测试,跳过集成测试。

运行集成测试非常简单:

$ LONG=true go test -run TestIndexIntegration ./chapter05/handlers -v
=== RUN TestIndexIntegration
--- PASS: TestIndexIntegration (0.00s)
PASS
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter05/handlers 0.779s

请注意,这个命令只在 CMD 终端中有效。或者,您可以在终端中将 LONG 环境变量设置为 true,然后单独运行上述 go test 命令。

我们将在接下来的部分中使用环境变量解决方案。测试套件的预期默认行为是只运行快速的单元测试。这个解决方案使我们可以保持对默认行为的专用知识的隔离,并且使得在需要时运行集成测试变得更加容易。此外,它还与容器化技术(如 Docker)很好地集成,我们将在本章后续部分进行探讨。