测试的设置(setup)和拆卸(teardown)

我们通过利用外部测试包和 testing.T 类型编写了第一个测试和源代码示例。在这个简单的例子中,这种方法效果很好,但随着我们编写更多的测试,重复相同的测试设置和清理工作可能会变得繁琐。在本节中,我们将探讨 testing 包提供的功能,以简化这一过程。

TestMain 方法

Go 1.4 引入了一个名为 TestMain 的特殊测试功能。这一特性虽然常常未被充分利用,但它在设置(setup)和拆卸(teardown)代码方面为我们提供了极大的灵活性。该测试函数的签名如下:

func TestMain(m *testing.M) {
    // 实现
}

与其他测试不同,这个测试的名称是固定的,并且它接收的唯一参数是 *testing.M 类型,而不是像其他测试那样接收 *testing.T 类型。一旦你重写了这个方法,其中的代码将为你提供更多的控制权,来决定如何运行你的测试。TestMain 方法会在该包中的其他所有测试运行之前执行。

每个包只能有一个 TestMain 函数

由于包内的名称需要唯一,所以每个包只能定义一个 TestMain 函数。你应该注意,这个方法会控制该包中所有测试的执行,而不仅仅是某个文件中的测试。

testing.M 类型比 testing.T 类型要小,且它暴露了一个名为 Run() 的方法,允许我们运行该包中的所有测试,并返回一个退出码。

这个函数的使用非常简单,示例如下:

func TestMain(m *testing.M) {
    // 设置代码
    setup()
    // 运行测试
    e := m.Run()
    // 清理代码
    teardown()
    // 报告退出码
    os.Exit(e)
}

上面的代码示例概述了一个简单的步骤:

  1. 声明特殊的 TestMain 签名:在测试文件中编写正确的名称和签名。通常,应该将此定义放在文件的最顶部。

  2. 编写设置代码:在 TestMain 的主体内编写设置代码。建议编写一个单独的 setup() 函数,并在 TestMain 中调用它,而不是直接在测试函数内编写代码。这有助于提高测试文件的可读性。这些语句会在执行测试之前运行。

  3. 调用 Run() 函数:编写完设置代码后,调用 m.Run() 并将返回的退出值保存到变量中,在示例代码中是 e。此时,测试将会运行,退出值将报告测试是否失败。

  4. 编写清理代码:与设置代码一样,在调用 Run() 方法后编写清理代码。我也建议创建一个单独的 teardown() 函数,而不是直接在 TestMain 代码块中编写代码。这些语句将在测试执行之后运行。

  5. 报告退出值:这一点非常重要,它允许我们将测试失败情况传递给测试运行器。应该将 Run() 方法返回的退出码传递给 os.Exit() 函数。如果你忘记添加这部分代码,测试运行器可能会报告虚假的成功结果。

我们在计算器示例中实现相同的步骤,在 engine_test.go 文件中定义 TestMain 函数和 TestAdd 测试:

func TestMain(m *testing.M) {
    // 设置语句
    setup()
    // 运行测试
    e := m.Run()
    // 清理语句
    teardown()
    // 报告退出码
    os.Exit(e)
}

func setup() {
    log.Println("Setting up.")
}

func teardown() {
    log.Println("Tearing down.")
}

setup()teardown() 函数仅仅是打印两行日志到终端。运行测试后,我们看到以下输出:

$ go test -run TestAdd ./chapter02/calculator -v
2022/08/14 11:02:51 Setting up.
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
2022/08/14 11:02:51 Tearing down.
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.345s

从测试输出中可以看出,设置和清理日志行被打印在测试运行输出的前后,分别在之前和之后执行。

init 函数

另一个确保测试设置正确运行的选项是使用 init 函数。在单元测试中,通常不需要清理逻辑,只需要设置逻辑。在这种情况下,如果你只想确保在测试之前执行一些逻辑,可以选择比 TestMain 更简便的方法。

TestMain 方法不同,init 函数并不专门限制于测试代码。init 函数的签名如下:

func init() {
    // 实现
}

init 函数的名称是固定的,并且它不接受任何参数。该函数将在任何 main 函数之前调用,无论该 main 函数是在源代码中,还是在特殊的测试运行器主函数中。

每个包可以有多个 init 函数

与其他名称不同,每个包允许有多个 init 函数。然而,你应该注意它们都会在主运行器之前被调用。当多个 init 函数在同一文件中定义时,它们按定义顺序运行。另一方面,当它们在多个文件中定义时,它们会按文件名的字母顺序运行

我们将在 engine_test.go 文件中定义一个 init 函数,同时使用 TestMainTestAdd 测试:

func init() {
    log.Println("Init setup.")
}

init() 函数只是简单地打印另一行日志。在终端运行测试时,我们看到以下输出:

$ go test -run TestAdd ./chapter02/calculator -v
2022/08/14 11:57:38 Init setup.
2022/08/14 11:57:38 Setting up.
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
2022/08/14 11:57:38 Tearing down.
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.252s

从测试运行的输出中可以看到,init 设置在 TestMain 设置之前运行。init 函数中定义的日志行在任何其他代码执行之前被打印出来。

延迟函数

我们可以使用 defer 语句来处理在当前测试范围内执行的清理逻辑,就像 init 函数一样,defer 语句不仅仅存在于测试代码中。在 Go 中,defer 语句用于延迟执行某个函数。当我们将 defer 应用到一个函数调用时,这个函数会在包围它的函数调用完成之后才执行,无论是成功完成还是因 panic 异常终止。例如,我们可以这样延迟执行 teardown 函数:

defer teardown()
延迟函数定义

我们可以将 defer 语句应用于命名函数或在内联定义的匿名函数。Go 中的约定是将延迟执行的函数定义放在包围函数的顶部,这样可以确保函数在发生错误之前就被延迟执行,从而避免错误阻止延迟操作的发生。

我们到目前为止见到的所有方式都属于 Go 的语言构造,但它们可能会变得繁琐,并且有一个缺点,那就是会在包级别产生变化。延迟函数让我们可以对单独的测试进行更细粒度的控制,只有在调用它们的测试中发生变化。然而,这种方法的缺点是我们需要记得在每个测试中都添加它,而且我们只能使用这种方式来处理清理逻辑,而无法处理设置逻辑。你应该根据自己的需求权衡每种机制的优缺点。

让我们修改 engine_test.go 文件中的 TestAdd 函数,添加一个延迟函数,而保持 TestMaininit 函数不变:

func TestAdd(t *testing.T) {
    defer func() {
        log.Println("Deferred tearing down.")
    }()

    // 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)
    }
}

这个延迟函数只是简单地打印出另一行日志。在终端运行测试时,我们看到以下输出:

$ go test -run TestAdd ./chapter02/calculator -v
2022/08/14 12:25:49 Init setup.
2022/08/14 12:25:49 Setting up.
=== RUN TestAdd
2022/08/14 12:25:49 Deferred tearing down.
--- PASS: TestAdd (0.00s)
PASS
2022/08/14 12:25:49 Tearing down.
ok github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter02/calculator 0.215s

从测试输出中可以看出,延迟的清理操作在 TestMain 函数的清理步骤之前执行。这是因为延迟函数的调用顺序。

如图 2.7 所示,所有我们看过的设置(setup)和清理(teardown)机制的执行顺序如下:

image 2025 01 02 11 10 04 174
Figure 1. Figure 2.7 – Summary of the order of setup and teardown mechanisms

这个顺序验证了我们在终端输出中看到的内容:

  1. 测试通过 go test 命令启动,就像我们之前所习惯的那样。

  2. init 函数在临时的测试主程序之前执行。

  3. 测试准备执行时,TestMain 函数开始,执行其 setup 函数。

  4. 然后通过 TestMain 中调用 m.Run() 来运行测试。

  5. 所有测试执行完毕后,延迟函数在测试的作用域内执行。

  6. 测试和它们的函数退出后,TestMain 函数的 teardown 函数被执行。

  7. 最后,通过 m.Run() 的返回值来结束测试。

随着我们开始考虑在更大规模上编写测试,我们还需要一种方法来根据较小的测试范围和不同的场景对测试进行分类。在下一部分中,我们将看到如何使用子测试来实现这一目标。