阅读 287

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 testgo 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、 FatalFatalfSkipNowSkipSkipf 中的任意一个时,则宣告该测试函数结束。跟 Parallel 方法一样,以上提到的这些方法只能在运行测试函数的 goroutine 中调用。

至于其他报告方法,比如 Log 以及 Error 的变种, 则可以在多个 goroutine 中同时进行调用。

T 类型内嵌了 common 类型,common 提供这一系列方法,我们经常会用到的(注意,这里说的测试中断,都是指当前测试函数,并不是中断整个测试文件的执行):

  1. 当我们遇到一个断言错误的时候,标识这个测试失败,会使用到:

Fail : 测试失败,测试继续,也就是之后的代码依然会执行 FailNow : 测试失败,测试函数中断 复制代码

在 FailNow 方法实现的内部,是通过调用 runtime.Goexit() 来中断测试的。

  1. 当我们遇到一个断言错误,只希望跳过这个错误并中断,但是不希望标识测试失败,会使用到:

SkipNow : 跳过测试,测试中断 复制代码

在 SkipNow 方法实现的内部,是通过调用 runtime.Goexit() 来中断测试函数的。

  1. 当我们只希望打印信息,会用到 :

Log : 输出信息 Logf : 输出格式化的信息 复制代码

注意:默认情况下,单元测试成功时,它们打印的信息不会输出,可以通过加上 -v 选项,输出这些信息。但对于基准测试,它们总是会被输出。

  1. 当我们希望跳过这个测试函数,并且打印出信息,会用到:

Skip : 相当于 Log + SkipNow Skipf : 相当于 Logf + SkipNow 复制代码

  1. 当我们希望断言失败的时候,标识测试失败,并打印出必要的信息,但是测试函数继续执行,会用到:

Error : 相当于 Log + Fail Errorf : 相当于 Logf + Fail 复制代码

  1. 当我们希望断言失败的时候,标识测试失败,打印出必要的信息,但中断测试函数,会用到:

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测试方法的执行顺序被打乱了。

截图.PNG

切换到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


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐