Golang单元测试

测试文件一般以xxx_test.go,代表测试xxx.go文件中的函数功能。

单元测试都需要:

  1. 正常执行
  2. 异常例子
  3. 正常与异常的边界例子
  4. 追求分支覆盖
  5. 追求代码覆盖

单元功能测试

功能测试函数名称以Test开头

1
2
3
4
5
func TestGetArea(t *testing.T) {
if 200 != GetArea(20, 10) {
t.Error("wrong result")
}
}

执行方式

1
2
3
4
5
6
7
8
9
go test -v
=== RUN TestGetArea
--- PASS: TestGetArea (0.00s)
PASS
ok gostudy/demotest 0.095s
// 或者指定某个测试函数名称,单独跑这个测试
go test -run=GetArea
PASS
ok gostudy/demotest 0.089s

快速生成测试用例

通过Goland编译器可以快速生成单元测试模板,在代码文件中,通过快捷键command+N呼出生成菜单,选择生成文件测试生成对应文件中方法的测试,或者选择软件包测试,生成包的测试。

image-20220816220742093 image-20220816220829820

基准测试(性能压力测试)

性能压力测试函数名称以Benchmark开头

1
2
3
4
5
func BenchmarkGetArea(b *testing.B) {
for i := 0; i < b.N; i++ {
GetArea(i, i)
}
}

执行方式

1
2
3
4
5
6
7
8
go test -bench=.
goos: darwin
goarch: arm64
pkg: gostudy/demotest
// 测试函数名称 测试次数 每次操作花费时间
BenchmarkGetArea-10 1000000000 0.6229 ns/op
PASS
ok gostudy/demotest 0.998s

代码覆盖率测试

代码覆盖率测试可以实现在测试过程检查覆盖到的代码

执行方式

1
2
3
4
go test -cover
PASS
coverage: 100.0% of statements
ok gostudy/demotest 0.408s

也可以将测试情况输出到文件

1
2
3
4
go test -coverprofile=./cover.out
PASS
coverage: 100.0% of statements
ok gostudy/demotest 3.055s

通过字符展示

1
2
3
go tool cover -func=./cover.out
gostudy/demotest/demo.go:3: GetArea 100.0%
total: (statements) 100.0%

或者通过页面图形化展示

1
go tool cover -html=./cover.out

子测试

单元测试中,支持将一些测试集中在一起,作为一个单元测试的集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestCalc(t *testing.T) {
t.Run("add", func(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("add error")
}
})

t.Run("sub", func(t *testing.T) {
if Sub(2, 1) != 1 {
t.Error("sub error")
}
})
}

执行结果

1
2
3
4
5
6
7
8
9
#  go test -v
=== RUN TestCalc
=== RUN TestCalc/add
=== RUN TestCalc/sub
--- PASS: TestCalc (0.00s)
--- PASS: TestCalc/add (0.00s)
--- PASS: TestCalc/sub (0.00s)
PASS
ok gostudy/demotest 1.732s

表驱动测试

表驱动测试可以很好的覆盖到多种测试场景

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
func TestCalc(t *testing.T) {
cases := []struct {
name string
x, y int
addexpe ct int
subexpect int
}{
{
"simpel",
1, 2,
3,
-1,
},
{
"negative",
-110, -220,
-330,
110,
},
{
"error",
4, 5,
10,
8,
},
}

for _, c := range cases {
if addres := Add(c.x, c.y); c.addexpect != addres {
t.Errorf("%s error,expect %d,got %d", c.name, c.addexpect, addres)
}
if subres := Sub(c.x, c.y); c.subexpect != subres {
t.Errorf("%s error,expect %d,got %d", c.name, c.subexpect, subres)
}
}
}

帮助函数

t.Helper()帮助函数,标注该函数是帮助函数,报错时,将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

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
type Case struct {
name string
x, y int
addexpect int
subexpect int
}

func TestCalc(t *testing.T) {
cases := []Case{
{
"simpel",
1, 2,
3,
-1,
},
{
"negative",
-110, -220,
-330,
110,
}, {
"error test",
4, 5,
10,
8,
},
}
for _, c := range cases {
createCalcTest(t, c)
}
}

func createCalcTest(t *testing.T, c Case) {
t.Helper()
t.Run(c.name, func(t *testing.T) {
if addres := Add(c.x, c.y); c.addexpect != addres {
t.Errorf("%s error,expect %d,got %d", c.name, c.addexpect, addres)
}
if subres := Sub(c.x, c.y); c.subexpect != subres {
t.Errorf("%s error,expect %d,got %d", c.name, c.subexpect, subres)
}
})
}

运行结果

1
2
3
4
5
6
7
8
9
10
=== RUN   TestCalc
=== RUN TestCalc/simpel
=== RUN TestCalc/negative
=== RUN TestCalc/error_test
demo_test.go:69: error test error,expect 10,got 9
demo_test.go:72: error test error,expect 8,got -1
--- FAIL: TestCalc (0.00s)
--- PASS: TestCalc/simpel (0.00s)
--- PASS: TestCalc/negative (0.00s)
--- FAIL: TestCalc/error_test (0.00s)

顺序逻辑测试

函数testMain,当有testMain函数时,会执行testMain函数,在testMain中的m.Run会将其他的单元测试函数按照顺序执行。如果有需要事先准备的函数或者事后清理的函数,也会按照顺序执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func prepare() {
fmt.Println("do something prepare")
}

func done() {
fmt.Println("do something done")
}

func TestTest2(t *testing.T) {
fmt.Println("test 2")
}

func TestTest1(t *testing.T) {
fmt.Println("test 1")
}

func TestMain(m *testing.M) {
fmt.Println("start")
prepare()
m.Run()
done()
fmt.Println("end")
}

通过go test -v输出

1
2
3
4
5
6
7
8
9
10
11
12
start
do something prepare
=== RUN TestTest2
test 2
--- PASS: TestTest2 (0.00s)
=== RUN TestTest1
test 1
--- PASS: TestTest1 (0.00s)
PASS
do something done
end
ok gostudy/demotest/pratices1 0.340s

web测试

通过httptest包测试http.handler函数

1
2
3
func ping(rw http.ResponseWriter, req *http.Request) {
fmt.Fprint(rw, "pong")
}

测试函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestHttp(t *testing.T) {
request := httptest.NewRequest("GET", "http://127.0.0.1:8080/ping", nil)
response := httptest.NewRecorder()
ping(response, request)
resp := response.Result()
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
handlerError(t, err)
if string(b) != "pong" {
t.Errorf("http test error,get %s,want: pong", string(b))
}
}

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

测试性能

1
2
3
4
5
6
7
8
9
10
func BenchmarkHttp(b *testing.B) {
for i := 0; i < b.N; i++ {
request := httptest.NewRequest("GET", "http://127.0.0.1:8080/ping", nil)
response := httptest.NewRecorder()
ping(response, request)
if r := response.Body.String(); r != "pong" {
b.Errorf("http test error,get %s,want: pong", r)
}
}
}

若是使用框架启动http服务,例如通过Gin框架实现http服务

1
2
3
4
5
6
7
8
9
10
11
func UserList(c *gin.Context) {
c.JSON(200, gin.H{"res": "hello world"})
return
}

func Server() *gin.Engine {
e := gin.Default()
e.Handle("GET", "/user", UserList)
e.Run(":8080")
return e
}

通过httptest测试

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
func TestUserList(t *testing.T) {
tests := []struct {
name string
param string
expect string
}{
{
name: "testcase",
param: "",
expect: "hello world",
},
}
r := Server()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(
"GET",
"/user",
strings.NewReader(tt.param),
)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)

var resp map[string]string
err := json.Unmarshal([]byte(w.Body.String()), &resp)
assert.Nil(t, err)
assert.Equal(t, tt.expect, resp["res"])
})
}
}

此时并没有http服务启动起来,直接测试的heanlder函数。

通过发送http请求测试服务

1
2
3
4
5
6
7
8
9
10
func TestHttpConn(t *testing.T) {
response, err := http.Get("http://localhost:8080/ping")
handlerError(t, err)
defer response.Body.Close()
b, err := ioutil.ReadAll(response.Body)
handlerError(t, err)
if string(b) != "pong" {
t.Errorf("http test error,get %s,want: pong", string(b))
}
}

gotests

测试自动代码生成,gotests

安装

1
go get -u github.com/cweill/gotests/...

用法

1
2
-all  对所有函数和方法生成
-excl 通过正则匹配,匹配的方法和函数不生成
1
gotests -all ./ > demo1_test.go

生成的测试代码,自动使用表驱动测试

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

import "testing"

func TestGetArea(t *testing.T) {
type args struct {
x int
y int
}
tests := []struct {
name string
args args
want int
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetArea(tt.args.x, tt.args.y); got != tt.want {
t.Errorf("GetArea() = %v, want %v", got, tt.want)
}
})
}
}

func TestAdd(t *testing.T) {
type args struct {
x int
y int
}
tests := []struct {
name string
args args
want int
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.args.x, tt.args.y); got != tt.want {
t.Errorf("Add() = %v, want %v", got, tt.want)
}
})
}
}

func TestSub(t *testing.T) {
type args struct {
x int
y int
}
tests := []struct {
name string
args args
want int
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Sub(tt.args.x, tt.args.y); got != tt.want {
t.Errorf("Sub() = %v, want %v", got, tt.want)
}
})
}
}

如果当前目录下已经有测试文件,通过gotests生成的测试代码会仿照已存在的测试文件模板。

示例函数

在其他包调用函数时,IED会将示例展示出来,godoc文档服务器也会将示例展示出来。

1
2
3
4
5
6
func ExampleAdd() {
x, y := 1, 2
z := Add(x, y)
fmt.Println(z)
// Output: 3
}

展示效果

1
2
3
4
5
Example
Code:
x, y := 1, 2 z := Add(x, y) fmt.Println(z)
Output:
3

mock测试

当待测试的函数、对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件IO等,这种场景就非常适合使用mock/stub测试。用mock对象模拟依赖项的行为。

面向数据库连接mock测试

使用mysql或者pgsql是一样的,选择对应驱动即可

安装go-sqlmock

1
github.com/DATA-DOG/go-sqlmock

官方的例子可查看:go-sqlmock

pgsql

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

import (
_ "github.com/lib/pq"
"gorm.io/gorm"
)

type Product struct {
Product int64
Views int64
}

type ProductViews struct {
UserID int64
Product int64
}

// 更新操作
func UpdateViews(db *gorm.DB, productID int64) (err error) {
err = db.Model(&Product{}).Where("product", productID).Update("views", gorm.Expr("views + ?", 1)).Error
return err
}

// 创建操作
func CreateProduct(db *gorm.DB, userID, productID int64) (err error) {
product := ProductViews{
UserID: userID,
Product: productID,
}
err = db.Model(&ProductViews{}).Create(product).Error
return
}

mock测试

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

import (
"database/sql"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"regexp"
"testing"
)

var (
mock sqlmock.Sqlmock
db *sql.DB
gdb *gorm.DB
)

func TestMain(m *testing.M) {
db, mock, _ = sqlmock.New()
defer db.Close()

dialector := postgres.New(postgres.Config{
DSN: "sqlmock_db_0",
DriverName: "postgres",
Conn: db,
PreferSimpleProtocol: true,
})

gdb, _ = gorm.Open(dialector, &gorm.Config{})

m.Run()
}

func Test_UpdateViews(t *testing.T) {
type args struct {
db *gorm.DB
productID int64
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "test",
args: args{
db: gdb,
productID: 200,
},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectBegin()
// 字符串匹配,要匹配的一丝不差,所以如果存在时间参数,需要注意时间偏差。
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "products" SET "views"=views + $1 WHERE "product" = $2`)).WithArgs(int64(1), tt.args.productID).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

if err := UpdateViews(tt.args.db, tt.args.productID); (err != nil) != tt.wantErr {
t.Errorf("recordStats() error = %v, wantErr %v\n", err, tt.wantErr)
}
})
}
}

func Test_CreateProduct(t *testing.T) {
type args struct {
db *gorm.DB
userID int64
productID int64
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "test",
args: args{
db: gdb,
userID: 100,
productID: 200,
},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO "product_views" ("user_id","product") VALUES ($1,$2)`)).WithArgs(tt.args.userID, tt.args.productID).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

if err := CreateProduct(tt.args.db, tt.args.userID, tt.args.productID); (err != nil) != tt.wantErr {
t.Errorf("recordStats() error = %v, wantErr %v\n", err, tt.wantErr)
}
})
}
}

测试结果

1
2
3
4
5
6
7
8
9
=== RUN   Test_UpdateViews
=== RUN Test_UpdateViews/test
--- PASS: Test_UpdateViews (0.00s)
--- PASS: Test_UpdateViews/test (0.00s)
=== RUN Test_CreateProduct
=== RUN Test_CreateProduct/test
--- PASS: Test_CreateProduct (0.00s)
--- PASS: Test_CreateProduct/test (0.00s)
PASS

redis测试

官方文档和示例:miniredis

安装miniredis

1
go get github.com/alicebob/miniredis/v2

redis功能

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 redis

import (
"context"
"github.com/go-redis/redis/v8"
"strings"
"time"
)

const (
KeyValidWebsite = "app:valid:website:list"
)

func DoSomethingWithRedis(rdb *redis.Client, key string) bool {
ctx := context.TODO()
if !rdb.SIsMember(ctx, KeyValidWebsite, key).Val() {
return false
}
val, err := rdb.Get(ctx, key).Result()
if err != nil {
return false
}
if !strings.HasPrefix(val, "https://") {
val = "https://" + val
}
// 设置 blog key 五秒过期
if err := rdb.Set(ctx, key, val, 5*time.Second).Err(); err != nil {
return false
}
return true
}

单元测试

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

import (
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"testing"
"time"
)

var (
rdb *redis.Client
s *miniredis.Miniredis
)

func TestMain(m *testing.M) {
s, _ = miniredis.Run()
defer s.Close()

// 连接mock的redis server
rdb = redis.NewClient(&redis.Options{
Addr: s.Addr(), // mock redis server的地址
})
m.Run()
}

func TestDoSomethingWithRedis(t *testing.T) {
type args struct {
rdb *redis.Client
key string
value string
}
type res struct {
want bool
wantValue string
}
tests := []struct {
name string
args args
res res
}{
{
name: "normal",
args: args{
rdb: rdb,
key: "mitaka",
value: "xiaoyeshiyu.com",
},
res: res{
want: true,
wantValue: "https://xiaoyeshiyu.com",
},
},
{
name: "error",
args: args{
rdb: rdb,
key: "error",
value: "error.com",
},
res: res{
want: true,
wantValue: "abc",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Set(tt.args.key, tt.args.value)
s.SAdd(KeyValidWebsite, tt.args.key)
if got := DoSomethingWithRedis(tt.args.rdb, tt.args.key); got != tt.res.want {
t.Errorf("DoSomethingWithRedis() = %v, want %v", got, tt.res.want)
}
// 可以手动检查redis中的值是否复合预期
if got, err := s.Get(tt.args.key); (err != nil || got != tt.res.wantValue) && !tt.res.want {
t.Fatalf("%s has the wrong value,want %s,get %s", tt.args.key, tt.res.wantValue, got)
}
// 也可以使用帮助工具检查,工具检查无法通过手动设置结果判断
//s.CheckGet(t, tt.args.key, tt.res.wantValue)

// 过期检查
s.FastForward(5 * time.Second) // 快进4秒
if s.Exists(tt.args.key) {
t.Fatalf("%s should not have existed anymore", tt.args.key)
}
})
}
}

面向接口mock测试

mock

推荐阅读

安装

1
go install github.com/golang/mock/[email protected]

例如测试通过数据库获取信息,模拟测试接口

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

// DB 数据接口
type DB interface {
Get(key string) (int, error)
Add(key string, value int) error
}

// GetFromDB 根据key从DB查询数据的函数
func GetFromDB(db DB, key string) int {
if v, err := db.Get(key); err == nil {
return v
}
return -1
}

GetFromDB函数生成单元测试代码,就可以通过mock 这个DB来测试

1
mockgen -source=db.go -destination=mocks/db_mock.go -package=mocks

在本地生成一个mocks目录,里面有db_mock.go文件

在单元测试中引用mocks包中的方法即可

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

import (
"github.com/golang/mock/gomock"
"gostudy/demotest/mock/mocks"
"testing"
)

func TestGetFromDB(t *testing.T) {
// 创建gomock控制器,用来记录后续的操作信息
ctrl := gomock.NewController(t)
// 断言期望的方法都被执行
// Go1.14+的单测中不再需要手动调用该方法
defer ctrl.Finish()
// 调用mockgen生成代码中的NewMockDB方法
// 这里mocks是我们生成代码时指定的package名称
m := mocks.NewMockDB(ctrl)
// 打桩(stub)
// 当传入Get函数的参数为mitaka时返回1和nil
o1 := m.
EXPECT().
Get(gomock.Eq("mitaka")). // 参数
Return(1, nil). // 返回值
Times(1) // 调用次数
// 当传入Get函数的参数为xiaoyeshiyu时返回100和nil
o2 := m.
EXPECT().
Get(gomock.Eq("xiaoyeshiyu")).
Return(100, nil).
Times(10)

// 顺序调用
gomock.InOrder(o1, o2)
// 调用GetFromDB函数时传入上面的mock对象
if v := GetFromDB(m, "mitaka"); v != 1 {
t.Fatal()
}
for i := 0; i < 10; i++ {
if v := GetFromDB(m, "xiaoyeshiyu"); v != 100 {
t.Fatal()
}
}
}

模糊测试

Golang 1.18版本开始引入模糊测试(Go Fuzz),使用方法与单元测试一样,相比单元测试,模糊测试集成了各项测试用例,可以测试出一些想象不到的、边界情况下的问题。

1
2
3
4
5
6
7
8
func Equal(a []byte, b []byte) bool {
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

上面的例子,当出现out fo range的情况就无法测试出来

1
2
3
4
5
6
func FuzzEqual(f *testing.F) {
f.Add([]byte{1, 2, 3}, []byte{1, 2, 3}) // 增加语料
f.Fuzz(func(t *testing.T, a []byte, b []byte) { // fuzz测试
Equal(a, b)
})
}

运行测试

1
2
3
4
5
6
7
8
9
10
11
12
# go test -v -fuzztime=10s -fuzz .    // -fuzz . 使用fuzz测试,-fuzztime=10s 表示测试时间
=== FUZZ FuzzEqual
fuzz: elapsed: 0s, gathering baseline coverage: 0/11 completed
failure while testing seed corpus entry: FuzzEqual/84ed65595ad05a58e293dbf423c1a816b697e2763a29d7c37aa476d6eef6fd60
fuzz: elapsed: 0s, gathering baseline coverage: 1/11 completed
--- FAIL: FuzzEqual (0.02s)
--- FAIL: FuzzEqual (0.00s)
testing.go:1349: panic: runtime error: index out of range [0] with length 0
goroutine 25 [running]:

FAIL
exit status 1

可以看到fuzz测试会模拟一些情况index out of range [0] with length 0

修改Equal函数代码

1
2
3
4
5
6
7
8
9
10
11
func Equal(a []byte, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

再次测试

1
2
3
4
5
6
7
8
9
10
go test -v -fuzztime=10s -fuzz .
=== FUZZ FuzzEqual
fuzz: elapsed: 0s, gathering baseline coverage: 0/11 completed
fuzz: elapsed: 0s, gathering baseline coverage: 11/11 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1657019 (552217/sec), new interesting: 0 (total: 11)
fuzz: elapsed: 6s, execs: 3371719 (571634/sec), new interesting: 0 (total: 11)
fuzz: elapsed: 9s, execs: 5038271 (555462/sec), new interesting: 0 (total: 11)
fuzz: elapsed: 10s, execs: 5603379 (518577/sec), new interesting: 0 (total: 11)
--- PASS: FuzzEqual (10.10s)
PASS

推荐阅读:

fuzz测试