使用 testing 包

标准库提供了 testing 包,它包含了编写和运行测试所需的基本功能。在本节中,我们将探索如何使用它,并开始应用它,以便为我们的简单终端计算器示例编写测试。

testing 包

testing 包提供了支持测试 Go 代码的功能。所有测试代码都必须导入这个包,因为它是与测试运行器交互的唯一方式。乍一看,testing 包似乎非常简洁,但它与 Go 的语言设计相符合。Go 的包应该是小型的、专注的,并且依赖项有限,这使得它们易于测试,并且可以使用相对简单的测试库。

以下是我们将使用的一些重要类型:

  • testing.T:所有测试必须使用这个类型与测试运行器交互。它包含用于声明失败的测试、跳过测试以及并行运行测试的方法。在本节中,我们将查看并开始使用这些方法。

  • testing.B:类似于测试运行器,这个类型是 Go 的基准测试运行器。它具有与 testing.T 相同的方法,用于失败的测试、跳过测试和并行运行基准测试。基准测试是用于验证代码性能的特殊测试,而不是其功能。我们将在本章稍后探讨基准测试。

  • testing.F:此类型用于设置和运行模糊测试(fuzz tests),并且是在 Go 1.18 中加入 Go 标准工具链的。它为测试目标创建一个随机种子,并与 testing.T 类型一起提供测试运行功能。模糊测试是一种特殊的测试,使用随机输入来查找代码中的边缘情况和错误。我们将在第 10 章《测试边缘情况》中进一步探索模糊测试。

testing 包在所有测试中使用

testing 包必须在所有测试中导入,因为它是与 Go 的测试运行器交互的唯一方式。如前所述,测试文件必须以 _test.go 结尾,但只有当它们使用 testing 包时,测试才会被运行。测试还必须满足标准的测试签名,我们将在下一节《使用 testing 包》详细说明。

现在,让我们更详细地看看 testing.T 类型,因为它将是我们本章探索的重点。图 2.5 总结了我们将讨论的一些方法:

image 2025 01 02 11 02 18 906
Figure 1. Figure 2.5 – The logging, failing, and skipping methods of the testing.T type

它暴露了以下用于日志记录、跳过和标记失败的测试的方法,这些方法非常重要:

  • t.Log(args):在测试执行结束后,将给定的参数打印到错误日志。

  • t.Logf(format, args):与 t.Log 方法相同,但允许在打印之前格式化参数。

  • t.Fail():将当前测试标记为失败,但会继续执行直到结束。

  • t.FailNow():将当前测试标记为失败,并立即停止当前测试的执行。下一个测试将在继续测试套件时运行。

  • t.Error(args):相当于调用 t.Log(args)t.Fail()。这个方法便于在错误日志中记录错误并标记当前测试失败。

  • t.Errorf(format, args):相当于调用 t.Logf(format, args)t.Fail()。这个方法便于失败测试后格式化并打印错误信息。

  • t.Fatal(args):相当于调用 t.Log(args)t.FailNow()。这个方法便于在一行代码中标记测试失败并打印错误信息。

  • t.Fatalf(format, args):相当于调用 t.Logf(format, args)t.FailNow()。这个方法便于在一行代码中标记测试失败并格式化打印错误信息。

  • t.SkipNow():将当前测试标记为跳过,并立即停止它的执行。请注意,如果测试已经标记为失败,它将仍然是失败的,而不是跳过的。

  • t.Skip(args):相当于调用 t.Log(args),然后调用 t.SkipNow()。这个方法便于跳过测试并打印错误信息。

  • T.Skipf(format, args):相当于调用 t.Logf(format, args),然后调用 t.SkipNow()。这个方法便于跳过测试后格式化并打印错误信息。

通常,开发人员在编写测试时会使用前面介绍的便捷方法,而不是显式调用 t.Fail()t.FailNow()t.SkipNow()。接下来,在编写测试代码时,我们将利用这些方法。

你可能会好奇,testing 包是否提供任何内建的断言功能。答案是没有,它不提供内建的断言功能。因此,我们需要自己比较值。在第 3 章《模拟与断言框架》中,我们将进一步探讨第三方断言库。

测试签名

testing 包用于编写单元测试,这些测试被放置在各自的测试文件中。Go 测试是满足以下签名的函数:

func TestName(t *testing.T) {
    // implementation
}

这个测试签名突出了 Go 测试的以下要求:

  • 测试是导出的函数,其名称以 Test 开头。

  • 测试名称可以有一个额外的后缀,指定测试覆盖的内容。这个后缀也必须以大写字母开头,正如我们在测试签名中的 Name 所示,它同时也是测试的名称。

  • 测试必须接受一个 *testing.T 类型的单一参数。如前所述,这是与测试运行器交互的方式。你可以将测试参数命名为任何名字,但 Go 开发者通常使用 t 来表示它。

  • 测试不能有返回值。

Go 测试只是函数

正如我们所看到的,测试只是满足某种签名的函数。Go 测试工具会扫描代码库中的 _test.go 文件,查找这些特殊函数并相应地运行它们。

在这些测试函数内部,我们可以使用 AAAArrangeActAssert)模式来定义和实现测试代码。你应该将测试的范围保持得很小,优先编写多个测试,而不是编写一个大的、可能脆弱的测试。

和包名一样,测试名称也非常重要,因此我们需要特别考虑它们。有效命名的测试可以为开发者带来一些重要的优势:

  • 文档性和理解:一组有效命名的测试将帮助新手理解某一段代码应该如何工作。由于它们容易修改,因此也允许你在不同条件下探索代码的行为。

  • 支持重构:测试名称确定了测试的意图,测试实现只是执行这个意图。一旦代码被重构,测试实现可能会改变,但测试的意图通过名称传达出来,保持不变。命名得当的测试可以支持代码重构,而重构可能需要改变测试的实现/执行。我们将在第 7 章《Go 中的重构》进一步讨论代码重构策略。

  • 一致性:为代码库中的测试命名和结构设置一个标准,将使你更容易预期测试的内容,从而减少阅读代码时的认知负担。

除了我们刚刚看到的特殊签名,Go 不强制执行其他的命名标准。Go 社区的共识是,测试应该简洁且易于理解。Go 标准库将测试与它们测试的函数(即被测试单元,Unit Under Test,UUT)联系起来:测试名称简单地遵循 TestUnitUnderTest 的结构。例如,测试 Add 函数的测试将命名为 TestAdd

另一种常见的命名方法是使用 行为驱动开发(BDD) 风格的命名方式。我们将在第 5 章《执行集成测试》中详细探讨 BDD 测试。在这种命名方式中,测试的名称遵循 TestUnitUnderTest_PreconditionsOrInputs_ExpectedOutput 的结构。例如,测试两个负数相加的结果的测试名称将是 TestAdd_TwoNegativeNumbers_NegativeResults

虽然 BDD 风格的命名模式更加精确,但它违背了 Go 中简洁性和简明性这一核心原则。因此,我们将使用更简单的方式:以被测试单元的名称命名测试。我们将在本章后面看到如何使用子测试(subtests)来实现对前提条件和预期输出的额外精确描述。

运行测试

Go 工具链的命令之一是 go test 命令。我们之前提到过它是 Go 的测试运行器,且我们将使用它来执行测试。在本节中,我们将详细探讨如何使用该命令。

_test.go 文件中,测试运行器会特别处理三种类型的函数:

  • 测试函数,名称以 Test 开头。我们在本节中已经详细讨论了测试函数。

  • 基准测试函数,名称以 Benchmark 开头。我们将在本章《测试与基准测试的区别》一节中讨论这些。

  • 示例函数,名称以 Example 开头。这些函数不在本书讨论的范围内。

测试运行器会查找以 _test.go 结尾的文件,将它们构建为独立的包,并将它们链接到测试二进制文件中。

go test 命令的输出将打印执行的所有测试的失败信息到标准输出。如果你加上 -v 参数(即 "verbose" 的缩写),它会打印所有测试的名称和执行时间,包括通过的测试。

测试会按照字典顺序执行。以下是我们 engine_test.go 文件中的输出,里面包含了计算器操作的测试(实现于 engine.go 中):

$ go test ./...
=== RUN TestAdd
engine_test.go:7: Add(2,3) incorrect, got: 2, want: 5.
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.278s

测试失败会在输出中标记为 FAIL,任何错误信息都会打印到标准输出。在我们的例子中,TestAdd 测试失败了。如前节所示,我们可以使用 testing.T 类型中的多种方法打印信息错误消息并标记测试失败。

在测试输出的末尾,我们可以看到整个测试运行的结果,以及它运行所花费的时间。我们还可以看到每个测试的运行时间。

测试运行器支持两种运行模式:

  1. 本地目录模式:当命令没有指定包时,它会构建并运行当前目录下的所有测试。这就是我们之前使用 go test -v 命令的方式。

  2. 包列表模式:当命令指定了包参数时,它会构建并运行所有匹配指定包的测试。这通常在大型项目中使用,因为切换目录并在每个目录中运行测试可能会很麻烦。在这种情况下,开发人员通常会使用包列表模式。

我们可以通过以下方式轻松指定要运行的测试:

  • 指定包名:例如,go test engine_test 将运行来自 engine_test 包的测试,无论当前在哪个项目目录下执行。

  • 使用表达式作为包标识符:例如,go test ./…​ 将运行项目中的所有测试,无论从哪个目录运行。

  • 指定子目录路径:例如,go test ./chapter02 将只运行当前路径下 chapter02 子目录中的所有测试,但不会遍历更深的嵌套目录。

  • 结合 -run 标志使用正则表达式:例如,go test -run "^engine" 将运行所有以 engine 开头的包。你也可以在指定测试名称时提供子目录路径。

  • 指定测试名称,结合 -run 标志:例如,go test -run TestAdd 只会运行指定的 TestAdd 测试。你也可以在指定测试名称时提供子目录路径。

Go 测试运行器可以缓存成功的测试结果,以避免在代码未更改的情况下浪费资源重新运行测试。默认情况下,在本地目录模式下,缓存是禁用的,但在包列表模式下,缓存是启用的。

如你所见,当运行 go test -v ./…​ 命令(触发包列表模式)时,成功的测试结果会在相应的输出行中标记为 (cached)

$ go test -v ./...
=== RUN TestAdd
engine_test.go:7: Add(2,3) incorrect, got: 2, want: 5.
--- FAIL: TestAdd (0.00s)
FAIL
FAIL github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.112s
=== RUN TestParser
--- PASS: TestParser (0.00s)
PASS
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/input (cached)
FAIL

请注意,只有成功运行的测试结果才能被缓存。测试失败会每次运行,直到它们通过为止,届时才会缓存结果。

编写测试

到目前为止,我们已经探讨了包的结构、测试文件在结构中的位置,以及了解了 Go 的测试包和测试签名。现在,让我们将所学的知识应用起来。

有了我们对 Go 中测试工作原理的了解,我们可以扩展红、绿、重构方法,并增加更具体的步骤。图 2.6 展示了 Go 中 TDD 扩展后的流程,用于实现新功能:

image 2025 01 02 11 04 57 192
Figure 2. Figure 2.6 – Expanded TDD flow in Go

我们可以通过以下步骤描述编写测试的过程:

  1. 创建测试文件和测试包:首先,创建一个新目录来存放文件和包。然后,在这个新目录中放置一个与将要实现的测试相对应的 _test.go 文件。这样,你就有了一个可以开始编写测试代码的地方,并且可以将其与实现代码放在一起。正如之前所提到的,虽然 Go 不强制要求使用外部测试包,但你应该尽可能使用它。

  2. 创建源代码文件和包:在同一个目录中,创建源代码文件,并在文件顶部声明包名,以确保空的 .go 文件会立即被编译。在这一点上,你开始考虑代码的结构,正如我们在本章开头讨论的简单终端计算器一样。

  3. 为新功能编写测试签名:在测试文件中,你可以创建新的测试,名称应与将要测试的目标单元(UUT)相对应。测试签名还需要你导入 testing 包,为编写测试代码做好准备。

  4. 编写 UUT 的定义:在你之前空白的源代码文件中,编写自定义类型、方法和函数的定义,目的是进行测试。这将帮助你确定 UUT 的签名或 API,然后根据此结构化测试。正如我们在简单终端计算器中所做的那样,根据方法的签名返回空值或零值,以确保代码可以编译。

  5. 设置测试场景:从最简单的测试用例开始,使用 AAA(Arrange, Act, Assert)方法编写测试,调用你之前定义的 UUT 签名。这就是为什么我们创建了签名并返回虚拟值以确保代码能够编译的原因。

  6. 运行测试并观察失败:此时,你的代码应该已经可以完全编译,因此你可以运行测试并观察新代码失败的情况。为了加速反馈,你可以在新包的目录下运行 go test 命令。

  7. 实现你的测试场景所需的功能:回到你的 UUT,编写足够的代码来满足你最新的测试场景。这将要求你修改一些虚拟代码。

  8. 运行测试并观察通过:此时,你的代码应该已经可以完全编译,你可以运行 go test 命令。你新的测试应该会通过。

  9. 重构你最新的测试和代码:查看你是否可以改进源代码和测试代码。改进应当是频繁且小步的,因此确保你花时间检查代码。

  10. 再次运行测试以确保通过:在重构之后,你的测试应该继续通过。

  11. 根据需要重复这些步骤,直到所有功能实现:你将根据需要定义 UUT 的签名和测试,从最简单的功能开始,逐步推进。

如预期的那样,TDD 过程需要频繁地在源代码和测试代码之间切换。测试运行应该始终先失败,然后随着源代码的实现和重构而通过。

用例 - 实现计算器引擎

让我们使用已经建立的工作实践,编写并实现我们将在 engine.go 中定义的计算器功能。这也将使我们能够通过实践更好地了解 Go 的测试包。

步骤 1 – 创建测试文件和测试包

正如我们在图 2.2 中看到的,我们将创建一个名为 calculator 的目录,并在其中放置相应的计算器引擎文件。

我们将创建 engine_test.go 测试文件,并声明外部的 calculator_test 包:

package calculator_test

此时,测试文件仅包含一行代码,并且没有编译错误。

步骤 2 – 创建源代码文件和包

在同一个目录下,我们必须创建 engine.go 文件,并声明 calculator 包,这与已声明的外部测试包相匹配:

package calculator

此时,源代码文件也仅包含一行代码,并且没有编译错误。

步骤 3 – 为新功能编写测试签名

我们将从测试并实现计算器的加法功能开始,因为这是最简单的功能。在 engine_test.go 文件中,添加一个新的测试函数,符合 Go 测试签名,并导入 testing 包:

package calculator_test

import "testing"

func TestAdd(t *testing.T) {

}

正如其名称和包所示,我们将测试 calculator 包中的 Add 函数或方法。仅通过这些几行代码,我们已经可以清晰地了解这个测试将要覆盖的内容。这是一个非常强大的机制。

步骤 4 – 编写 UUT 的定义

在这里,我们将向源代码文件添加 UUT 的存根定义,以便我们可以在新编写的代码中引用它们。虽然这有点偏离 “没有相应测试代码不写代码” 的原则,但它将使我们能够在任何代码编辑器中轻松地引用代码并进行测试。在 engine.go 文件中,我们必须为 Engine 自定义类型和 Add 方法添加存根:

package calculator

type Engine struct {}

func(e *Engine) Add(x, y float64) float64 {
    return 0
}

我们返回一个虚拟值 0,以确保代码继续编译。

步骤 5 – 设置测试场景

回到测试代码中,我们将为 TestAdd 函数添加一个简单的测试场景。此时,TestAdd 仍然是空的。在 engine_test.go 文件中,我们将使用 AAA(Arrange、Act、Assert)模式编写测试代码,并为每个步骤添加注释:

package calculator_test

import (
    "testing"
    "github.com/PacktPublishing/Test-Driven-Development-in-Go/chapter02/calculator"
)

func TestAdd(t *testing.T) {
    // Arrange
    e := calculator.Engine{}

    // Act
    got := e.Add(2.5, 3.5)

    // Assert
    if got != 6.0 {
        t.Errorf("Add(%.2f,%.2f) incorrect, got: %.2f, want: %.2f", 2.5, 3.5, got, 6.0)
    }
}

Arrange 步骤中,TestAdd 创建了 calculator.Engine 的实例,这需要在测试文件中导入 calculator 包。

Act 步骤中,我们调用 Add 方法,传入两个输入值。

最后,在 Assert 步骤中,我们通过 if 语句比较实际值和预期值,并使用 t.Errorf 调用测试失败(如果它们不匹配)。

步骤 6 – 运行测试并观察它失败

从一个失败的测试开始是 TDD 方法论中的重要步骤,因为它确保了我们的测试确实被执行,并且不会错误地通过。我们可以运行我们的测试:

$ go test -run TestAdd ./chapter02/calculator -v
--- FAIL: TestAdd (0.00s)
engine_test.go:20: Add(2.50,3.50) incorrect, got: 0.00, want: 6.00
FAIL
exit status 1
FAIL github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.198s

测试失败了,我们的错误信息被打印到终端。这是预期的,因为我们的 Add 方法当前只返回了虚拟值 0。至此,我们完成了 TDD 中红色阶段的工作。

步骤 7 – 实现测试场景所需的功能

在失败的 TestAdd 测试存在的情况下,现在是时候实现使其通过的功能了。在 engine.go 文件中,我们必须修改 Add 方法,去除返回虚拟值的部分:

func(e *Engine) Add(x, y float64) float64 {
    return x + y
}

Add 方法现在将使用输入参数,并返回它们的加和。

步骤 8 – 运行测试并观察它通过

就像步骤 6 中那样,我们运行测试并观察它是否通过:

$ go test -run TestAdd ./chapter02/calculator -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.109s

测试现在通过了,我们已经验证了我们的代码满足了测试的要求。至此,我们完成了 TDD 中绿色阶段的工作。

步骤 9 – 重构最新的测试和代码

此步骤并不是每次都必需的。我们可以通过提取变量来改进我们的测试代码,这样可以清理代码,避免重复硬编码的值:

func TestAdd(t *testing.T) {
    // Arrange
    e := calculator.Engine{}
    x, y := 2.5, 3.5
    want := 6.0

    // Act
    got := e.Add(x, y)

    // Assert
    if got != want {
        t.Errorf("Add(%.2f,%.2f) incorrect, got: %.2f, want: %.2f", x, y, got, want)
    }
}

Arrange 部分,我们现在声明了三个变量,用于存储输入值和期望的输出值。我们在整个测试中都使用这些变量,将它们传递给 UUT,并将其用于格式化的错误信息。

步骤 10 – 重新运行测试并确保它通过

就像步骤 8 一样,我们必须重新运行测试,以确保重构后没有破坏任何已实现的功能:

$ go test -run TestAdd ./chapter02/calculator -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.196s

测试现在通过了,我们已经验证了我们的重构没有破坏任何实现的功能。至此,我们完成了 TDD 中重构阶段的工作。

这些步骤现在可以重复应用于简单终端计算器的所有其他操作。你可以继续实现它们,这将帮助你练习在 Go 中使用 TDD。接下来,我们将探讨如何通过测试设置和拆卸来简化我们的测试编写过程。