测试的设置(setup)和拆卸(teardown)
我们通过利用外部测试包和 testing.T
类型编写了第一个测试和源代码示例。在这个简单的例子中,这种方法效果很好,但随着我们编写更多的测试,重复相同的测试设置和清理工作可能会变得繁琐。在本节中,我们将探讨 testing
包提供的功能,以简化这一过程。
TestMain 方法
Go 1.4 引入了一个名为 TestMain
的特殊测试功能。这一特性虽然常常未被充分利用,但它在设置(setup)和拆卸(teardown)代码方面为我们提供了极大的灵活性。该测试函数的签名如下:
func TestMain(m *testing.M) {
// 实现
}
与其他测试不同,这个测试的名称是固定的,并且它接收的唯一参数是 *testing.M
类型,而不是像其他测试那样接收 *testing.T
类型。一旦你重写了这个方法,其中的代码将为你提供更多的控制权,来决定如何运行你的测试。TestMain
方法会在该包中的其他所有测试运行之前执行。
每个包只能有一个
TestMain 函数由于包内的名称需要唯一,所以每个包只能定义一个 |
testing.M
类型比 testing.T
类型要小,且它暴露了一个名为 Run()
的方法,允许我们运行该包中的所有测试,并返回一个退出码。
这个函数的使用非常简单,示例如下:
func TestMain(m *testing.M) {
// 设置代码
setup()
// 运行测试
e := m.Run()
// 清理代码
teardown()
// 报告退出码
os.Exit(e)
}
上面的代码示例概述了一个简单的步骤:
-
声明特殊的
TestMain
签名:在测试文件中编写正确的名称和签名。通常,应该将此定义放在文件的最顶部。 -
编写设置代码:在
TestMain
的主体内编写设置代码。建议编写一个单独的setup()
函数,并在TestMain
中调用它,而不是直接在测试函数内编写代码。这有助于提高测试文件的可读性。这些语句会在执行测试之前运行。 -
调用
Run()
函数:编写完设置代码后,调用m.Run()
并将返回的退出值保存到变量中,在示例代码中是e
。此时,测试将会运行,退出值将报告测试是否失败。 -
编写清理代码:与设置代码一样,在调用
Run()
方法后编写清理代码。我也建议创建一个单独的teardown()
函数,而不是直接在TestMain
代码块中编写代码。这些语句将在测试执行之后运行。 -
报告退出值:这一点非常重要,它允许我们将测试失败情况传递给测试运行器。应该将
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 函数与其他名称不同,每个包允许有多个 |
我们将在 engine_test.go
文件中定义一个 init
函数,同时使用 TestMain
和 TestAdd
测试:
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()
延迟函数定义
我们可以将 |
我们到目前为止见到的所有方式都属于 Go 的语言构造,但它们可能会变得繁琐,并且有一个缺点,那就是会在包级别产生变化。延迟函数让我们可以对单独的测试进行更细粒度的控制,只有在调用它们的测试中发生变化。然而,这种方法的缺点是我们需要记得在每个测试中都添加它,而且我们只能使用这种方式来处理清理逻辑,而无法处理设置逻辑。你应该根据自己的需求权衡每种机制的优缺点。
让我们修改 engine_test.go
文件中的 TestAdd
函数,添加一个延迟函数,而保持 TestMain
和 init
函数不变:
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)机制的执行顺序如下:

这个顺序验证了我们在终端输出中看到的内容:
-
测试通过
go test
命令启动,就像我们之前所习惯的那样。 -
init
函数在临时的测试主程序之前执行。 -
测试准备执行时,
TestMain
函数开始,执行其setup
函数。 -
然后通过
TestMain
中调用m.Run()
来运行测试。 -
所有测试执行完毕后,延迟函数在测试的作用域内执行。
-
测试和它们的函数退出后,
TestMain
函数的teardown
函数被执行。 -
最后,通过
m.Run()
的返回值来结束测试。
随着我们开始考虑在更大规模上编写测试,我们还需要一种方法来根据较小的测试范围和不同的场景对测试进行分类。在下一部分中,我们将看到如何使用子测试来实现这一目标。