Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

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来总结(带测试固件的)平铺模式下的测试执行流。

015-01

图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总结一下这种模式下的测试执行流。

015-01

图41-4 xUnit实践模式下的测试执行流

小结

在确定了将测试代码放入包内测试还是包外测试之后,我们在编写测试前,还要做好测试包内部测试代码的组织规划,建立起适合自己项目规模的测试代码层次体系。简单的测试可采用平铺模式,复杂的测试可借鉴xUnit的最佳实践,利用subtest建立包、测试套件、测试用例三级的测试代码组织形式,并利用TestMain和testing.Cleanup方法为各层次的测试代码建立测试固件。