单元测试
# go test工具
go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以
_test.go
为后缀名的源代码文件都是go test
测试的一部分,不会被go build
编译到最终的可执行文件中。在
*_test.go
文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。类型 格式 作用 测试函数 函数名前缀为Test 测试程序的一些逻辑行为是否正确 基准函数 函数名前缀为Benchmark 测试函数的性能 示例函数 函数名前缀为Example 为文档提供示例文档 go test
命令会遍历所有的*_test.go
文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。
# 单元测试函数
# 1. 格式
每个测试函数必须导入
testing
包,测试函数的基本格式(签名)如下:func TestName(t *testing.T){ // ... }
1
2
3测试函数的名字必须以
Test
开头,可选的后缀名必须以大写字母开头,举几个例子:func TestAdd(t *testing.T){ ... } func TestSum(t *testing.T){ ... } func TestLog(t *testing.T){ ... }
1
2
3其中参数
t
用于报告测试失败和附加的日志信息。testing.T
的拥有的方法如下:func (c *T) Cleanup(func()) func (c *T) Error(args ...interface{}) func (c *T) Errorf(format string, args ...interface{}) func (c *T) Fail() func (c *T) FailNow() func (c *T) Failed() bool func (c *T) Fatal(args ...interface{}) func (c *T) Fatalf(format string, args ...interface{}) func (c *T) Helper() func (c *T) Log(args ...interface{}) func (c *T) Logf(format string, args ...interface{}) func (c *T) Name() string func (c *T) Skip(args ...interface{}) func (c *T) SkipNow() func (c *T) Skipf(format string, args ...interface{}) func (c *T) Skipped() bool func (c *T) TempDir() string
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2. 单元测试示例
就像细胞是构成我们身体的基本单位,一个软件程序也是由很多单元组件构成的。单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。
接下来,我们在
base_demo
包中定义了一个Split
函数,具体实现如下:// base_demo/split.go package base_demo import "strings" // Split 把字符串s按照给定的分隔符sep进行分割返回字符串切片 func Split(s, sep string) (result []string) { i := strings.Index(s, sep) for i > -1 { result = append(result, s[:i]) s = s[i+1:] i = strings.Index(s, sep) } result = append(result, s) return }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18在当前目录下,我们创建一个
split_test.go
的测试文件,并定义一个测试函数如下:// split/split_test.go package split import ( "reflect" "testing" ) func TestSplit(t *testing.T) { // 测试函数名必须以Test开头,必须接收一个*testing.T类型参数 got := Split("a:b:c", ":") // 程序输出的结果 want := []string{"a", "b", "c"} // 期望的结果 if !reflect.DeepEqual(want, got) { // 因为slice不能比较直接,借助反射包中的方法比较 t.Errorf("expected:%v, got:%v", want, got) // 测试失败输出错误提示 } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16此时
split
这个包中的文件如下:❯ ls -l total 16 -rw-r--r-- 1 staff 408 4 29 15:50 split.go -rw-r--r-- 1 staff 466 4 29 16:04 split_test.go
1
2
3
4在当前路径下执行
go test
命令,可以看到输出结果如下:❯ go test PASS ok golang-unit-test-demo/base_demo 0.005s
1
2
3
# go test -v
一个测试用例有点单薄,我们再编写一个测试使用多个字符切割字符串的例子,在
split_test.go
中添加如下测试函数:func TestSplitWithComplexSep(t *testing.T) { got := Split("abcd", "bc") want := []string{"a", "d"} if !reflect.DeepEqual(want, got) { t.Errorf("expected:%v, got:%v", want, got) } }
1
2
3
4
5
6
7现在我们有多个测试用例了,为了能更好的在输出结果中看到每个测试用例的执行情况,我们可以为
go test
命令添加-v
参数,让它输出完整的测试结果。❯ go test -v === RUN TestSplit --- PASS: TestSplit (0.00s) === RUN TestSplitWithComplexSep split_test.go:20: expected:[a d], got:[a cd] --- FAIL: TestSplitWithComplexSep (0.00s) FAIL exit status 1 FAIL golang-unit-test-demo/base_demo 0.009s
1
2
3
4
5
6
7
8
9从上面的输出结果我们能清楚的看到是
TestSplitWithComplexSep
这个测试用例没有测试通过。
# go test -run
单元测试的结果表明
split
函数的实现并不可靠,没有考虑到传入的sep参数是多个字符的情况,下面我们来修复下这个Bug:package base_demo import "strings" // Split 把字符串s按照给定的分隔符sep进行分割返回字符串切片 func Split(s, sep string) (result []string) { i := strings.Index(s, sep) for i > -1 { result = append(result, s[:i]) s = s[i+len(sep):] // 这里使用len(sep)获取sep的长度 i = strings.Index(s, sep) } result = append(result, s) return }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16在执行
go test
命令的时候可以添加-run
参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被go test
命令执行。例如通过给
go test
添加-run=Sep
参数来告诉它本次测试只运行TestSplitWithComplexSep
这个测试用例:❯ go test -run=Sep -v === RUN TestSplitWithComplexSep --- PASS: TestSplitWithComplexSep (0.00s) PASS ok golang-unit-test-demo/base_demo 0.010s
1
2
3
4
5最终的测试结果表情我们成功修复了之前的Bug。
# 回归测试
修改了代码之后仅仅执行那些失败的测试用例或新引入的测试用例是错误且危险的,正确的做法应该是完整运行所有的测试用例,保证不会因为修改代码而引入新的问题。
❯ go test -v === RUN TestSplit --- PASS: TestSplit (0.00s) === RUN TestSplitWithComplexSep --- PASS: TestSplitWithComplexSep (0.00s) PASS ok golang-unit-test-demo/base_demo 0.011s
1
2
3
4
5
6
7测试结果表明我们的单元测试全部通过。
通过这个示例我们可以看到,有了单元测试就能够在代码改动后快速进行回归测试,极大地提高开发效率并保证代码的质量。
# 跳过某些测试用例
为了节省时间支持在单元测试时跳过某些耗时的测试用例。
func TestTimeConsuming(t *testing.T) { if testing.Short() { t.Skip("short模式下会跳过该测试用例") } ... }
1
2
3
4
5
6当执行
go test -short
时就不会执行上面的TestTimeConsuming
测试用例。
# 子测试
在上面的示例中我们为每一个测试数据编写了一个测试函数,而通常单元测试中需要多组测试数据保证测试的效果。Go1.7+中新增了子测试,支持在测试函数中使用
t.Run
执行一组测试用例,这样就不需要为不同的测试数据定义多个测试函数了。func TestXXX(t *testing.T){ t.Run("case1", func(t *testing.T){...}) t.Run("case2", func(t *testing.T){...}) t.Run("case3", func(t *testing.T){...}) }
1
2
3
4
5
# 表格驱动测试
# 1. 介绍
表格驱动测试不是工具、包或其他任何东西,它只是编写更清晰测试的一种方式和视角。
编写好的测试并非易事,但在许多情况下,表格驱动测试可以涵盖很多方面:表格里的每一个条目都是一个完整的测试用例,包含输入和预期结果,有时还包含测试名称等附加信息,以使测试输出易于阅读。
使用表格驱动测试能够很方便的维护多个测试用例,避免在编写单元测试时频繁的复制粘贴。
表格驱动测试的步骤通常是定义一个测试用例表格,然后遍历表格,并使用
t.Run
对每个条目执行必要的测试。
# 2. 示例
官方标准库中有很多表格驱动测试的示例,例如fmt包中便有如下测试代码:
var flagtests = []struct { in string out string }{ {"%a", "[%a]"}, {"%-a", "[%-a]"}, {"%+a", "[%+a]"}, {"%#a", "[%#a]"}, {"% a", "[% a]"}, {"%0a", "[%0a]"}, {"%1.2a", "[%1.2a]"}, {"%-1.2a", "[%-1.2a]"}, {"%+1.2a", "[%+1.2a]"}, {"%-+1.2a", "[%+-1.2a]"}, {"%-+1.2abc", "[%+-1.2a]bc"}, {"%-1.2abc", "[%-1.2a]bc"}, } func TestFlagParser(t *testing.T) { var flagprinter flagPrinter for _, tt := range flagtests { t.Run(tt.in, func(t *testing.T) { s := Sprintf(tt.in, &flagprinter) if s != tt.out { t.Errorf("got %q, want %q", s, tt.out) } }) } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28通常表格是匿名结构体切片,可以定义结构体或使用已经存在的结构进行结构体数组声明。name属性用来描述特定的测试用例。
接下来让我们试着自己编写表格驱动测试:
func TestSplitAll(t *testing.T) { // 定义测试表格 // 这里使用匿名结构体定义了若干个测试用例 // 并且为每个测试用例设置了一个名称 tests := []struct { name string input string sep string want []string }{ {"base case", "a:b:c", ":", []string{"a", "b", "c"}}, {"wrong sep", "a:b:c", ",", []string{"a:b:c"}}, {"more sep", "abcd", "bc", []string{"a", "d"}}, {"leading sep", "沙河有沙又有河", "沙", []string{"", "河有", "又有河"}}, } // 遍历测试用例 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试 got := Split(tt.input, tt.sep) if !reflect.DeepEqual(got, tt.want) { t.Errorf("expected:%#v, got:%#v", tt.want, got) } }) } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25在终端执行
go test -v
,会得到如下测试输出结果:❯ go test -v === RUN TestSplit --- PASS: TestSplit (0.00s) === RUN TestSplitWithComplexSep --- PASS: TestSplitWithComplexSep (0.00s) === RUN TestSplitAll === RUN TestSplitAll/base_case === RUN TestSplitAll/wrong_sep === RUN TestSplitAll/more_sep === RUN TestSplitAll/leading_sep --- PASS: TestSplitAll (0.00s) --- PASS: TestSplitAll/base_case (0.00s) --- PASS: TestSplitAll/wrong_sep (0.00s) --- PASS: TestSplitAll/more_sep (0.00s) --- PASS: TestSplitAll/leading_sep (0.00s) PASS ok golang-unit-test-demo/base_demo 0.010s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3. 并行测试
表格驱动测试中通常会定义比较多的测试用例,而Go语言又天生支持并发,所以很容易发挥自身并发优势将表格驱动测试并行化。 想要在单元测试过程中使用并行测试,可以像下面的代码示例中那样通过添加
t.Parallel()
来实现。func TestSplitAll(t *testing.T) { t.Parallel() // 将 TLog 标记为能够与其他测试并行运行 // 定义测试表格 // 这里使用匿名结构体定义了若干个测试用例 // 并且为每个测试用例设置了一个名称 tests := []struct { name string input string sep string want []string }{ {"base case", "a:b:c", ":", []string{"a", "b", "c"}}, {"wrong sep", "a:b:c", ",", []string{"a:b:c"}}, {"more sep", "abcd", "bc", []string{"a", "d"}}, {"leading sep", "沙河有沙又有河", "沙", []string{"", "河有", "又有河"}}, } // 遍历测试用例 for _, tt := range tests { tt := tt // 注意这里重新声明tt变量(避免多个goroutine中使用了相同的变量) t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试 t.Parallel() // 将每个测试用例标记为能够彼此并行运行 got := Split(tt.input, tt.sep) if !reflect.DeepEqual(got, tt.want) { t.Errorf("expected:%#v, got:%#v", tt.want, got) } }) } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28这样我们执行
go test -v
的时候就会看到每个测试用例并不是按照我们定义的顺序执行,而是互相并行了。
# 4. 使用工具生成测试代码
社区里有很多自动生成表格驱动测试函数的工具,比如gotests (opens new window)等,很多编辑器如Goland也支持快速生成测试文件。
安装
go get -u github.com/cweill/gotests/...
1执行
gotests -all -w split.go
1上面的命令表示,为
split.go
文件的所有函数生成测试代码至split_test.go
文件(目录下如果事先存在这个文件就不再生成)。生成的测试代码大致如下:
package base_demo import ( "reflect" "testing" ) func TestSplit(t *testing.T) { type args struct { s string sep string } tests := []struct { name string args args wantResult []string }{ // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if gotResult := Split(tt.args.s, tt.args.sep); !reflect.DeepEqual(gotResult, tt.wantResult) { t.Errorf("Split() = %v, want %v", gotResult, tt.wantResult) } }) } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27代码格式与我们上面的类似,只需要在TODO位置添加我们的测试逻辑就可以了。
# 测试覆盖率
测试覆盖率是指代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。在公司内部一般会要求测试覆盖率达到80%左右。
Go提供内置功能来检查你的代码覆盖率,即使用
go test -cover
来查看测试覆盖率。❯ go test -cover PASS coverage: 100.0% of statements ok golang-unit-test-demo/base_demo 0.009s
1
2
3
4从上面的结果可以看到我们的测试用例覆盖了100%的代码。
Go还提供了一个额外的
-coverprofile
参数,用来将覆盖率相关的记录信息输出到一个文件。例如:❯ go test -cover -coverprofile=c.out PASS coverage: 100.0% of statements ok golang-unit-test-demo/base_demo 0.009s
1
2
3
4上面的命令会将覆盖率相关的信息输出到当前文件夹下面的
c.out
文件中。❯ tree . . ├── c.out ├── split.go └── split_test.go
1
2
3
4
5然后我们执行
go tool cover -html=c.out
,使用cover
工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。- 上图中每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。