41.3 测试固件
无论测试代码是采用传统的平铺模式,还是采用基于测试套件和测试用例的xUnit实践模式进行组织,都有着对测试固件(test fixture)的需求。
测试固件是指一个人造的、确定性的环境,一个测试用例或一个测试套件(下的一组测试用例)在这个环境中进行测试,其测试结果是可重复的(多次测试运行的结果是相同的)。我们一般使用setUp和tearDown来代表测试固件的创建/设置与拆除/销毁的动作。
下面是一些使用测试固件的常见场景:
- 将一组已知的特定数据加载到数据库中,测试结束后清除这些数据;
- 复制一组特定的已知文件,测试结束后清除这些文件;
- 创建伪对象(fake object)或模拟对象(mock object),并为这些对象设定测试时所需的特定数据和期望结果。
在传统的平铺模式下,由于每个测试函数都是相互独立的,因此一旦有对测试固件的需求,我们需要为每个TestXxx测试函数单独创建和销毁测试固件。看下面的示例:
// chapter8/sources/classic_testfixture_test.go package demo_test ... func setUp(testName string) func() { fmt.Printf("\tsetUp fixture for %s\n", testName) return func() { fmt.Printf("\ttearDown fixture for %s\n", testName) } } func TestFunc1(t *testing.T) { defer setUp(t.Name())() fmt.Printf("\tExecute test: %s\n", t.Name()) } func TestFunc2(t *testing.T) { defer setUp(t.Name())() fmt.Printf("\tExecute test: %s\n", t.Name()) } func TestFunc3(t *testing.T) { defer setUp(t.Name())() fmt.Printf("\tExecute test: %s\n", t.Name()) }
运行该示例:
$go test -v classic_testfixture_test.go === RUN TestFunc1 setUp fixture for TestFunc1 Execute test: TestFunc1 tearDown fixture for TestFunc1 --- PASS: TestFunc1 (0.00s) === RUN TestFunc2 setUp fixture for TestFunc2 Execute test: TestFunc2 tearDown fixture for TestFunc2 --- PASS: TestFunc2 (0.00s) === RUN TestFunc3 setUp fixture for TestFunc3 Execute test: TestFunc3 tearDown fixture for TestFunc3 --- PASS: TestFunc3 (0.00s) PASS ok command-line-arguments 0.005s
上面的示例在运行每个测试函数TestXxx时,都会先通过setUp函数建立测试固件,并在defer函数中注册测试固件的销毁函数,以保证在每个TestXxx执行完毕时为之建立的测试固件会被销毁,使得各个测试函数之间的测试执行互不干扰。
在Go 1.14版本以前,测试固件的setUp与tearDown一般是这么实现的:
func setUp() func(){ ... return func() { } } func TestXxx(t *testing.T) { defer setUp()() ... }
在setUp中返回匿名函数来实现tearDown的好处是,可以在setUp中利用闭包特性在两个函数间共享一些变量,避免了包级变量的使用。
Go 1.14版本testing包增加了testing.Cleanup方法,为测试固件的销毁提供了包级原生的支持:
func setUp() func(){ ... return func() { } } func TestXxx(t *testing.T) { t.Cleanup(setUp()) ... }
有些时候,我们需要将所有测试函数放入一个更大范围的测试固件环境中执行,这就是包级别测试固件。在Go 1.4版本以前,我们仅能在init函数中创建测试固件,而无法销毁包级别测试固件。Go 1.4版本引入了TestMain,使得包级别测试固件的创建和销毁终于有了正式的施展舞台。看下面的示例:
// chapter8/sources/classic_package_level_testfixture_test.go package demo_test ... func setUp(testName string) func() { fmt.Printf("\tsetUp fixture for %s\n", testName) return func() { fmt.Printf("\ttearDown fixture for %s\n", testName) } } func TestFunc1(t *testing.T) { t.Cleanup(setUp(t.Name())) fmt.Printf("\tExecute test: %s\n", t.Name()) } func TestFunc2(t *testing.T) { t.Cleanup(setUp(t.Name())) fmt.Printf("\tExecute test: %s\n", t.Name()) } func TestFunc3(t *testing.T) { t.Cleanup(setUp(t.Name())) fmt.Printf("\tExecute test: %s\n", t.Name()) } func pkgSetUp(pkgName string) func() { fmt.Printf("package SetUp fixture for %s\n", pkgName) return func() { fmt.Printf("package TearDown fixture for %s\n", pkgName) } } func TestMain(m *testing.M) { defer pkgSetUp("package demo_test")() m.Run() }
运行该示例:
$go test -v classic_package_level_testfixture_test.go package SetUp fixture for package demo_test === RUN TestFunc1 setUp fixture for TestFunc1 Execute test: TestFunc1 tearDown fixture for TestFunc1 --- PASS: TestFunc1 (0.00s) === RUN TestFunc2 setUp fixture for TestFunc2 Execute test: TestFunc2 tearDown fixture for TestFunc2 --- PASS: TestFunc2 (0.00s) === RUN TestFunc3 setUp fixture for TestFunc3 Execute test: TestFunc3 tearDown fixture for TestFunc3 --- PASS: TestFunc3 (0.00s) PASS package TearDown fixture for package demo_test ok command-line-arguments 0.008s
我们看到,在所有测试函数运行之前,包级别测试固件被创建;在所有测试函数运行完毕后,包级别测试固件被销毁。
可以用图41-3来总结(带测试固件的)平铺模式下的测试执行流。
图41-3 平铺模式下的测试执行流
有些时候,一些测试函数所需的测试固件是相同的,在平铺模式下为每个测试函数都单独创建/销毁一次测试固件就显得有些重复和冗余。在这样的情况下,我们可以尝试采用测试套件来减少测试固件的重复创建。来看下面的示例:
// chapter8/sources/xunit_suite_level_testfixture_test.go package demo_test ... func suiteSetUp(suiteName string) func() { fmt.Printf("\tsetUp fixture for suite %s\n", suiteName) return func() { fmt.Printf("\ttearDown fixture for suite %s\n", suiteName) } } func func1TestCase1(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func func1TestCase2(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func func1TestCase3(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func TestFunc1(t *testing.T) { t.Cleanup(suiteSetUp(t.Name())) t.Run("testcase1", func1TestCase1) t.Run("testcase2", func1TestCase2) t.Run("testcase3", func1TestCase3) } func func2TestCase1(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func func2TestCase2(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func func2TestCase3(t *testing.T) { fmt.Printf("\t\tExecute test: %s\n", t.Name()) } func TestFunc2(t *testing.T) { t.Cleanup(suiteSetUp(t.Name())) t.Run("testcase1", func2TestCase1) t.Run("testcase2", func2TestCase2) t.Run("testcase3", func2TestCase3) } func pkgSetUp(pkgName string) func() { fmt.Printf("package SetUp fixture for %s\n", pkgName) return func() { fmt.Printf("package TearDown fixture for %s\n", pkgName) } } func TestMain(m *testing.M) { defer pkgSetUp("package demo_test")() m.Run() }
这个示例采用了xUnit实践的测试代码组织方式,将对测试固件需求相同的一组测试用例放在一个测试套件中,这样就可以针对测试套件创建和销毁测试固件了。
运行一下该示例:
$go test -v xunit_suite_level_testfixture_test.go package SetUp fixture for package demo_test === RUN TestFunc1 setUp fixture for suite TestFunc1 === RUN TestFunc1/testcase1 Execute test: TestFunc1/testcase1 === RUN TestFunc1/testcase2 Execute test: TestFunc1/testcase2 === RUN TestFunc1/testcase3 Execute test: TestFunc1/testcase3 tearDown fixture for suite TestFunc1 --- PASS: TestFunc1 (0.00s) --- PASS: TestFunc1/testcase1 (0.00s) --- PASS: TestFunc1/testcase2 (0.00s) --- PASS: TestFunc1/testcase3 (0.00s) === RUN TestFunc2 setUp fixture for suite TestFunc2 === RUN TestFunc2/testcase1 Execute test: TestFunc2/testcase1 === RUN TestFunc2/testcase2 Execute test: TestFunc2/testcase2 === RUN TestFunc2/testcase3 Execute test: TestFunc2/testcase3 tearDown fixture for suite TestFunc2 --- PASS: TestFunc2 (0.00s) --- PASS: TestFunc2/testcase1 (0.00s) --- PASS: TestFunc2/testcase2 (0.00s) --- PASS: TestFunc2/testcase3 (0.00s) PASS package TearDown fixture for package demo_test ok command-line-arguments 0.005s
当然在这样的测试代码组织方式下,我们仍然可以单独为每个测试用例创建和销毁测试固件,从而形成一种多层次的、更灵活的测试固件设置体系。可以用图41-4总结一下这种模式下的测试执行流。
图41-4 xUnit实践模式下的测试执行流
小结
在确定了将测试代码放入包内测试还是包外测试之后,我们在编写测试前,还要做好测试包内部测试代码的组织规划,建立起适合自己项目规模的测试代码层次体系。简单的测试可采用平铺模式,复杂的测试可借鉴xUnit的最佳实践,利用subtest建立包、测试套件、测试用例三级的测试代码组织形式,并利用TestMain和testing.Cleanup方法为各层次的测试代码建立测试固件。