lyyyuna 的小花园

动静中之动, by

RSS

Golang 与子测试

发表于 2020-12

前言

表格驱动测试可谓是最受欢迎的测试方法了,它抽取了相似用例的公共步骤,结构清晰,维护简单,比如:

func TestOlder(t *testing.T) {
	cases := []struct {
		age1     int
		age2     int
		expected bool
	}{
        // 第一个测试用例
		{
			age1:     1,
			age2:     2,
			expected: false,
		},
        // 第二个测试用例
		{
			age1:     2,
			age2:     1,
			expected: true,
		},
	}

	for _, c := range cases {
		_, p1 := NewPerson(c.age1)
		_, p2 := NewPerson(c.age2)

		got := p1.older(p2)

		if got != c.expected {
			t.Errorf("Expected %v > %v, got %v", p1.age, p2.age, got)
        }
    } 
}

但是这种写法有着一个致命的缺陷,你无法像之前一样选择某个用例执行,即不支持 go test -run regex 命令行来选择只执行第一个或第二个测试用例。

Go 1.7 中加入了子测试的概念,以解决该问题。

什么是 Go 的子测试

子测试在 testing 包中由 Run 方法 提供,它有俩个参数:子测试的名字和子测试函数,其中名字是子测试的标识符。

子测试和其他普通的测试函数一样,是在独立的 goroutine 中运行,测试结果也会计入测试报告,所有子测试运行完毕后,父测试函数才会结束。

如何使用t.Run

使用t.Run重构前言中的测试代码,代码变动了不少:

func TestOlder(t *testing.T) {
	cases := []struct {
		name     string
		age1     int
		age2     int
		expected bool
	}{
		{
			name:     "FirstOlderThanSecond",
			age1:     1,
			age2:     2,
			expected: false,
		},
		{
			name:     "SecondOlderThanFirst",
			age1:     2,
			age2:     1,
			expected: true,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, p1 := NewPerson(c.age1)
			_, p2 := NewPerson(c.age2)

			got := p1.older(p2)

			if got != c.expected {
				t.Errorf("Expected %v > %v, got %v", p1.age, p2.age, got)
			}
		})
	}

}

首先我们修改了定义用例的结构体,加入了string类型的name属性。这样每个用例都有了自己的名字来标示自己。例如,第一个用例由于参数arg1大于参数arg2,所以被命名称FirstOlderThanSecond

然后在for循环中,我们把整个测试逻辑包裹在t.Run块中,并把用例名作为第一个参数。

运行该测试,可得:

$ go test -v -count=1
=== RUN   TestOlder
=== RUN   TestOlder/FirstOlderThanSecond
=== RUN   TestOlder/SecondOlderThanFirst
 PASS: TestOlder (0.00s)
     PASS: TestOlder/FirstOlderThanSecond (0.00s)
     PASS: TestOlder/SecondOlderThanFirst (0.00s)
PASS
ok  	person	0.004s

从结果中我们发现,TestOlder派生出另外两个子测试函数:TestOlder/FirstOlderThanSecondTestOlder/SecondOlderThanFirst。在这两个子测试结束之前,TestOlder都不会结束。

子测试函数的测试结果在终端里是缩进的,且测试用例的名字都以TestOlder开头,这些都用来凸显测试用例之间的父子关系。

go test选择子测试运行

在调试特定测试用例或复现某个 bug 时我们常用go test -run=regex来指定。子测试regex的命名规则和上一节中测试结果一致:父测试名/子测试名

比如可用以下命令执行子测试FirstOlderThenSecond

$ go test -v -count=1 -run="TestOlder/FirstOlderThanSecond"
=== RUN   TestOlder
=== RUN   TestOlder/FirstOlderThanSecond
 PASS: TestOlder (0.00s)
     PASS: TestOlder/FirstOlderThanSecond (0.00s)
PASS

如果要执行某个父测试下的所有子测试,可键入:

$ go test -v -count=1 -run="TestOlder"
=== RUN   TestOlder
=== RUN   TestOlder/FirstOlderThanSecond
=== RUN   TestOlder/SecondOlderThanFirst
 PASS: TestOlder (0.00s)
     PASS: TestOlder/FirstOlderThanSecond (0.00s)
     PASS: TestOlder/SecondOlderThanFirst (0.00s)
PASS

Setup 和 Teardown 和 TestMain

使用过其他测试框架的同学一定不会对SetupTeardown陌生,这几乎是测试框架的标配。而 testing 包长期以来在这块是缺失的,我们无法为所有的测试用例添加一些公共的初始化和结束步骤。引入t.Run之后,我们便可以实现缺失的功能。

请看下面的例子,在子测试开始时,先调用setupSubtest(t)做初始化工作,然后使用defer teardownSubtest(t)保证在t.Run结束前执行清理工作。

func setupSubtest(t *testing.T) {
	t.Logf("[SETUP] Hello 👋!")
}

func teardownSubtest(t *testing.T) {
	t.Logf("[TEARDOWN] Bye, bye 🖖!")
}

func TestOlder(t *testing.T) {
......
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
            // setup
            setupSubtest(t)
            // teardown
			defer teardownSubtest(t)

			_, p1 := NewPerson(c.age1)
			_, p2 := NewPerson(c.age2)

			got := p1.older(p2)

			t.Logf("[TEST] Hello from subtest %s \n", c.name)
			if got != c.expected {
				t.Errorf("Expected %v > %v, got %v", p1.age, p2.age, got)
			}
		})
	}
}

运行测试后,可以看到SetupTeardown在每个子测试中都会被调用:

$ go test -v -count=1 -run="TestOlder"
=== RUN   TestOlder
=== RUN   TestOlder/FirstOlderThanSecond
=== RUN   TestOlder/SecondOlderThanFirst
 PASS: TestOlder (0.00s)
     PASS: TestOlder/FirstOlderThanSecond (0.00s)
        person_test.go:33: [SETUP] Hello 👋!
        person_test.go:71: [TEST] Hello from subtest FirstOlderThanSecond
        person_test.go:37: [TEARDOWN] Bye, bye 🖖!
     PASS: TestOlder/SecondOlderThanFirst (0.00s)
        person_test.go:33: [SETUP] Hello 👋!
        person_test.go:71: [TEST] Hello from subtest SecondOlderThanFirst
        person_test.go:37: [TEARDOWN] Bye, bye 🖖!
PASS
ok  	person	0.005s

进一步的,每个包的测试文件其实都包含一个“隐藏”的TestMain(m *testing.M)函数:

func TestMain(m *testing.M) {
	os.Exit(m.Run())
}

若重写该函数,在m.Run上下加入SetupTeardown后便得到了全局的初始化和清理函数。

func setupSubtest() {
	fmt.Println("[SETUP] Hello 👋!")
}

func teardownSubtest() {
	fmt.Println("[TEARDOWN] Bye, bye 🖖!")
}

func TestMain(m *testing.M) {
    setupSubtest()
    code := m.Run()
    teardownSubtest(t)
    os.Exit(code)
}