Jinyun's Notes

没什么天赋,爱好也不多,但愿坚持做些喜欢的事情

0%

Go 单元测试

🏋️‍♂️ Go 语言从开发初期就注意了测试用例的编写。特别是静态语言,由于调试没有动态语言那么方便,所以能最快最方便地编写一个测试用例就显得非常重要了。

单元测试命令

单元测试命令实例

1
2
3
4
$ go test -v hello.go hello_test.go
$ go test -v .
$ go test -v -run TestFib .
$ go test -v -timeout 30s -run '^TestFib$' example.com/hello .

一些测试选项

  • -bench regexp:执行相应的 benchmarks,例如:-bench=.
  • -cover:开户测试覆盖率;
  • -run regexp:只运行 regexp 匹配的函数,例如:-run=Array 仅执行包含有 Array 开头的函数;
  • -v:显示测试的详细命令;
1
2
3
4
5
$ go test -v -timeout 10s -run '^TestFib$' example.com/hello .
=== RUN TestFib
--- PASS: TestFib (0.00s)
PASS
ok example.com/hello 0.006s

单元测试函数

单元测试函数格式

TestXxxXxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。

单元测试源码文件可以由多个测试用例组成,每个测试用例函数需要以 Test 为前缀:

  • 前缀用例文件不会参与正常源码编译,不会被包含到可执行文件中;
  • 单元测试用例使用 go test 指令来执行,没有也不需要 main() 作为函数入口。所有以 _test 结尾的源码内以 Test 开头的函数会被自动执行;
  • 单元测试函数的参数 t *test.T 必须传入,否则会报函数签名错误,即:wrong signature for TestXxx, must be: func TestXxx(t *testing.T)
1
2
3
4
5
6
func TestXxx(t *testing.T)

// 以下命名是合法的
func Test123(t *testing.T)
func Test中国(t *testing.T)
func TestMac(t *testing.T)

要编写一个新的测试套件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数。

各种不同测试用例

  • TestSomething(t *testing.T):基本测试用例;
  • BenchmarkSomething(b *testing.B):基准测试用例;
  • ExampleSomething():控制台测试用例;
  • TestMain(m *testing.M):主函数测试用例;

单元测试用例

基本测试实例

待测代码:

1
2
3
4
5
6
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}

测试代码:

1
2
3
4
5
6
7
8
9
10
func TestFib(t *testing.T) {
var (
in = 7
expect = 13
)
actual := Fib(in)
if actual != expect {
t.Errorf("Fib(%d) = %d; want %d", in, actual, expect)
}
}
1
2
3
4
5
6
$ go test -v -timeout 30s -run '^TestFib$' example.com/hello

=== RUN TestFib
--- PASS: TestFib (0.00s)
PASS
ok example.com/hello 0.007s

表驱动测试实例

Table-Driven 的测试方式在 Go 的源码中司空见惯,比如:time_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestFib(t *testing.T) {
var fibTests = []struct {
in int
expect int
}{
{1, 1},
{2, 1},
{3, 2},
{4, 3},
{5, 5},
{6, 8},
{7, 13},
{8, 21},
{9, 34},
}
for i, tt := range fibTests {
actual := Fib(tt.in)
if actual != tt.expect {
t.Errorf("#%d: Fib(%d) = %d; want %d", i, tt.in, actual, tt.expect)
}
}
}
1
2
3
4
5
6
$ go test -v -timeout 30s -run '^TestFib$' example.com/hello

=== RUN TestFib
--- PASS: TestFib (0.00s)
PASS
ok example.com/hello (cached)

子测试实例

待测代码:

1
2
3
4
5
package hello

func Add(x, y int) int {
return x + y
}

测试代码:

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

import (
"testing"
)

func TestAll(t *testing.T) {
var positiveNumberTests = []struct {
name string
x, y, expected int
}{
{"positive number", 1, 1, 2},
{"negative number", 0, 9, 9},
{"zero number", 0, 0, 0},
}

for _, tt := range positiveNumberTests {
t.Run(tt.name, func(t *testing.T) {
if actual := Add(tt.x, tt.y); actual != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.x, tt.y, actual, tt.expected)
}
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
$ go test -v -timeout 30s -run '^TestAll$' example.com/hello

=== RUN TestAll
=== RUN TestAll/positive_number
=== RUN TestAll/negative_number
=== RUN TestAll/zero_number
--- PASS: TestAll (0.00s)
--- PASS: TestAll/positive_number (0.00s)
--- PASS: TestAll/negative_number (0.00s)
--- PASS: TestAll/zero_number (0.00s)
PASS
ok example.com/hello 0.013s

模仿测试实例

待测代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package hello

import "context"

type API interface {
FetchPostByID(ctx context.Context, id int) (*APIPost, error)
}

type APIPost struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
}

测试代码:

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
package hello

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"testing"
"time"
)

type APIMock struct{}

func (a *APIMock) FetchPostByID(ctx context.Context, id int) (*APIPost, error) {
return nil, fmt.Errorf(http.StatusText(http.StatusNotFound))
}

type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}

type HTTPClientMock struct {
DoFunc func(*http.Request) (*http.Response, error)
}

func (h HTTPClientMock) Do(r *http.Request) (*http.Response, error) {
return h.DoFunc(r)
}

func New(client HTTPClient, baseURL string, timeout time.Duration) API {
return &v1{
c: client,
baseURL: baseURL,
timeout: timeout,
}
}

type v1 struct {
c HTTPClient
baseURL string
timeout time.Duration
}

func (v v1) FetchPostByID(ctx context.Context, id int) (*APIPost, error) {
u := fmt.Sprintf("%s/posts/%d", v.baseURL, id)
ctx, cancel := context.WithTimeout(ctx, v.timeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
res, err := v.c.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf(http.StatusText(res.StatusCode))
}

var result *APIPost
return result, json.NewDecoder(res.Body).Decode(&result)
}

var (
client = &HTTPClientMock{}
api = New(client, "", 0)
)

func TestV1FetchPostByID(t *testing.T) {
postTests := []struct {
Body string
StatusCode int
Result *APIPost
Error error
}{
{
Body: `{"id":1,"user_id":1001,"title":"title 1","body":"body 1"}`,
StatusCode: 200,
Result: &APIPost{1, 1001, "title 1", "body 1"},
Error: nil,
},
{
Body: `{"id":2,"user_id":1002,"title":"title 2","body":"body 2"}`,
StatusCode: 200,
Result: &APIPost{2, 1002, "title 2", "body 2"},
Error: nil,
},
{
Body: ``,
StatusCode: http.StatusBadRequest,
Result: nil,
Error: fmt.Errorf(http.StatusText(http.StatusBadRequest)),
},
{
Body: ``,
StatusCode: http.StatusBadRequest,
Result: nil,
Error: fmt.Errorf(http.StatusText(http.StatusBadRequest)),
},
}

for _, tt := range postTests {
client.DoFunc = func(r *http.Request) (*http.Response, error) {
return &http.Response{
Body: io.NopCloser(strings.NewReader(tt.Body)),
StatusCode: tt.StatusCode,
}, nil
}

post, err := api.FetchPostByID(context.Background(), 0)
if err != nil && err.Error() != tt.Error.Error() {
t.Fatalf("want %v, got %v", tt.Error, err)
}

if !reflect.DeepEqual(post, tt.Result) {
t.Fatalf("want %v, got %v", tt.Result, post)
}
}
}
1
2
3
4
5
6
$ go test -v -timeout 30s -run '^TestV1FetchPostByID$' example.com/hello

=== RUN TestV1FetchPostByID
--- PASS: TestV1FetchPostByID (0.00s)
PASS
ok example.com/hello (cached)

T 类型

单元测试中,传递给测试函数的参数是 *testing.T 类型。它用于管理测试状态并支持格式化测试日志。测试日志会在执行测试的过程中不断累积,并在测试完成时转储至标准输出。

报告方法

错误方法

1
2
3
4
5
6
7
8
9
10
11
package hello

import (
"errors"
"testing"
)

func TestErrorFunc(t *testing.T) {
t.Error("this is a error")
t.Errorf("%s", errors.New("this is a error"))
}
1
2
3
4
5
6
7
8
$ go test -v -timeout 30s -run '^TestErrorFunc$' example.com/hello

=== RUN TestErrorFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:9: this is a error
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:10: this is a error
--- FAIL: TestErrorFunc (0.00s)
FAIL
FAIL example.com/hello 0.010s

失败方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package hello

import (
"testing"
)

func TestFailFunc(t *testing.T) {
t.Fail()
t.Log("see if this line will output #1")
}

func TestFailedFunc(t *testing.T) {
t.Failed()
t.Log("see if this line will output #2")
}

func TestFailNowFunc(t *testing.T) {
t.FailNow()
t.Log("see if this line will output #3")
}
1
2
3
4
5
6
7
8
9
10
11
12
$ go test -v -timeout 30s -run '^(TestFailFunc|TestFailedFunc|TestFailNowFunc)$' example.com/hello

=== RUN TestFailFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:9: see if this line will output #1
--- FAIL: TestFailFunc (0.00s)
=== RUN TestFailedFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:14: see if this line will output #2
--- PASS: TestFailedFunc (0.00s)
=== RUN TestFailNowFunc
--- FAIL: TestFailNowFunc (0.00s)
FAIL
FAIL example.com/hello 0.008s

致命方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package hello

import (
"errors"
"testing"
)

func TestFatalFunc(t *testing.T) {
t.Fatal("this is a fatal")
t.Log("see if this line will output #1")
}

func TestFatalfFunc(t *testing.T) {
t.Fatalf("%s", errors.New("this is a fatal"))
t.Log("see if this line will output #2")
}
1
2
3
4
5
6
7
8
9
10
$ go test -v -timeout 30s -run '^(TestFatalFunc|TestFatalfFunc)$' example.com/hello

=== RUN TestFatalFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:9: this is a fatal
--- FAIL: TestFatalFunc (0.00s)
=== RUN TestFatalfFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:14: this is a fatal
--- FAIL: TestFatalfFunc (0.00s)
FAIL
FAIL example.com/hello 0.012s

日志方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package hello

import (
"errors"
"testing"
)

func TestLogFunc(t *testing.T) {
t.Log("this is a log")
}

func TestLogfFunc(t *testing.T) {
t.Logf("%s", errors.New("this is a log"))
}
1
2
3
4
5
6
7
8
9
10
$ go test -v -timeout 30s -run '^(TestLogFunc|TestLogfFunc)$' example.com/hello

=== RUN TestLogFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:9: this is a log
--- PASS: TestLogFunc (0.00s)
=== RUN TestLogfFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:13: this is a log
--- PASS: TestLogfFunc (0.00s)
PASS
ok example.com/hello 0.015s

跳过方法

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 hello

import (
"errors"
"testing"
)

func TestSkipFunc(t *testing.T) {
t.Skip("this is a skip")
t.Log("see if this line will output #1")
}

func TestSkipfFunc(t *testing.T) {
t.Logf("%s", errors.New("this is a skip"))
t.Log("see if this line will output #2")
}

func TestSkipNowFunc(t *testing.T) {
t.SkipNow()
t.Log("see if this line will output #3")
}

func TestSkippedFunc(t *testing.T) {
if t.Skipped() {
t.Log("see if this line will output #4")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ go test -timeout 30s -run '^(TestSkipFunc|TestSkipfFunc|TestSkipNowFunc|TestSkippedFunc)$' example.com/hello -v

=== RUN TestSkipFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:9: this is a skip
--- SKIP: TestSkipFunc (0.00s)
=== RUN TestSkipfFunc
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:14: this is a skip
/Users/majinyun/Codes/go/src/github.com/imajinyun/hello/hello_test.go:15: see if this line will output #2
--- PASS: TestSkipfFunc (0.00s)
=== RUN TestSkipNowFunc
--- SKIP: TestSkipNowFunc (0.00s)
=== RUN TestSkippedFunc
--- PASS: TestSkippedFunc (0.00s)
PASS
ok example.com/hello 0.011s

并行测试

默认情况下,指定包的测试是按照顺序执行的,但也可以通过在测试的函数内部使用 t.Parallel() 来标志某些测试也可以被安全的并发执行(和默认的一样,假设参数名为 t)。在并行执行的情况下,只有当那些被标记为并行的测试才会被并行执行,所以只有一个测试函数时是没意义的。它应该在测试函数体中第一个被调用(在任何需要跳过的条件之后),因为它会重置测试时间:

1
2
3
4
func TestParallel(t *testing.T) {
t.Parallel()
// actual test...
}

在并发情况下,同时运行的测试的数量默认取决于 GOMAXPROCS。它可以通过 -parallel n 被指定(go test -parallel 4)。

另外一个可以实现并行的方法,尽管不是函数级粒度,但却是包级粒度,就是类似这样执行 go test p1 p2 p3(也就是说,同时调用多个测试包)。在这种情况下,包会被先编译,并同时被执行。当然,这对于总的时间来说是有好处的,但它也可能会导致错误变得具有不可预测性,比如一些资源被多个包同时使用时(例如,一些测试需要访问数据库,并删除一些行,而这些行又刚好被其他的测试包使用的话)。

为了保持可控性,-p 标志可以用来指定编译和测试的并发数。当仓库中有多个测试包,并且每个包在不同的子目录中,一个可以执行所有包的命令是 go test ./...,这包含当前目录和所有子目录。没有带 -p 标志执行时,总的运行时间应该接近于运行时间最长的包的时间(加上编译时间)。运行 go test -p 1 ./...,使编译和测试工具只能在一个包中执行时,总的时间应该接近于所有独立的包测试的时间加上编译的时间的总和。具体可以执行 go test -p 3 ./... 来看一下对运行时间的影响。

还有,另外一个可以并行化的地方是在包的代码里面。多亏了 Go 非常棒的并行原语,实际上,除非 GOMAXPROCS 通过环境变量或者在代码中显式设置为 GOMAXPROCS=1,否则,包中一个 goroutines 都没有用是不太常见的。想要使用 2CPU,可以执行 GOMAXPROCS=2 go test,想要使用 4CPU,可以执行 GOMAXPROCS=4 go test,但还有更好的方法:go test -cpu=1,2,4 将会执行 3 次,其中 GOMAXPROCS 值分别为 1,2,4

待测代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package hello

import "sync"

var (
data = make(map[string]string)
locker sync.RWMutex
)

func SetToMap(k, v string) {
locker.Lock()
defer locker.Unlock()
data[k] = v
}

func GetToMap(k string) string {
locker.Lock()
defer locker.RUnlock()
return data[k]
}

测试代码:

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
package hello

import "testing"

var mapTests = []struct {
k, v string
}{
{"name", "🎉 Hello World"},
{"nickname", "Golang"},
{"email", "example@golang.com"},
{"phone", "12311118888"},
{"address", "Nanyang Technological University"},
{"compony", "Amazon"},
}

func TestSetToMap(t *testing.T) {
t.Parallel()
for _, tt := range mapTests {
SetToMap(tt.k, tt.v)
}
}

func TestGetToMap(t *testing.T) {
t.Parallel()
for _, tt := range mapTests {
actual := GetToMap(tt.k)
if actual != tt.v {
t.Errorf("GetToMap(%s) = %v, want: %v", tt.k, actual, tt.v)
}
}
}
1
2
3
4
5
6
7
$ go test -v -timeout 30s -run ^(TestSetToMap|TestGetToMap)$ example.com/hello

=== RUN TestSetToMap
--- PASS: TestSetToMap (0.00s)
=== RUN TestGetToMap
fatal error: sync: RUnlock of unlocked RWMutex
...

因此,如果代码能够进行并行测试,在写测试时,尽量加上 Parallel,这样可以测试出一些可能的问题。

本笔记是笔者在学习和工作中的一些整理,如对您有用,请鼓励我继续写作