Go工程化(八) 单元测试

本系列为 Go 进阶训练营 笔记,预计 2021Q2 完成更新,访问 博客: Go进阶训练营, 即可查看当前更新进度,部分文章篇幅较长,使用 PC 大屏浏览体验更佳。

3 月进度: 05/15 3 月开始会尝试爆更模式,争取做到两天更新一篇文章,如果感兴趣可以拉到文章最下方获取关注方式。

从我们开始开发以来,应该很多人都提到过测试的重要性,而在所有的测试类型当中,以单元测试为代表的单元测试无疑是成本最小,性价比最高的一种,而且有的公司为了保证质量会要求单元测试覆盖率的指标(包括我们)

image.png
所以希望看完这篇文章,希望大家可以很快的在我们之前提出的项目结构上进行单元测试的编写,可以做到又快又好。
本文分为两部分,前半段会简单介绍一下 go 的单元测试怎么写,不会有很复杂的技巧,如果已经比较了解可以跳过,后半段会介绍一下我们在项目当中该如何写 “单元测试”

单元测试简明教程

go test

一个简单的 🌰

项目结构

1
2
3
.
├── max.go
└── max_test.go

max.go

1
2
3
4
5
6
7
8
9
package max

// Int get the max
func Int(a, b int) int {
if a > b {
return a
}
return b
}

max_test.go

1
2
3
4
5
6
7
8
9
package max

import "testing"

func TestInt(t *testing.T) {
if got := Int(1, 2); got != 2 {
t.Errorf("exp: %d, got: %d", 2, got)
}
}

执行结果

1
2
3
▶ go test
PASS
ok code/max 0.006s

单元测试文件说明

  • 文件名必须是_test.go结尾的,这样在执行go test的时候才会执行到相应的代码。
  • 你必须import testing这个包。
  • 所有的测试用例函数必须是Test开头。
  • 测试用例会按照源代码中写的顺序依次执行。
  • 测试函数TestX()的参数是testing.T,我们可以使用该类型来记录错误或者是测试状态。
  • 测试格式:func TestXxx (t *testing.T),Xxx部分可以为任意的字母数字的组合,但是首字母不能是小写字母[a-z],例如Testintdiv是错误的函数名。
  • 函数中通过调用testing.TError, Errorf, FailNow, Fatal, FatalIf方法,说明测试不通过,调用Log方法用来记录测试的信息。

表驱动测试

在实际编写单元测试的时候,我们往往需要执行多个测试用例,期望达到更全面的覆盖效果,这时候就需要使用表驱动测试了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestInt_Table(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
}{
{name: "a>b", a: 10, b: 2, want: 10},
{name: "a<b", a: 1, b: 2, want: 2},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Int(tt.a, tt.b); got != tt.want {
t.Errorf("exp: %d, got: %d", tt.want, got)
}
})
}
}

执行结果

1
2
3
4
5
6
7
8
9
10
go test -v
=== RUN TestInt
--- PASS: TestInt (0.00s)
=== RUN TestInt_Table
=== RUN TestInt_Table/a>b
=== RUN TestInt_Table/a<b
--- PASS: TestInt_Table (0.00s)
--- PASS: TestInt_Table/a>b (0.00s)
--- PASS: TestInt_Table/a<b (0.00s)
PASS

随机执行
上面的例子是按照顺序执行的,单元测试大多随机执行更能够发现一些没有注意到的错误, 如下面的这个例子,利用 map 的特性我们很容易将上面这个例子改造为随机执行的单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestInt_RandTable(t *testing.T) {
tests := map[string]struct {
a int
b int
want int
}{
"a>b": {a: 10, b: 2, want: 10},
"a<b": {a: 1, b: 2, want: 2},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := Int(tt.a, tt.b); got != tt.want {
t.Errorf("exp: %d, got: %d", tt.want, got)
}
})
}
}

testfiy

标准库为我们提供了一个还不错的测试框架,但是没有提供断言的功能,testify 包含了 断言、mock、suite 三个功能,mock 推荐使用官方的 gomock

testify/assert 提供了非常多的方法,这里为大家介绍最为常用的一些,所有的方法可以访问 https://godoc.org/github.com/stretchr/testify/assert 查看

1
2
3
4
5
6
7
8
9
10
// 判断两个值是否相等
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 判断两个值不相等
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 测试失败,测试中断
func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool
// 判断值是否为nil,常用于 error 的判断
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 判断值是否不为nil,常用于 error 的判断
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

我们可以发现,断言方法都会返回一个 bool 值,我们可以通过这个返回值判断断言成功/失败,从而做一些处理

一个例子

1
2
3
4
func TestInt_assert_fail(t *testing.T) {
got := Int(1, 2)
assert.Equal(t, 1, got)
}

执行结果, 可以看到输出十分的清晰

1
2
3
4
5
6
7
8
9
10
=== RUN   TestInt_assert_fail
--- FAIL: TestInt_assert_fail (0.00s)
max_test.go:62:
Error Trace: max_test.go:62
Error: Not equal:
expected: 1
actual : 2
Test: TestInt_assert_fail
FAIL
FAIL code/max 0.017s

gomock

安装

注意: 请在非项目文件夹执行下面这条命令

1
GO111MODULE=on GOPROXY=https://goproxy.cn go get github.com/golang/mock/mockgen

mockgen 是一个代码生成工具,可以对包或者源代码文件生成指定接口的 Mock 代码

生成 Mock 代码

指定源文件

1
2
3
mockgen -source=./.go  -destination=./a_mock.go  INeedMockInterface

mockgen -source=源文件路径 -destination=写入文件的路径(没有这个参数输出到终端) 需要mock的接口名(多个接口逗号间隔)

指定包路径

1
mockgen  -destination=写入文件的路径(没有这个参数输出到终端) 包路径 需要mock的接口名(多个接口逗号间隔)

一个简单的 gomock 🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UserAge 获取用户年龄
type UserAge interface {
GetAge(user string) int
}

// Simple 一个简单的例子
func Simple(user string, age UserAge) string {
return fmt.Sprintf("%s age is: %d", user, age.GetAge(user))
}

func TestSimple(t *testing.T) {
// 新建一个mock对象
ctrl := gomock.NewController(t)
age := mock_mock.NewMockUserAge(ctrl)

// mock 返回值
age.EXPECT().GetAge(gomock.Any()).Return(1).AnyTimes()

assert.Equal(t, "a age is: 1", Simple("a", age))
}
本文只是简单介绍用法,详细使用及 API 说明可以查看官方仓库,https://github.com/golang/mock

项目 “单元测试”

接下来就是本文的重点,在我们之前提到的 Go 工程化(二) 项目目录结构 当中,如何编写单元测试。虽然这里说的是单元测试,其实后面讲的其实很多不是单元测试,像 repo 层,如果涉及到数据库后面就会讲到我们一般会启动一个真实的数据库来测试,这其实已经算是集成测试了,但是它仍然是轻量级的。

service

这一层主要处理的 dto 和 do 数据之间的相互转换,本身是不含什么业务逻辑的,目前我们使用的是 http,所以在这一层的测试一般会使用 httptest 来模拟实际请求的测试。然后在对 usecase 层的调用上,我们使用 gomock mock 掉相关的接口,简化我们的测试。如果你不想写的那么麻烦,也可以不用启用 httptest 来测试,直接测试 service 层的代码也是可以的,不过这样的话,service 层的代码测试的内容就没有多少了,也就是看转换数据的时候符不符合预期。

这一层主要完成的测试是

  • 参数的校验是否符合预期
  • 数据的转换是否符合预期,如果你像我一样偷懒使用了类似 copier 的工具的话一定要写这部分的单元测试,不然还是很容易出错,容易字段名不一致导致 copier 的工作不正常

当然如果时间有限的话,这一层的测试也不是必须的,因为接入层相对来说变化也比较快一点,这是说写了单元测试,基本上在测试阶段很少会出现由于参数的问题提交过来的 bug

同样我们直接看一个例子, 首先是 service 层的代码,可以看到逻辑很简单,就是调用了一下,usecase 层的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var _ v1.BlogServiceHTTPServer = &PostService{}

// PostService PostService
type PostService struct {
Usecase domain.IPostUsecase
}

// CreateArticle 创建文章
func (p *PostService) CreateArticle(ctx context.Context, req *v1.Article) (*v1.Article, error) {
article, err := p.Usecase.CreateArticle(ctx, domain.Article{
Title: req.Title,
Content: req.Content,
AuthorID: req.AuthorId,
})

if err != nil {
return nil, err
}

var resp v1.Article
err = copier.Copy(&resp, &article)
return &resp, err
}

再看看单元测试
首先是初始化,之前我们讲到初始化的时候我们一般在 cmd 当中使用 wire 自动生成,但是在单元测试中 wire 并不好用,并且由于单元测试的时候我们的依赖项其实没有真实的依赖项那么复杂我们只需要关心当前这一层的依赖即可,所以一般在单元测试的时候我都是手写初始化
一般会向下面这样,使用一个 struct 包装起来,因为在后面像是 mock 的 usecase 还需要调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type testPostService struct {
post *PostService
usecase *mock_domain.MockIPostUsecase
handler *gin.Engine
}

func initPostService(t *testing.T) *testPostService {
ctrl := gomock.NewController(t)
usecase := mock_domain.NewMockIPostUsecase(ctrl)
service := &PostService{Usecase: usecase}

handler := gin.New()
v1.RegisterBlogServiceHTTPServer(handler, service)

return &testPostService{
post: service,
usecase: usecase,
handler: handler,
}
}

实际的测试,这一块主要是为了展示一个完整的单元测试所以贴的代码稍微长了一些,后面的两层具体的单元测试代码都大同小异,我就不再贴了,主要的思路就是把依赖的接口都用 gomock 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
func TestPostService_CreateArticle(t *testing.T) {
s := initPostService(t)
s.usecase.EXPECT().
CreateArticle(gomock.Any(), gomock.Eq(domain.Article{Title: "err", AuthorID: 1})).
Return(domain.Article{}, fmt.Errorf("err"))
s.usecase.EXPECT().
CreateArticle(gomock.Any(), gomock.Eq(domain.Article{Title: "success", AuthorID: 2})).
Return(domain.Article{Title: "success"}, nil)

tests := []struct {
name string
params *v1.Article
want *v1.Article
wantStatus int
wantCode int
wantErr string
}{
{
name: "参数错误 author_id 必须",
params: &v1.Article{
Title: "1",
Content: "2",
AuthorId: 0,
},
want: nil,
wantStatus: 400,
wantCode: 400,
},
{
name: "失败",
params: &v1.Article{
Title: "err",
AuthorId: 1,
},
want: nil,
wantStatus: 500,
wantCode: -1,
},
{
name: "成功",
params: &v1.Article{
Title: "success",
AuthorId: 2,
},
want: &v1.Article{
Title: "success",
},
wantStatus: 200,
wantCode: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 下面这些一般都会封装在一起,这里是为了演示

// 初始化请求
b, err := json.Marshal(tt.params)
require.NoError(t, err)
uri := fmt.Sprintf("/v1/author/%d/articles", tt.params.AuthorId)
req := httptest.NewRequest(http.MethodPost, uri, bytes.NewReader(b))

// 初始化响应
w := httptest.NewRecorder()

// 调用相应的handler接口
s.handler.ServeHTTP(w, req)

// 提取响应
resp := w.Result()
defer resp.Body.Close()
require.Equal(t, tt.wantStatus, resp.StatusCode)

// 读取响应body
respBody, _ := ioutil.ReadAll(resp.Body)
r := struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *v1.Article `json:"data"`
}{}
require.NoError(t, json.Unmarshal(respBody, &r))

assert.Equal(t, tt.wantCode, r.Code)
assert.Equal(t, tt.want, r.Data)
})
}
}

usecase

usecase 是主要的业务逻辑,所以一般写单元测试的时候都应该先写这一层的单远测试,而且这一层我们没有任何依赖,只需要把 repo 层的接口直接 mock 掉就可以了,是非常纯净的一层,其实也就这一层的单元测试才是真正的单元测试

repo

repo 层我们一般依赖 mysql 或者是 redis 等数据库,在测试的时候我们可以直接启动一个全新的数据库用于测试即可。

本地

直接使用 docker run 对应的数据库就可以了

ci/cd

我们的 ci cd 是使用的 gitlab,gitlab 有一个比较好用的功能是指定 service,只需要指定对应的数据库镜像我们就可以在测试容器启动的时候自动启动对应的测试数据库容器,并且每一次都是全新的空数据库。我们只需要每次跑单元测试的时候先跑一下数据库的 migration 就可以了。

下面给出一个配置示例

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
test:
stage: test
image: golang:1.15-alpine-test
services:
- redis:v4.0.11
- postgres:10-alpine
- docker:19-dind
variables:
POSTGRES_DB: test_db
POSTGRES_USER: root
POSTGRES_PASSWORD: 1234567
GOPROXY: "这里设置 proxy 地址"
CGO_ENABLED: 0
script:
- go mod download
- go run db/*.go
- mkdir artifacts
- gotestsum -- -p 1 -v -coverprofile=./artifacts/coverage.out -coverpkg=./... ./...
# 单元测试统计去除一些不需要测试的代码
- |
cat ./artifacts/coverage.out | \
grep -v "/mock/" | grep -v "/db/" | grep -v "pb.go" > ./artifacts/coverage.out2
- go tool cover -func=./artifacts/coverage.out2
# 捕获单元测试覆盖率在 gitlab job 上显示
coverage: '/total:\s+.*\s+\d+\.\d+%/'
artifacts:
paths:
- artifacts/coverage.out

总结

单元测试的介绍就到这里了,这篇文章从单元测试的基本写法,再到我们在项目当中如何写单元测试都简单介绍了一下,希望你看了这篇文章能有所收获。

同时我们 Go 工程化 这一章节的内容也接近尾声了,整理的材料也挺多的,下一篇就是这一节的最后一篇文章,讲一讲我在真实改造一个项目的时候遇到的一些问题和解决方案。

参考文献

  1. Go 进阶训练营-极客时间
  2. https://github.com/stretchr/testify
  3. https://github.com/golang/mock
  4. https://pkg.go.dev/github.com/jinzhu/copier

第 0 期已经结束,想要报名后面课程的同学,我联系极客时间为大家争取到了读者专属优惠
扫描下方微信公众号二维码,发送【福利】获取专属优惠,比官方优惠更给力哦

关注我获取更新

wechat
知乎
开发者头条
github