单元测试

单元测试基础

Go单元测试采用内置的测试框架,通过引入 testing 包以及 go test 命令来提供测试功能

  • testing 包专门用来进行自动化测试,日志和错误报告,且包含一些基准测试函数功能

  • go test 是一个按照一定的约定和组织来测试代码的程序

在源代码包目录中,所有以 _test.go 为后缀的源文件被 go test 认定为测试文件,这些测试文件不包含在 go build 的代码构建中,而是单独通过 go test 来编译,执行。

Note: *_test.go 文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。

  • 一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。
  • 基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准函数以计算一个平均的执行时间。
  • 示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。

单元测试约定

  • 文件名必须是 _test.go 结尾, 且必须导入 import testing

  • 测试函数格式:func TestFuncName(t *testing.T) 其中 FuncName 为函数名,首字母必须大写, 参数是testing.T, 可以使用该类型来记录错误或者测试状态;测试函数签名如下

    1
    2
    3
    
    func TestName(t *testing.T) {
        // ...
    }
    • 测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:
    1
    2
    3
    
    func TestSin(t *testing.T) { /* ... */ }
    func TestCos(t *testing.T) { /* ... */ }
    func TestLog(t *testing.T) { /* ... */ }
  • 上面测试函数中:(t *testing.T) 是传给测试函数的结构类型,用来管理测试状态,支持格式化日志。如 t.Log、t.Error、t.Errorf等。在函数结尾把输出跟想要的的结果做对比,如果不等于就打印错误,成功的错误则直接返回。

    Note:

    • LogLogf 方法用于日志输出,默认只输出错误日志,如果要输出全部日志需要使用-v标识运行go test命令。benchmarks默认输出全部日志。

    • func (t *T) Fail() — 标记测试函数为失败,然后继续执行当前函数测试代码以及剩余的所有的测试函数。

    • func (t *T) FailNow() — 标记测试失败并且立即停止执行当前函数测试代码,继续执行下一个(默认按书写顺序)测试函数(文件)。

    • Error 等价于 Log 加 Fail, Errorf 等价于 LogfFail

    • SkipNow 标记跳过并停止执行该用例,继续执行下一个用例。Skip 等价于 Log 加 SkipNow,Skipf 等价于 Logf 加 SkipNow, Skipped 返还用例是否被跳过

    • func (t *T) Fatal(args ...interface{}) — FailNow + Log

  • 将测试文件和源码放在相同的目录下,并将名字命名为{source_filename}_test.go。(如:./example.go的测试文件为:./example_test.go)

  • 测试用例会按照源代码中写的顺序依次执行

go test介绍

运行测试

1
2
3
go test ./test      // 指定目录
go test test        // 指定包名(包需要在GOPTAH/src或者GOROOT/src目录下面)
cd test && go test  // 进入包测试

Note

  • go test 命令会遍历所有的 *_test.go 文件中符合上述命名规则的函数,生成一个临时的 main 包用于调用相应的测试函数,接着构建并运行,报告测试结果,最后清理测试中生成的临时文件。
  • go test 可以接收一个或多个函数程序作为参数,并指定一些选项:
    • -v: 参数可用于打印每个测试函数的名字和运行时间:(例如:go test fmt_test.go -v)
    • -run: 参数对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行:
    • -cover: 参数可显示出执行的测试用例的测试覆盖率
  • 使用go test命令只能在一个目录下执行所有测试文件,如果想要执行当前目录及递归子目录所有测试文件,可以使用go test ./…,注意后面是三个点。

单元测试Demo

Code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// main.go
package main

import "errors"

func main() {
}

func Add(a, b int) int {
	return a + b
}

func Sub(a, b int) int {
	return a - b
}

func Mul(a, b int) int {
	return a * b
}

func Div(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("divisor can not be 0")
	}
	return a / b, nil
}


// main_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
	r := Add(1, 2)
	if r != 3 {
		t.Fail() // continue next testing
	}
	t.Log("add test is ok")
}

func TestSub(t *testing.T) {
	r := Sub(1, 2)
	if r != -1 {
		t.FailNow() // current file testing end in here, and continue test next file testing
	}
	t.Log("sub test is ok")
}

func TestMul(t *testing.T) {
	r := Mul(1, 2)
	if r != 2 {
		t.Fatal() // = t.Log + t.FailNow()
	}
	t.Log("mul test is ok")
}

func TestDiv(t *testing.T) {
	if r, err := Div(4, 2); err != nil || r != 2 { // usual case
		t.Errorf("div test failed: %v\n", err)
	}
	t.Log("div test is ok")
}

func TestDiv2(t *testing.T) {
	if _, err := Div(4, 0); err != nil { // exception test
		t.Errorf("div test failed: %v\n", err)
	}
	t.Log("div test is ok")
}

Result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
> go test -v -cover
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
        main_test.go:10: add test is ok
=== RUN   TestSub
--- PASS: TestSub (0.00s)
        main_test.go:18: sub test is ok
=== RUN   TestMul
--- PASS: TestMul (0.00s)
        main_test.go:26: mul test is ok
=== RUN   TestDiv
--- PASS: TestDiv (0.00s)
        main_test.go:33: div test is ok
=== RUN   TestDiv2
--- FAIL: TestDiv2 (0.00s)
        main_test.go:38: div test failed: divisor can not be 0
        main_test.go:40: div test is ok
FAIL
coverage: 100.0% of statements
exit status 1
FAIL    instance.golang.com/test        0.094s

Note: 测试用例函数通常需要考虑一下几方面

  • 正常的用例
  • 错误的用例(错误的输入、没有输入、类型不符等)
  • 边界检查用例的(例如:参数范围为0-1000,则需要对此做检查)

表驱动测试

表格驱动测试:在表格中预先定义好输入,期望的输出以及测试失败的描述信息,然后循环表格调用被测试的方法,根据输入判断输出是否与期望输出一致,不一致则测试失败,返回错误的描述信息。

这种方法易于覆盖各种测试分支,测试逻辑代码没有冗余,开发人员只需要想表格中添加新的测试数据即可。

Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// main.go
package main

import "strings"

func main() {
}

func StringInSlice(a string, list []string) bool {
	for _, b := range list {
		if strings.HasSuffix(b, a) {
			return true
		}
	}
	return false
}

// main_test.go
package main

import "testing"

func TestStringInSlice(t *testing.T) {
	type args struct {
		a    string
		list []string
	}
	tests := []struct {
		name string
		args args
		want bool
	}{
		{
			name: "yes",
			args: args{a: "a", list: []string{"a", "abc"}},
			want: true,
		},
		{
			name: "no",
			args: args{a: "a", list: []string{"cd", "cde"}},
			want: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := StringInSlice(tt.args.a, tt.args.list); got != tt.want {
				t.Errorf("StringInSlice() = %v, want %v", got, tt.want)
			}
		})
	}
}

Result:

1
2
3
4
5
6
7
8
9
[zhe@zhe table]$ go test -v
=== RUN   TestStringInSlice
=== RUN   TestStringInSlice/yes
=== RUN   TestStringInSlice/no
--- PASS: TestStringInSlice (0.00s)
    --- PASS: TestStringInSlice/yes (0.00s)
    --- PASS: TestStringInSlice/no (0.00s)
PASS
ok      instance.golang.com/test/table  0.208s

测试覆盖率

对待测程序执行的测试的程度称为测试的覆盖率。go tool命令可以报告测试覆盖率统计。

测试覆盖率工具的使用用法: 即 cover 命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ go tool cover

Usage of 'go tool cover':

Given a coverage profile produced by 'go test':
	
	go test -coverprofile=c.out # 生成覆盖率的profile文件

Open a web browser displaying annotated source code:
	go tool cover -html=c.out   # 以html格式查看
	 
Write out an HTML file instead of launching a web browser:
    go tool cover -html=c.out -o coverage.html

Display coverage percentages to stdout for each function:
    go tool cover -func=c.out   # 输出到 stdout 查看

Note: go test可以生成覆盖率的profile文件(例如:cover.out),这个文件可以被go tool cover工具解析。

查看 cover.out 文件的方法

  • 输出到标准输出查看: go tool cover -func=cover.out
1
2
3
4
5
6
instance.golang.com\test\unit\main.go:5:        main            0.0%
instance.golang.com\test\unit\main.go:8:        Add             100.0%
instance.golang.com\test\unit\main.go:12:       Sub             100.0%
instance.golang.com\test\unit\main.go:16:       Mul             100.0%
instance.golang.com\test\unit\main.go:20:       Div             66.7%
total:                                          (statements)    83.3%
  • 输出到 html 文件查看:go tool cover -html=c.out

    go-test-coverage

基准测试(压力测试)

压力测试用来检测函数(方法)的性能,和编写单元功能测试的方法类似, 需要注意:

  • 基准测试文件名也必须以 _test.go 结尾(例如:add_bench_test.go)

  • 压力测试函数必须遵循格式: 以Benchmark为前缀名,并且带有一个*testing.B类型的参数, 其中XXX可以是任意字母数字的组合,但是首字母不能是小写字母, 如下:

    1
    
    func BenchmarkXXX(b *testing.B) { ... }
    • go test默认不会执行压力测试的函数,如果要执行压力测试需要带上参数 -test.bench | -bench 手动指定要运行的基准测试函数, 语法格式如下:
    1
    2
    3
    
    Usage: go test -test.bench="test_name_regex"
           go test -bench="test_name_regex"
           go test -test.bench=".*"  // 例子表示测试全部的压力测试函数 
    • -test.bench(-bench) 参数的值是一个正则表达式,用于匹配要执行的基准测试函数的名字, 默认值为空
    • '.' 模式将可以匹配所有基准测试函数
  • 在压力测试用例中,请记得在循环体内使用testing.B.N, 以使测试可以正常的运行(整数N,用于指定操作执行的循环次数。)

  • 如果有初始化代码,调用 b.ResetTimer 可重置计时器,这样可以避免for循环之前的初始化代码的干扰

Note: 因为默认情况下 go test 会运行单元测试,为了防止单元测试的输出影响我们查看基准测试的结果,可以使用-run=匹配一个从来没有的单元测试方法,过滤掉单元测试的输出,比如用: -run=none

Code Demo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package unit

import "testing"

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(1, 2)
	}
}

func BenchmarkSub(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Sub(1, 2)
	}
}

func BenchmarkMul(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Mul(1, 2)
	}
}

func BenchmarkDiv(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Div(4, 2)
	}
}

Result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[zhe@zhe unit]$ go test -bench=.
goos: windows
goarch: amd64
pkg: instance.golang.com/test/unit
BenchmarkAdd-4          2000000000               0.28 ns/op
BenchmarkSub-4          2000000000               0.29 ns/op
BenchmarkMul-4          2000000000               0.28 ns/op
BenchmarkDiv-4          2000000000               0.71 ns/op
PASS
ok      instance.golang.com/test/unit   3.314s

压力测试结果剖析:

  • 基准测试结果中测试函数名的数字后缀部分,这里是 4,表示运行时对应的 GOMAXPROCS 的值
  • 接着的,2000000000 表示 for 循环的执行次数,也就是被测试的函数的执行次数
  • 最后,0.28 ns/op 表示每次执行的平均时间
  • 最后一条显示的是总共的执行时间

以上是测试时间默认是 1s, 也就是说 1s 内调用了 2000000000 次,每次调用花费 0.28/ns。如果想要测试运行时间更长, 可以通过 -benchtime 选项指定,比如 3s:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ go test -bench=. -benchtime=3s -run=none
goos: windows
goarch: amd64
pkg: instance.golang.com/go-web/testing/unit
BenchmarkAdd-2          5000000000               0.35 ns/op
BenchmarkSub-2          5000000000               0.38 ns/op
BenchmarkMul-2          5000000000               0.35 ns/op
BenchmarkDiv-2          5000000000               0.35 ns/op
PASS
ok      instance.golang.com/go-web/testing/unit 7.359s

Note: 可以发现,我们加长了测试时间,测试的次数变多了,但是最终的性能结果:每次执行的时间,并没有太大变化。一般来说这个值最好不要超过3秒,意义不大。

Web服务测试

针对模拟网络访问,标准库了提供了一个httptest包,可以让我们模拟http的网络调用, 可分为 http server(http handler)http client(提供mock server给client)的测试

  • 要测试http handler, 所需要的是ResponseRecorder, 基本上相关的也只需要两个方法 http.NewRequest, httptest.NewRecorder

httptest.Server

httptest.Server 是一个 Listening 在本地回环网口的端口上的服务,这个端口是系统选择的。它常用于端到端的HTTP测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// A Server is an HTTP server listening on a system-chosen port on the
// local loopback interface, for use in end-to-end HTTP tests.
type Server struct {
        URL      string // base URL of form http://ipaddr:port with no trailing slash
        Listener net.Listener

        // TLS is the optional TLS configuration, populated with a new config
        // after TLS is started. If set on an unstarted server before StartTLS
        // is called, existing fields are copied into the new config.
        TLS *tls.Config

        // Config may be changed after calling NewUnstartedServer and
        // before Start or StartTLS.
        Config *http.Server
        // contains filtered or unexported fields
}

相关知识点

黑盒测试:黑盒测试只测试包公开文档和API,内部实现对测试代码是透明的

白盒测试:白盒测试有访问函数内部和数据结构的权限,因此可以做到一些普通客户端无法实现的测试

Click here to check out the repo

See Also

Thanks to the authors 🙂

2018-12-17 Updated

2017-11-04 Updated