Go 每日一库之 testing

寸阳分阴须爱惜,休负春色与时光。这篇文章主要讲述Go 每日一库之 testing相关的知识,希望能为你提供帮助。
简介 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=1II=2III=3。但是,十位字符(I/X/C/M)最多出现 3 次,所以不能用IIII表示 4,需要在V左边添加一个I(即IV)来表示,不能用VIIII表示 9,需要使用IX代替。另外五位字符(V/L/D)不能连续出现 2 次,所以不能出现VV,需要用X代替。
// roman.go package romanimport ( "bytes" "errors" "regexp" )type romanNumPair struct Roman string Numintvar ( 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(`^M0,3(CM|CD|D?C0,3)(XC|XL|L?X0,3)(IX|IV|V?I0,3)$`)func ToRoman(n int) (string, error) if n < = 0 || n > = 4000 return "", ErrOutOfRangevar buf bytes.Buffer for _, pair := range romanNumParis for n > pair.Num buf.WriteString(pair.Roman) n -= pair.Numreturn buf.String(), nilfunc FromRoman(roman string) (int, error) if !romanRegex.MatchString(roman) return 0, ErrInvalidRomanvar 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类型的参数:
// roman_test.go package romanimport ( "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.TErrorf()输出错误信息。运行测试时,这些错误信息会被收集起来,运行结束后统一输出。
测试编写完成之后,使用go test命令运行测试,输出结果:
$ go test --- FAIL: TestToRoman (0.00s) roman_test.go:18: ToRoman(1) expect:I got: FAIL exit status 1 FAILgithub.com/darjun/go-daily-lib/testing0.172s

我故意将ToRoman()函数中写错了一行代码,n > pair.Num> 应该为> =,单元测试成功找出了错误。修改之后重新运行测试:
$ go test PASS okgithub.com/darjun/go-daily-lib/testing0.178s

这次测试都通过了!
我们还可以给go test命令传入-v选项,输出详细的测试信息:
$ go test -v === RUNTestToRoman --- PASS: TestToRoman (0.00s) PASS okgithub.com/darjun/go-daily-lib/testing0.174s

在运行每个测试函数前,都输出一行=== RUN,运行结束之后输出--- PASS--- FAIL信息。
表格驱动测试
在上面的例子中,我们实际上只测试了两种情况,0 和 1。按照这种方式将每种情况都写出来就太繁琐了,Go 中流行使用表格的方式将各个测试数据和结果列举出来:
func TestToRoman(t *testing.T) testCases := []struct numint expect string errerror0, "", 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结构:
type toRomanCase struct numint expect string errerror

将所有的测试数据划分到 3 个组中:
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的函数:
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)

为每个分组定义一个测试函数:
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()第一个参数为子测试名,第二个参数为子测试函数:
func TestToRoman(t *testing.T) t.Run("Invalid", testToRomanInvalid) t.Run("Single", testToRomanSingle) t.Run("Normal", testToRomanNormal)

运行:
$ go test -v === RUNTestToRoman === RUNTestToRoman/Invalid === RUNTestToRoman/Single === RUNTestToRoman/Normal --- PASS: TestToRoman (0.00s) --- PASS: TestToRoman/Invalid (0.00s) --- PASS: TestToRoman/Single (0.00s) --- PASS: TestToRoman/Normal (0.00s) PASS okgithub.com/darjun/go-daily-lib/testing0.188s

可以看到,依次运行 3 个子测试,子测试名是父测试名和t.Run()指定的名字组合而成的,如TestToRoman/Invalid
默认情况下,这些测试都是依次顺序执行的。如果各个测试之间没有联系,我们可以让他们并行以加快测试速度。方法也很简单,在testToRomanInvalid/testToRomanSingle/testToRomanNormal这 3 个函数开始处调用t.Parallel(),由于这 3 个函数直接调用了testToRomanCases,也可以只在testToRomanCases函数开头出添加:
func testToRomanCases(cases []toRomanCase, t *testing.T) t.Parallel() // ...

运行:
$ go test -v ... --- PASS: TestToRoman (0.00s) --- PASS: TestToRoman/Invalid (0.00s) --- PASS: TestToRoman/Normal (0.00s) --- PASS: TestToRoman/Single (0.00s) PASS okgithub.com/darjun/go-daily-lib/testing0.182s

我们发现测试完成的顺序并不是我们指定的顺序。
另外,这个示例中我将roman_test.go文件移到了roman_test包中,所以需要import "github.com/darjun/go-daily-lib/testing/roman"。这种方式在测试包有循环依赖的情况下非常有用,例如标准库中net/http依赖net/urlurl的测试函数依赖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()类似下面代码:
func TestMain(m *testing.M) os.Exit(m.Run())

下面自定义一个TestMain()函数,打印go test支持的选项:
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())

运行:
$ 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()我没有写任何测试,就交给大家了????
性能测试 性能测试是为了对函数的运行性能进行评测。性能测试也必须在_test.go文件中编写,且函数名必须是BenchmarkXxxx开头。性能测试函数接受一个*testing.B的参数。下面我们编写 3 个计算第 n 个斐波那契数的函数。
第一种方式:递归
func Fib1(n int) int if n < = 1 return nreturn Fib1(n-1) + Fib1(n-2)

第二种方式:备忘录
func fibHelper(n int, m map[int]int) int if n < = 1 return nif v, ok := m[n]; ok return vv := fibHelper(n-2, m) + fibHelper(n-1, m) m[n] = v return vfunc Fib2(n int) int m := make(map[int]int) return fibHelper(n, m)

第三种方式:迭代
func Fib3(n int) int if n < = 1 return nf1, f2 := 0, 1 for i := 2; i < = n; i++ f1, f2 = f2, f1+f2return f2

下面我们来测试这 3 个函数的执行效率:
// fib_test.go func BenchmarkFib1(b *testing.B) for i := 0; i < b.N; i++ Fib1(20)func BenchmarkFib2(b *testing.B) for i := 0; i < b.N; i++ Fib2(20)func BenchmarkFib3(b *testing.B) for i := 0; i < b.N; i++ Fib3(20)

需要特别注意的是Ngo test会一直调整这个数值,直到测试时间能得出可靠的性能数据为止。运行:
$ go test -bench=. goos: windows goarch: amd64 pkg: github.com/darjun/go-daily-lib/testing/fib cpu: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz BenchmarkFib1-83111039144 ns/op BenchmarkFib2-85826373127 ns/op BenchmarkFib3-81916005825.588 ns/op PASS okgithub.com/darjun/go-daily-lib/testing/fib5.225s

性能测试默认不会执行,需要通过-bench=.指定运行。-bench选项的值是一个简单的模式,.表示匹配所有的,Fib表示运行名字中有Fib的。
上面的测试结果表示Fib1在指定时间内执行了 31110 次,平均每次 39144 ns,Fib2在指定时间内运行了 582637 次,平均每次耗时 3127 ns,Fib3在指定时间内运行了 191600582 次,平均每次耗时 5.588 ns。
其他选项
有一些选项可以控制性能测试的执行。
-benchtime:设置每个测试的运行时间。
$ go test -bench=. -benchtime=30s

运行了更长的时间:
$ go test -bench=. -benchtime=30s goos: windows goarch: amd64 pkg: github.com/darjun/go-daily-lib/testing/fib cpu: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz BenchmarkFib1-895646438756 ns/op BenchmarkFib2-8178624952306 ns/op BenchmarkFib3-810000000005.591 ns/op PASS okgithub.com/darjun/go-daily-lib/testing/fib113.498s

-benchmem:输出性能测试函数的内存分配情况。
-memprofile file:将内存分配数据写入文件。
【Go 每日一库之 testing】-cpuprofile file:将 CPU 采样数据写入文件,方便使用go tool pprof工具分析,详见我的另一篇文章《你不知道的 Go 之 pprof》
运行:
$ go test -bench=. -benchtime=10s -cpuprofile=./cpu.prof -memprofile=./mem.prof goos: windows goarch: amd64 pkg: github.com/darjun/fib BenchmarkFib1-1635600633423 ns/op BenchmarkFib2-1689581941340 ns/op BenchmarkFib3-1610000000006.60 ns/op PASS okgithub.com/darjun/fib33.321s

同时生成了 CPU 采样数据和内存分配数据,通过go tool pprof分析:
$ go tool pprof ./cpu.prof Type: cpu Time: Aug 4, 2021 at 10:21am (CST) Duration: 32.48s, Total samples = 36.64s (112.81%) Entering interactive mode (type "help" for commands, "o" for options) (pprof) top10 Showing nodes accounting for 29640ms, 80.90% of 36640ms total Dropped 153 nodes (cum < = 183.20ms) Showing top 10 nodes out of 74 flatflat%sum%cumcum% 11610ms 31.69% 31.69%11620ms 31.71%github.com/darjun/fib.Fib1 6490ms 17.71% 49.40%6680ms 18.23%github.com/darjun/fib.Fib3 2550ms6.96% 56.36%8740ms 23.85%runtime.mapassign_fast64 2050ms5.59% 61.95%2060ms5.62%runtime.stdcall2 1620ms4.42% 66.38%2140ms5.84%runtime.mapaccess2_fast64 1480ms4.04% 70.41%12350ms 33.71%github.com/darjun/fib.fibHelper 1480ms4.04% 74.45%2960ms8.08%runtime.evacuate_fast64 1050ms2.87% 77.32%1050ms2.87%runtime.memhash64 760ms2.07% 79.39%760ms2.07%runtime.stdcall7 550ms1.50% 80.90%7230ms 19.73%github.com/darjun/fib.BenchmarkFib3 (pprof)

内存:
$ go tool pprof ./mem.prof Type: alloc_space Time: Aug 4, 2021 at 10:30am (CST) Entering interactive mode (type "help" for commands, "o" for options) (pprof) top10 Showing nodes accounting for 8.69GB, 100% of 8.69GB total Dropped 12 nodes (cum < = 0.04GB) flatflat%sum%cumcum% 8.69GB100%100%8.69GB100%github.com/darjun/fib.fibHelper 00%100%8.69GB100%github.com/darjun/fib.BenchmarkFib2 00%100%8.69GB100%github.com/darjun/fib.Fib2 (inline) 00%100%8.69GB100%testing.(*B).launch 00%100%8.69GB100%testing.(*B).runN (pprof)

示例测试 示例测试用于演示模块或函数的使用。同样地,示例测试也在文件_test.go中编写,并且示例测试函数名必须是ExampleXxx的形式。在Example*函数中编写代码,然后在注释中编写期望的输出,go test会运行该函数,然后将实际输出与期望的做比较。下面摘取自 Go 源码net/url/example_test.go文件中的代码演示了url.Values的用法:
func ExampleValuesGet() v := url.Values v.Set("name", "Ava") v.Add("friend", "Jess") v.Add("friend", "Sarah") v.Add("friend", "Zoe") fmt.Println(v.Get("name")) fmt.Println(v.Get("friend")) fmt.Println(v["friend"]) // Output: // Ava // Jess // [Jess Sarah Zoe]

注释中Output:后是期望的输出结果,go test会运行这些函数并与期望的结果做比较,比较会忽略空格。
有时候我们输出的顺序是不确定的,这时就需要使用Unordered Output。我们知道url.Values底层类型为map[string][]string,所以可以遍历输出所有的键值,但是输出顺序不确定:
func ExampleValuesAll() v := url.Values v.Set("name", "Ava") v.Add("friend", "Jess") v.Add("friend", "Sarah") v.Add("friend", "Zoe") for key, values := range v fmt.Println(key, values)// Unordered Output: // name [Ava] // friend [Jess Sarah Zoe]

运行:
$ go test -v $ go test -v === RUNExampleValuesGet --- PASS: ExampleValuesGet (0.00s) === RUNExampleValuesAll --- PASS: ExampleValuesAll (0.00s) PASS okgithub.com/darjun/url0.172s

没有注释,或注释中无Output/Unordered Output的函数会被忽略。
总结 本文介绍了 Go 中的 3 种测试:单元测试,性能测试和示例测试。为了让程序更可靠,让以后的重构更安全、更放心,单元测试必不可少。排查程序中的性能问题,性能测试能派上大用场。示例测试主要是为了演示如何使用某个功能。
大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue????
参考
  1. testing 官方文档: https://golang.google.cn/pkg/testing/
  2. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib
我 我的博客:https://darjun.github.io
欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~
Go 每日一库之 testing

文章图片


    推荐阅读