简介
testing是 Go 语言标准库自带的测试库。在 Go 语言中编写测试很简单,只需要遵循 Go 测试的几个约定,与编写正常的 Go 代码没有什么区别。Go 语言中有 3 种类型的测试:单元测试,性能测试,示例测试。下面依次来介绍。
单元测试
单元测试又称为功能性测试,是为了测试函数、模块等代码的逻辑是否正确。接下来我们编写一个库,用于将表示罗马数字的字符串和整数互转。罗马数字是由M/D/C/L/X/V/I
这几个字符根据一定的规则组合起来表示一个正整数:
- M=1000,D=500,C=100,L=50,X=10,V=5,I=1;
- 只能表示 1-3999 范围内的整数,不能表示 0 和负数,不能表示 4000 及以上的整数,不能表示分数和小数(当然有其他复杂的规则来表示这些数字,这里暂不考虑);
-
每个整数只有一种表示方式,一般情况下,连写的字符表示对应整数相加,例如
I=1
,II=2
,III=3
。但是,十位字符(I/X/C/M
)最多出现 3 次,所以不能用IIII
表示 4,需要在V
左边添加一个I
(即IV
)来表示,不能用VIIII
表示 9,需要使用IX
代替。另外五位字符(V/L/D
)不能连续出现 2 次,所以不能出现VV
,需要用X
代替。
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
// roman.go package roman import ( "bytes" "errors" "regexp" ) type romanNumPair struct { Roman string Num int } var ( romanNumParis []romanNumPair romanRegex *regexp.Regexp ) var ( ErrOutOfRange = errors. New ( "out of range" ) ErrInvalidRoman = errors. New ( "invalid roman" ) ) func init() { romanNumParis = []romanNumPair{ { "M" , 1000 }, { "CM" , 900 }, { "D" , 500 }, { "CD" , 400 }, { "C" , 100 }, { "XC" , 90 }, { "L" , 50 }, { "XL" , 40 }, { "X" , 10 }, { "IX" , 9 }, { "V" , 5 }, { "IV" , 4 }, { "I" , 1 }, } romanRegex = regexp.MustCompile(`^M{ 0 , 3 }(CM|CD|D?C{ 0 , 3 })(XC|XL|L?X{ 0 , 3 })(IX|IV|V?I{ 0 , 3 })$`) } func ToRoman(n int ) ( string , error ) { if n <= 0 || n >= 4000 { return "" , ErrOutOfRange } var buf bytes.Buffer for _, pair := range romanNumParis { for n > pair.Num { buf.WriteString(pair.Roman) n -= pair.Num } } return buf. String (), nil } func FromRoman(roman string ) ( int , error ) { if !romanRegex.MatchString(roman) { return 0 , ErrInvalidRoman } var result int var index int for _, pair := range romanNumParis { for roman[index:index+ len (pair.Roman)] == pair.Roman { result += pair.Num index += len (pair.Roman) } } return result, nil } |
在 Go 中编写测试很简单,只需要在待测试功能所在文件的同级目录中创建一个以_test.go
结尾的文件。在该文件中,我们可以编写一个个测试函数。测试函数名必须是TestXxxx
这个形式,而且Xxxx
必须以大写字母开头,另外函数带有一个*testing.T
类型的参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// roman_test.go package roman import ( "testing" ) func TestToRoman(t *testing.T) { _, err1 := ToRoman( 0 ) if err1 != ErrOutOfRange { t.Errorf( "ToRoman(0) expect error:%v got:%v" , ErrOutOfRange, err1) } roman2, err2 := ToRoman( 1 ) if err2 != nil { t.Errorf( "ToRoman(1) expect nil error, got:%v" , err2) } if roman2 != "I" { t.Errorf( "ToRoman(1) expect:%s got:%s" , "I" , roman2) } } |
在测试函数中编写的代码与正常的代码没有什么不同,调用相应的函数,返回结果,判断结果与预期是否一致,如果不一致则调用testing.T
的Errorf()
输出错误信息。运行测试时,这些错误信息会被收集起来,运行结束后统一输出。
测试编写完成之后,使用go test
命令运行测试,输出结果:
1
|
$ go test |
--- FAIL: TestToRoman (0.00s)
roman_test.go:18: ToRoman(1) expect:I got:
FAIL
exit status 1
FAIL github.com/darjun/go-daily-lib/testing 0.172s
我故意将ToRoman()
函数中写错了一行代码,n > pair.Num
中>
应该为>=
,单元测试成功找出了错误。修改之后重新运行测试:
1
2
3
|
$ go test PASS ok github.com /darjun/go-daily-lib/testing 0.178s |
这次测试都通过了!
我们还可以给go test
命令传入-v
选项,输出详细的测试信息:
1
|
$ go test - v |
=== RUN TestToRoman
--- PASS: TestToRoman (0.00s)
PASS
ok github.com/darjun/go-daily-lib/testing 0.174s
在运行每个测试函数前,都输出一行=== RUN
,运行结束之后输出--- PASS
或--- FAIL
信息。
表格驱动测试
在上面的例子中,我们实际上只测试了两种情况,0 和 1。按照这种方式将每种情况都写出来就太繁琐了,Go 中流行使用表格的方式将各个测试数据和结果列举出来:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
func TestToRoman(t *testing.T) { testCases := [] struct { num int expect string err error }{ { 0 , "" , ErrOutOfRange}, { 1 , "I" , nil }, { 2 , "II" , nil }, { 3 , "III" , nil }, { 4 , "IV" , nil }, { 5 , "V" , nil }, { 6 , "VI" , nil }, { 7 , "VII" , nil }, { 8 , "VIII" , nil }, { 9 , "IX" , nil }, { 10 , "X" , nil }, { 50 , "L" , nil }, { 100 , "C" , nil }, { 500 , "D" , nil }, { 1000 , "M" , nil }, { 31 , "XXXI" , nil }, { 148 , "CXLVIII" , nil }, { 294 , "CCXCIV" , nil }, { 312 , "CCCXII" , nil }, { 421 , "CDXXI" , nil }, { 528 , "DXXVIII" , nil }, { 621 , "DCXXI" , nil }, { 782 , "DCCLXXXII" , nil }, { 870 , "DCCCLXX" , nil }, { 941 , "CMXLI" , nil }, { 1043 , "MXLIII" , nil }, { 1110 , "MCX" , nil }, { 1226 , "MCCXXVI" , nil }, { 1301 , "MCCCI" , nil }, { 1485 , "MCDLXXXV" , nil }, { 1509 , "MDIX" , nil }, { 1607 , "MDCVII" , nil }, { 1754 , "MDCCLIV" , nil }, { 1832 , "MDCCCXXXII" , nil }, { 1993 , "MCMXCIII" , nil }, { 2074 , "MMLXXIV" , nil }, { 2152 , "MMCLII" , nil }, { 2212 , "MMCCXII" , nil }, { 2343 , "MMCCCXLIII" , nil }, { 2499 , "MMCDXCIX" , nil }, { 2574 , "MMDLXXIV" , nil }, { 2646 , "MMDCXLVI" , nil }, { 2723 , "MMDCCXXIII" , nil }, { 2892 , "MMDCCCXCII" , nil }, { 2975 , "MMCMLXXV" , nil }, { 3051 , "MMMLI" , nil }, { 3185 , "MMMCLXXXV" , nil }, { 3250 , "MMMCCL" , nil }, { 3313 , "MMMCCCXIII" , nil }, { 3408 , "MMMCDVIII" , nil }, { 3501 , "MMMDI" , nil }, { 3610 , "MMMDCX" , nil }, { 3743 , "MMMDCCXLIII" , nil }, { 3844 , "MMMDCCCXLIV" , nil }, { 3888 , "MMMDCCCLXXXVIII" , nil }, { 3940 , "MMMCMXL" , nil }, { 3999 , "MMMCMXCIX" , nil }, { 4000 , "" , ErrOutOfRange}, } for _, testCase := range testCases { got, err := ToRoman(testCase.num) if got != testCase.expect { t.Errorf( "ToRoman(%d) expect:%s got:%s" , testCase.num, testCase.expect, got) } if err != testCase.err { t.Errorf( "ToRoman(%d) expect error:%v got:%v" , testCase.num, testCase.err, err) } } } |
上面将要测试的每种情况列举出来,然后针对每个整数调用ToRoman()
函数,比较返回的罗马数字字符串和错误值是否与预期的相符。后续要添加新的测试用例也很方便。
分组和并行
有时候对同一个函数有不同维度的测试,将这些组合在一起有利于维护。例如上面对ToRoman()
函数的测试可以分为非法值,单个罗马字符和普通 3 种情况。
为了分组,我对代码做了一定程度的重构,首先抽象一个toRomanCase
结构:
1
2
3
4
5
|
type toRomanCase struct { num int expect string err error } |
将所有的测试数据划分到 3 个组中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
var ( toRomanInvalidCases []toRomanCase toRomanSingleCases []toRomanCase toRomanNormalCases []toRomanCase ) func init() { toRomanInvalidCases = []toRomanCase{ { 0 , "" , roman.ErrOutOfRange}, { 4000 , "" , roman.ErrOutOfRange}, } toRomanSingleCases = []toRomanCase{ { 1 , "I" , nil }, { 5 , "V" , nil }, // ... } toRomanNormalCases = []toRomanCase{ { 2 , "II" , nil }, { 3 , "III" , nil }, // ... } } |
然后为了避免代码重复,抽象一个运行多个toRomanCase
的函数:
1
2
3
4
5
6
7
8
9
10
11
|
func testToRomanCases(cases []toRomanCase, t *testing.T) { for _, testCase := range cases { got, err := roman.ToRoman(testCase.num) if got != testCase.expect { t.Errorf( "ToRoman(%d) expect:%s got:%s" , testCase.num, testCase.expect, got) } if err != testCase.err { t.Errorf( "ToRoman(%d) expect error:%v got:%v" , testCase.num, testCase.err, err) } } } |
为每个分组定义一个测试函数:
1
2
3
4
5
6
7
8
9
|
func testToRomanInvalid(t *testing.T) { testToRomanCases(toRomanInvalidCases, t) } func testToRomanSingle(t *testing.T) { testToRomanCases(toRomanSingleCases, t) } func testToRomanNormal(t *testing.T) { testToRomanCases(toRomanNormalCases, t) } |
在原来的测试函数中,调用t.Run()
运行不同分组的测试函数,t.Run()
第一个参数为子测试名,第二个参数为子测试函数:
1
2
3
4
5
|
func TestToRoman(t *testing.T) { t.Run( "Invalid" , testToRomanInvalid) t.Run( "Single" , testToRomanSingle) t.Run( "Normal" , testToRomanNormal) } |
运行:
1
|
$ go test - v |
=== RUN TestToRoman
=== RUN TestToRoman/Invalid
=== RUN TestToRoman/Single
=== RUN TestToRoman/Normal
--- PASS: TestToRoman (0.00s)
--- PASS: TestToRoman/Invalid (0.00s)
--- PASS: TestToRoman/Single (0.00s)
--- PASS: TestToRoman/Normal (0.00s)
PASS
ok github.com/darjun/go-daily-lib/testing 0.188s
可以看到,依次运行 3 个子测试,子测试名是父测试名和t.Run()
指定的名字组合而成的,如TestToRoman/Invalid
。
默认情况下,这些测试都是依次顺序执行的。如果各个测试之间没有联系,我们可以让他们并行以加快测试速度。方法也很简单,在testToRomanInvalid/testToRomanSingle/testToRomanNormal
这 3 个函数开始处调用t.Parallel()
,由于这 3 个函数直接调用了testToRomanCases
,也可以只在testToRomanCases
函数开头出添加:
1
2
3
4
|
func testToRomanCases(cases []toRomanCase, t *testing.T) { t.Parallel() // ... } |
运行:
1
2
3
4
5
6
7
8
|
$ go test - v ... --- PASS: TestToRoman (0.00s) --- PASS: TestToRoman /Invalid (0.00s) --- PASS: TestToRoman /Normal (0.00s) --- PASS: TestToRoman /Single (0.00s) PASS ok github.com /darjun/go-daily-lib/testing 0.182s |
我们发现测试完成的顺序并不是我们指定的顺序。
另外,这个示例中我将roman_test.go
文件移到了roman_test
包中,所以需要import "github.com/darjun/go-daily-lib/testing/roman"
。这种方式在测试包有循环依赖的情况下非常有用,例如标准库中net/http
依赖net/url
,url
的测试函数依赖net/http
,如果把测试放在net/url
包中,那么就会导致循环依赖url_test(net/url)
->net/http
->net/url
。这时可以将url_test
放在一个独立的包中。
主测试函数
有一种特殊的测试函数,函数名为TestMain()
,接受一个*testing.M
类型的参数。这个函数一般用于在运行所有测试前执行一些初始化逻辑(如创建数据库链接),或所有测试都运行结束之后执行一些清理逻辑(释放数据库链接)。如果测试文件中定义了这个函数,则go test
命令会直接运行这个函数,否者go test
会创建一个默认的TestMain()
函数。这个函数的默认行为就是运行文件中定义的测试。我们自定义TestMain()
函数时,也需要手动调用m.Run()
方法运行测试函数,否则测试函数不会运行。默认的TestMain()
类似下面代码:
1
2
3
|
func TestMain(m *testing.M) { os.Exit(m.Run()) } |
下面自定义一个TestMain()
函数,打印go test
支持的选项:
1
2
3
4
5
6
7
|
func TestMain(m *testing.M) { flag.Parse() flag.VisitAll( func (f *flag.Flag) { fmt.Printf( "name:%s usage:%s value:%v\n" , f.Name, f.Usage, f.Value) }) os.Exit(m.Run()) } |
运行:
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 name: test .bench usage:run only benchmarks matching `regexp` value: name: test .benchmem usage:print memory allocations for benchmarks value: false name: test .benchtime usage:run each benchmark for duration `d` value:1s name: test .blockprofile usage:write a goroutine blocking profile to ` file ` value: name: test .blockprofilerate usage: set blocking profile `rate` (see runtime.SetBlockProfileRate) value:1 name: test .count usage:run tests and benchmarks `n` times value:1 name: test .coverprofile usage:write a coverage profile to ` file ` value: name: test .cpu usage:comma-separated `list` of cpu counts to run each test with value: name: test .cpuprofile usage:write a cpu profile to ` file ` value: name: test .failfast usage: do not start new tests after the first test failure value: false name: test .list usage:list tests, examples, and benchmarks matching `regexp` then exit value: name: test .memprofile usage:write an allocation profile to ` file ` value: name: test .memprofilerate usage: set memory allocation profiling `rate` (see runtime.MemProfileRate) value:0 name: test .mutexprofile usage:write a mutex contention profile to the named file after execution value: name: test .mutexprofilefraction usage: if >= 0, calls runtime.SetMutexProfileFraction() value:1 name: test .outputdir usage:write profiles to ` dir ` value: name: test .paniconexit0 usage:panic on call to os.Exit(0) value: true name: test .parallel usage:run at most `n` tests in parallel value:8 name: test .run usage:run only tests and examples matching `regexp` value: name: test .short usage:run smaller test suite to save time value: false name: test .testlogfile usage:write test action log to ` file ` ( for use only by cmd /go ) value: name: test .timeout usage:panic test binary after duration `d` (default 0, timeout disabled) value:10m0s name: test .trace usage:write an execution trace to ` file ` value: name: test . v usage:verbose: print additional output value:tru |
这些选项也可以通过go help testflag
查看。
其他
另一个函数FromRoman()
我没有写任何测试,就交给大家了