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选项来解决这种问题

猜你喜欢

转载自juejin.im/post/7033255153629134879