Golang测试第一弹:单元测试
热身
单元测试(模块测试)是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。Golang当然也有自带的测试包testing,使用该包可以进行自动化的单元测试,输出结果验证。
如果之前从没用过golang的单元测试的话,可以输入命令 go help test,看看官方的介绍。 这里只打印一些关键信息:
E:\mygolandproject\MyTest>go help test usage: go test [build/test flags] [packages] [build/test flags & test binary flags] 'Go test' automates testing the packages named by the import paths. It prints a summary of the test results in the format: ok archive/tar 0.011s FAIL archive/zip 0.022s ok compress/gzip 0.033s ... followed by detailed output for each failed package. // ...... The go tool will ignore a directory named "testdata", making it available to hold ancillary data needed by the tests. // ...... 'Go test' recompiles each package along with any files with names matching the file pattern "*_test.go". These additional files can contain test functions, benchmark functions, and example functions. See 'go help testfunc' for more. // ...... 复制代码
再执行 go help testfunc 看看
E:\mygolandproject\MyTest1>go help testfunc The 'go test' command expects to find test, benchmark, and example functions in the "*_test.go" files corresponding to the package under test. A test function is one named TestXxx (where Xxx does not start with a lower case letter) and should have the signature, func TestXxx(t *testing.T) { ... } // ...... See the documentation of the testing package for more information. 复制代码
现在应该清楚了,要编写一个测试套件,首先需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数:
func TestXxx(*testing.T) // Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。 复制代码
go test的基本格式是:
go test [build/test flags] [packages] [build/test flags & test binary flags] 复制代码
执行 go test 命令后,就会在指定的包下寻找 *_test.go
文件中的 TestXxx 函数来执行。 除了一些可选的 flags 外,需要注意一下 packages 的填写。该*_test.go
测试文件与待测试的文件是否置于同一包下并不关键,只要 cd 进*_test.go
文件所在目录,执行 go test
或 go test .
或 go test ./xxx_test.go
都可以运行。测试文件不会参与正常源码编译,不会被包含到可执行文件中。
go test
命令会忽略 testdata
目录,该目录是用来保存测试需要用到的辅助数据。
执行完成后就会打印结果信息:
ok archive/tar 0.011s FAIL archive/zip 0.022s ... 复制代码
单元测试
要测试的代码:
func Fib(n int) int { if n < 4 { return n } return Fib(n-1) + Fib(n-2) } 复制代码
测试代码:
func TestFib(t *testing.T) { var ( in = 7 expected = 13 ) actual := Fib(in) fmt.Println(actual) if actual != expected { // Errorf()函数是单元测试中用于打印格式化的错误信息。 t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected) } } 复制代码
执行结果如下:
E:\myGolandProject\MyTest>go test PASS ok gin/MyTest 0.670s 复制代码
把 expected 改为14,执行结果如下:
E:\myGolandProject\MyTest>go test --- FAIL: TestFib (0.00s) first_test.go:15: Fib(7) = 13; expected 14 FAIL exit status 1 FAIL gin/MyTest 0.585s 复制代码
测试讲究 case 覆盖,按上面的方式,当我们要覆盖更多 case 时,显然通过修改代码的方式很笨拙。这时我们可以采用 Table-Driven 的方式写测试,标准库中有很多测试是使用这种方式写的。
func TestFib(t *testing.T) { var fibTests = []struct { in int // input expected int // expected result }{ {1, 1}, {2, 1}, {3, 2}, {4, 3}, {5, 5}, {6, 8}, {7, 13}, } for _, tt := range fibTests { actual := Fib(tt.in) if actual != tt.expected { t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected) } } } 复制代码
上面例子中,即使其中某个 case 失败,也不会终止测试执行。
继续探索
到这里已经基本介绍了 Golang单元测试的基本流程。但是还有个疑问没解开,就是*testing.T
函数TestFib(t *testing.T)
中的入参 *testing.T
是个啥东西?我们进去源码瞧瞧
// T is a type passed to Test functions to manage test state and support formatted test logs. // // A test ends when its Test function returns or calls any of the methods // FailNow, Fatal, Fatalf, SkipNow, Skip, or Skipf. Those methods, as well as // the Parallel method, must be called only from the goroutine running the // Test function. // // The other reporting methods, such as the variations of Log and Error, // may be called simultaneously from multiple goroutines. type T struct { common isParallel bool context *testContext // For running tests and subtests. } 复制代码
可以看到,T是传递给Test函数的类型,用于管理测试状态并支持格式化的测试日志。测试日志会在执行测试的过程中不断累积,并在测试完成时转储至标准输出。
当测试函数返回时,或者当测试函数调用 FailNow
、 Fatal
、Fatalf
、SkipNow
、Skip
、Skipf
中的任意一个时,则宣告该测试函数结束。跟 Parallel
方法一样,以上提到的这些方法只能在运行测试函数的 goroutine 中调用。
至于其他报告方法,比如 Log
以及 Error
的变种, 则可以在多个 goroutine 中同时进行调用。
T 类型内嵌了 common 类型,common 提供这一系列方法,我们经常会用到的(注意,这里说的测试中断,都是指当前测试函数,并不是中断整个测试文件的执行):
当我们遇到一个断言错误的时候,标识这个测试失败,会使用到:
Fail : 测试失败,测试继续,也就是之后的代码依然会执行 FailNow : 测试失败,测试函数中断 复制代码
在 FailNow
方法实现的内部,是通过调用 runtime.Goexit()
来中断测试的。
当我们遇到一个断言错误,只希望跳过这个错误并中断,但是不希望标识测试失败,会使用到:
SkipNow : 跳过测试,测试中断 复制代码
在 SkipNow
方法实现的内部,是通过调用 runtime.Goexit()
来中断测试函数的。
当我们只希望打印信息,会用到 :
Log : 输出信息 Logf : 输出格式化的信息 复制代码
注意:默认情况下,单元测试成功时,它们打印的信息不会输出,可以通过加上 -v
选项,输出这些信息。但对于基准测试,它们总是会被输出。
当我们希望跳过这个测试函数,并且打印出信息,会用到:
Skip : 相当于 Log + SkipNow Skipf : 相当于 Logf + SkipNow 复制代码
当我们希望断言失败的时候,标识测试失败,并打印出必要的信息,但是测试函数继续执行,会用到:
Error : 相当于 Log + Fail Errorf : 相当于 Logf + Fail 复制代码
当我们希望断言失败的时候,标识测试失败,打印出必要的信息,但中断测试函数,会用到:
Fatal : 相当于 Log + FailNow Fatalf : 相当于 Logf + FailNow 复制代码
接着来看一下runtime.Goexit()
的定义:
// Goexit terminates the goroutine that calls it. No other goroutine is affected. // Goexit runs all deferred calls before terminating the goroutine. Because Goexit // is not a panic, any recover calls in those deferred functions will return nil. // // Calling Goexit from the main goroutine terminates that goroutine // without func main returning. Since func main has not returned, // the program continues execution of other goroutines. // If all other goroutines exit, the program crashes. func Goexit(){ ... } 复制代码
函数头第一句注释就说明了Goexit会终止调用它的goroutine。那问题来了,当某个测试函数断言失败调用FailNow的时候,为什么后面的测试代码还可以执行呢?难道不是一个Goroutine执行完整个测试文件吗?(菜鸡的我刚开始确实是这么想的..)。其实答案就在testing包!
testing包中有一个Runtest函数:
// RunTests is an internal function but exported because it is cross-package; // it is part of the implementation of the "go test" command. func RunTests(matchString func(pat, str string) (bool, error), tests []InternalTest) (ok bool) { var deadline time.Time if *timeout > 0 { deadline = time.Now().Add(*timeout) } ran, ok := runTests(matchString, tests, deadline) if !ran && !haveExamples { fmt.Fprintln(os.Stderr, "testing: warning: no tests to run") } return ok } 复制代码
原来Runtest函数就是go test命令的实现!
tests []InternalTest
这个切片入参就是保存着测试文件中所有的测试函数调用了runTests,tests切片入参也被传了进去
再看看runTests函数内部实现,我把其他的实现细节屏蔽了:
func runTests(matchString func(pat, str string) (bool, error), tests []InternalTest, deadline time.Time) (ran, ok bool) { // ...... tRunner(t, func(t *T) { for _, test := range tests { t.Run(test.Name, test.F) } }) // ...... } 复制代码
果然是这样,遍历了tests切片,对每个测试函数都调用了Run这个方法
// Run runs f as a subtest of t called name. It runs f in a separate goroutine // and blocks until f returns or calls t.Parallel to become a parallel test. // Run reports whether f succeeded (or at least did not fail before calling t.Parallel). // // Run may be called simultaneously from multiple goroutines, but all such calls // must return before the outer test function for t returns. func (t *T) Run(name string, f func(t *T)) bool { atomic.StoreInt32(&t.hasSub, 1) testName, ok, _ := t.context.match.fullName(&t.common, name) if !ok || shouldFailFast() { return true } // Record the stack trace at the point of this call so that if the subtest // function - which runs in a separate stack - is marked as a helper, we can // continue walking the stack into the parent test. var pc [maxStackLen]uintptr n := runtime.Callers(2, pc[:]) t = &T{ common: common{ barrier: make(chan bool), signal: make(chan bool, 1), name: testName, parent: &t.common, level: t.level + 1, creator: pc[:n], chatty: t.chatty, }, context: t.context, } t.w = indenter{&t.common} if t.chatty != nil { t.chatty.Updatef(t.name, "=== RUN %s\n", t.name) } // Instead of reducing the running count of this test before calling the // tRunner and increasing it afterwards, we rely on tRunner keeping the // count correct. This ensures that a sequence of sequential tests runs // without being preempted, even when their parent is a parallel test. This // may especially reduce surprises if *parallel == 1. go tRunner(t, f) if !<-t.signal { // At this point, it is likely that FailNow was called on one of the // parent tests by one of the subtests. Continue aborting up the chain. runtime.Goexit() } return !t.failed } 复制代码
答案就在这里,对于每个f,也就是测试函数,都起了一个新的Goroutine来执行!所以当某个测试函数断言失败调用FailNow的时候,后面的测试代码是可以执行的,因为每个TestXxx函数跑在不同的Goroutine上。
扩展
在Go1.17中,给go test
新增了一个-shuffle
选项,shuffle是洗牌的意思,顾名思义就是TestXxx测试方法的执行顺序被打乱了。
切换到Go1.17,执行go help testflag
,找到-shuffle
的描述
// ...... -shuffle off,on,N Randomize the execution order of tests and benchmarks. It is off by default. If -shuffle is set to on, then it will seed the randomizer using the system clock. If -shuffle is set to an integer N, then N will be used as the seed value. In both cases, the seed will be reported for reproducibility. 复制代码
-shuffle默认是off,设置为on就会打开洗牌。
写个简单Demo验证一下:
import ( "testing" ) func TestFunc1(t *testing.T) { t.Logf("1") } func TestFunc2(t *testing.T) { t.Logf("2") } func TestFunc3(t *testing.T) { t.Logf("3") } func TestFunc4(t *testing.T) { t.Logf("4") } 复制代码
执行结果如下:
E:\myGolandProject\MyTest>go test -v -shuffle=on . -test.shuffle 1637545619604654100 === RUN TestFunc4 fib2_test.go:20: 4 --- PASS: TestFunc4 (0.00s) === RUN TestFunc3 fib2_test.go:16: 3 --- PASS: TestFunc3 (0.00s) === RUN TestFunc1 fib2_test.go:8: 1 --- PASS: TestFunc1 (0.00s) === RUN TestFunc2 fib2_test.go:12: 2 --- PASS: TestFunc2 (0.00s) PASS ok command-line-arguments 0.025s 复制代码
如果按照某种测试顺序会导致错误的话,那么这种错误是很难定位的,这时候就可以利用-shuffle选项来解决这种问题
作者:热爱杰伦和Go
链接:https://juejin.cn/post/7033255153629134879