Go语言基础:Test的基础知识

559 阅读5分钟

本文内容:

  1. 功能测试
  2. 性能测试
  3. Main测试
  4. 子测试
  5. 示例文件
  6. 跳过函数

使用go test命令将会自动执行所有的形如func TestXxx(*testing.T)的测试函数。

在测试函数中,使用Error,Fail或者相关方法来表示测试失败。

测试文件与源代码文件放在一块,测试文件的文件名以_test.go结尾。

测试文件不会被正常编译,只会在使用go test命令时编译。

测试用例名称为Test加上待测试的方法名。

功能测试

func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

TCP功能测试

假设需要测试某个 API 接口的 handler 能够正常工作,例如 helloHandler

func helloHandler(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello world"))
}

那我们可以创建真实的网络连接进行测试:

import (
        "io/ioutil"
        "net"
        "net/http"
        "testing"
)

func handleError(t *testing.T, err error) {
        t.Helper()
        if err != nil {
                t.Fatal("failed", err)
        }
}

func TestConn(t *testing.T) {
        ln, err := net.Listen("tcp", "127.0.0.1:0")
        handleError(t, err)
        defer ln.Close()

        http.HandleFunc("/hello", helloHandler)
        go http.Serve(ln, nil)

        resp, err := http.Get("http://" + ln.Addr().String() + "/hello")
        handleError(t, err)

        defer resp.Body.Close()
        body, err := ioutil.ReadAll(resp.Body)
        handleError(t, err)

        if string(body) != "hello world" {
                t.Fatal("expected hello world, but got", string(body))
        }
}
  • net.Listen("tcp", "127.0.0.1:0"):监听一个未被占用的端口,并返回 Listener。

  • 调用 http.Serve(ln, nil) 启动 http 服务。

  • 使用 http.Get 发起一个 Get 请求,检查返回值是否正确。

  • 尽量不对 httpnet 库使用 mock,这样可以覆盖较为真实的场景。

HTTP测试

针对 http 开发的场景,使用标准库 net/http/httptest 进行测试更为高效。

上述的测试用例改写如下:

import (
        "io/ioutil"
        "net/http"
        "net/http/httptest"
        "testing"
)

func TestConn(t *testing.T) {
        req := httptest.NewRequest("GET", "http://example.com/foo", nil)
        w := httptest.NewRecorder()
        helloHandler(w, req)
        bytes, _ := ioutil.ReadAll(w.Result().Body)

        if string(bytes) != "hello world" {
                t.Fatal("expected hello world, but got", string(bytes))
        }
}

使用 httptest 模拟请求对象(req)和响应对象(w),达到了相同的目的。

性能测试

形如func BenchmarkXxx(*testing.B)的测试函数。

使用go test -bench命令运行。

func BenchmarkRandInt(b *testing.B) {
   for i := 0; i < b.N; i++ {
      rand.Int()
   }
}

性能测试函数必须循环运行目标代码b.N次。

在性能测试的过程中,b.N的值会自动调整,直至性能测试函数执行的时长达到一个可靠的时间。

性能测试将会输出:BenchmarkRandInt-8 68453040 17.8 ns/op

意为:循环了68453040次,平均每次循环使用了17.8ns

重置计时器

如果在执行目标代码前需要进行一些耗时的铺垫,则如要在循环前重置计时器。

func BenchmarkBigLen(b *testing.B) {
   big := NewBig()
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      big.Len()
   }
}

并行测试

如果性能测试需要使用并行设置,则需要使用RunParallel()辅助函数。

这种情况下需要使用go test -cpu来运行性能测试。

func BenchmarkTemplateParallel(b *testing.B) {
   templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
   b.RunParallel(func(pb *testing.PB) {
      var buf bytes.Buffer
      for pb.Next() {
         buf.Reset()
         templ.Execute(&buf, "World")
      }
   })
}

Main测试

有的时候,需要在测试前进行相关设置,或在测试后进行清理工作。还有的时候,需要控制哪些代码在主线程上运行。为了支持这些情况,可以提供一个主测试函数:fun TestMain(m *testing.M)

在一个测试文件中包含TestMain(m)之后,测试时将只会调用TestMain(m),而不会直接运行其他的测试函数。TestMain(m)运行在主协程当中并且可以在m.Run()前后编写任何必要的设置或清理代码。m.Run()将会返回一个退出码给os.Exit。如果TestMain运行结束,m.Run()的结果将会被传递给os.Exit

flag

运行TestMain的时候,flag.Parse还没有被运行,如果有需要,应该显式地调用flag.Parse方法。不然,命令行的flags总是在各个功能测试或者性能测试的时候被解析。

func TestMain(m *testing.M) {
   // call flag.Parse() here if TestMain uses flags
   os.Exit(m.Run())
}

子测试

TBRun()方法可以直接执行子功能测试和子性能测试,而不用为每一个测试用例单独编写一个测试函数。这样便可以使用类似于表驱动基准测试和创建分层测试。这种方法也提供了一种方式来处理共同的和。

func TestFoo(t *testing.T) {
   // <setup code>
   t.Run("A=1", func(t *testing.T) { ... })
   t.Run("A=2", func(t *testing.T) { ... })
   t.Run("B=1", func(t *testing.T) { ... })
   t.Run("A=1", func(t *testing.T) { ... })
   // <tear-down code>
}

每一个子功能测试和子性能测试都有唯一的名字:顶层的测试函数的名字和传入Run()方法的字符串用/连接,后面再加上一个可选的用于消除歧义的字符串。上面的四个子测试的唯一名字是:

  • TestFoo/A=1
  • TestFoo/A=2
  • TestFoo/B=1
  • TestFoo/A=1#01

命令行中的-run参数和-bench参数是可选的,可用来匹配测试用例的名字。对于包含多个斜杠分隔元素的测试,例如subtests,参数本身是斜杠分隔的,表达式依次匹配每个name元素。由于是可选参数,因此空表达式匹配所有的字符串。

go test -run ''      # Run all tests.
go test -run Foo     # Run top-level tests matching "Foo", such as "TestFooBar".
go test -run Foo/A=  # For top-level tests matching "Foo", run subtests matching "A=".
go test -run /A=1    # For all top-level tests, run subtests matching "A=1".

并行测试

子测试还可用于控制并行测试。只有当所有的子测试完成,父测试才能完成一次。在这个例子里,所有测试都是相互并行运行的,且仅相互并行运行,而不考虑可能定义的其他顶级测试。

func TestGroupedParallel(t *testing.T) {
   for _, tc := range tests {
      tc := tc // capture range variable
      t.Run(tc.Name, func(t *testing.T) {
         t.Parallel()
         ...
      })
   }
}

如果程序超过了8192个并行的goroutine,竞态检测器就会终止它,因此,在运行设置了-race标志的并行测试时要小心。

在并行子测试完成之前,Run不会返回,这提供了一种在一组并行测试之后进行清理的方法。

func TestTeardownParallel(t *testing.T) {
   // This Run will not return until the parallel tests finish.
   t.Run("group", func(t *testing.T) {
      t.Run("Test1", parallelTest1)
      t.Run("Test2", parallelTest2)
      t.Run("Test3", parallelTest3)
   })
   // <tear-down code>
}

帮助函数

Helper()函数将当前所在的函数标记为测试帮助方法。当打印文件和代码行信息时,该方法会被跳过。

package main

import "testing"

type calcCase struct{ A, B, Expected int }

func createMulTestCase(t *testing.T, c *calcCase) {
        // t.Helper()
        if ans := Mul(c.A, c.B); ans != c.Expected {
                t.Fatalf("%d * %d expected %d, but %d got",
                        c.A, c.B, c.Expected, ans)
        }

}

func TestMul(t *testing.T) {
        createMulTestCase(t, &calcCase{2, 3, 6})
        createMulTestCase(t, &calcCase{2, -3, -6})
        createMulTestCase(t, &calcCase{2, 0, 1}) // wrong case
}

在这里,我们故意创建了一个错误的测试用例,运行 go test,用例失败,会报告错误发生的文件和行号信息:

$ go test
--- FAIL: TestMul (0.00s)
    calc_test.go:11: 2 * 0 expected 1, but 0 got
FAIL
exit status 1
FAIL    example 0.007s

可以看到,错误发生在第11行,也就是帮助函数 createMulTestCase 内部。18, 19, 20行都调用了该方法,我们第一时间并不能够确定是哪一行发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

修改 createMulTestCase,调用 t.Helper()

func createMulTestCase(c *calcCase, t *testing.T) {
    t.Helper()
        t.Run(c.Name, func(t *testing.T) {
                if ans := Mul(c.A, c.B); ans != c.Expected {
                        t.Fatalf("%d * %d expected %d, but %d got",
                                c.A, c.B, c.Expected, ans)
                }
        })
}

运行 go test,报错信息如下,可以非常清晰地知道,错误发生在第 20 行。

$ go test
--- FAIL: TestMul (0.00s)
    calc_test.go:20: 2 * 0 expected 1, but 0 got
FAIL
exit status 1
FAIL    example 0.006s

关于 helper 函数的 2 个建议:

  • 不要返回错误, 帮助函数内部直接使用 t.Errort.Fatal 即可,在用例主逻辑中不会因为太多的错误处理代码,影响可读性。

  • 调用 t.Helper() 让报错信息更准确,有助于定位。

示例文件

测试工具包还能运行和验证示例代码。示例函数包含一个结论行注释,该注释以Output:开头,然后比较示例函数的标准输出和注释中的内容。

Output

func ExampleHello() {
   fmt.Println("hello")
   // Output: hello
}

func ExampleSalutations() {
   fmt.Println("hello, and")
   fmt.Println("goodbye")
   // Output:
   // hello, and
   // goodbye
}

Unordered output

Unordered output的前缀注释将匹配任意的行顺序。

func ExamplePerm() {
   for _, value := range Perm(5) {
      fmt.Println(value)
   }
   // Unordered output: 4
   // 2
   // 1
   // 3
   // 0
}

不包含output注释的示例函数,将不会被执行。

为包声明示例的命名约定:

  • F : function
  • T : type
  • M : method
func Example() { ... }
func ExampleF() { ... }
func ExampleT() { ... }
func ExampleT_M() { ... }

包/类型/函数/方法的多个示例函数可以通过在名称后面附加一个不同的后缀来提供。后缀必须以小写字母开头。

func Example_suffix() { ... }
func ExampleF_suffix() { ... }
func ExampleT_suffix() { ... }
func ExampleT_M_suffix() { ... }

当整个测试文件包含单个示例函数、至少一个其他函数、类型、变量或常量声明,且不包含测试或基准函数时,它将作为示例显示。

跳过函数

功能测试或性能测试时可以跳过一些测试函数。

func TestTimeConsuming(t *testing.T) {
   if testing.Short() {
      t.Skip("skipping test in short mode.")
   }
   ...
}