惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
The Exploit Database - CXSecurity.com
S
Secure Thoughts
A
Arctic Wolf
V
Vulnerabilities – Threatpost
S
Schneier on Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threat Research - Cisco Blogs
AWS News Blog
AWS News Blog
NISL@THU
NISL@THU
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
P
Palo Alto Networks Blog
L
Lohrmann on Cybersecurity
Schneier on Security
Schneier on Security
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Scott Helme
Scott Helme
L
LINUX DO - 最新话题
L
LangChain Blog
量子位
T
Threatpost
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
腾讯CDC
W
WeLiveSecurity
Last Week in AI
Last Week in AI
美团技术团队
The GitHub Blog
The GitHub Blog
The Last Watchdog
The Last Watchdog
C
CERT Recently Published Vulnerability Notes
月光博客
月光博客
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LINUX DO - 热门话题
Microsoft Security Blog
Microsoft Security Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
Troy Hunt's Blog
Webroot Blog
Webroot Blog
云风的 BLOG
云风的 BLOG
博客园 - 叶小钗
V2EX - 技术
V2EX - 技术
雷峰网
雷峰网
Security Latest
Security Latest
小众软件
小众软件
J
Java Code Geeks
博客园 - Franky
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Spread Privacy
Spread Privacy
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Recent Commits to openclaw:main
Recent Commits to openclaw:main
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
T
The Blog of Author Tim Ferriss
M
MIT News - Artificial intelligence

Mohuishou

如何实现支持多集群的 Kubernetes Operator? 第三方应用如何调用我们 kubebuilder 生成的自定义资源? Kubernetes 简明教程 k8s job 为何迟迟不能结束? Go 工程化(十一) 如何优雅的写出 repo 层代码 Go 工程化(十) 如何在整洁架构中使用事务? 给博客添加章节目录 使用 Notion Database 管理静态博客文章 一个普通 Go 开发的三年 4. localhost 就一定是 localhost 么? Go可用性(七) 总结: 一张图串联可用性知识点 Go可用性(六) 熔断 10. 总结 9. kubebuilder 进阶: 源码分析 8. kubebuilder 进阶: webhook 7. kubebuilder 进阶: 测试 6. kubebuilder 实战: status & event 5. kubebuilder 实战: CRUD 4. kustomize 简明教程 3. KubeBuilder 简明教程 2. Kind: 如何快速搭建本地 K8s 开发环境? 1. Operator概述: 如何对 Kubernetes 进行扩展 Go可用性(五) 自适应限流 Go可用性(四) 漏桶算法 Go可用性(三) 令牌桶的实现 rate/limt Go可用性(二) 令牌桶原理及使用 Go可用性(一) 隔离设计 Go并发编程(十二) Singleflight Go工程化(九) 项目重构实践 Go工程化(七) Go Module Go工程化(六) 配置管理 Go工程化(五) API 设计下: 基于 protobuf 自动生成 gin 代码 Go工程化(四) API 设计上: 项目结构 & 设计 Go工程化(三) 依赖注入框架 wire Go工程化(二) 项目目录结构 Go工程化(一) 架构整洁之道阅读笔记 Go并发编程(十一) 总结 Go并发编程(十) 深入理解 Channel Go并发编程(九) 深入理解 Context Go并发编程(八) 深入理解 sync.Once Go并发编程(七) 深入理解 errgroup Go并发编程(六) 深入理解 WaitGroup Go并发编程(五) 深入理解 sync/atomic Go并发编程(四) 深入理解 Mutex Go并发编程(三) data race Go并发编程(二) Go 内存模型 Go并发编程(一) goroutine Go错误处理最佳实践 微服务(二) 服务发现&多租户 微服务(一) 微服务概览 5. 栈下: 深入理解 defer 4. 栈上: 如何实现一个计算器 Go Struct 初始化风格的抉择 3. 数组下: 使用 GDB 调试 Golang 代码 2. 数组上: 深入理解 slice 1. 链表: 深入理解container/list&LRU缓存的实现 Go设计模式24-总结(更新完毕) Go设计模式23-中介模式 Go设计模式22-解释器模式 Go设计模式21-命令模式 Go设计模式20-备忘录模式 Go设计模式19-访问者模式 Go设计模式18-迭代器模式 Go设计模式17-状态模式 Go设计模式16-职责链模式(Gin的中间件实现) Go设计模式15-策略模式 Go模板模式14-模板模式 Go设计模式13-观察者模式(实现简单的EventBus) Go设计模式12-享元模式 Go设计模式11-组合模式 Go设计模式10-门面模式 Go设计模式09-适配器模式 Go设计模式08-装饰器模式 Go设计模式07-桥接模式 Go设计模式06-代理模式(generate实现类似动态代理) Go设计模式05-创建型模式总结 Go设计模式04-原型模式 Go设计模式03-建造者模式 Go设计模式02-工厂模式&DI容器 笔记-让你最快速地改善代码质量的20条编程规范 Go设计模式01-单例模式 一点拙见-如何写好一个技术预研报告? Go Web小技巧(四)在单个仓库中支持多个 go mod 模块 Go Web 小技巧(三)Gin 参数绑定 Go Web 小技巧(二)GORM 使用自定义类型 Go Web 小技巧(一)简化Gin接口代码 善用工具之postman高级用法概述 go generate and ast hexo-next-algolia-search全文搜索 docker镜像瘦身&优化 GORM避坑指南之含关联关系的更新 Github Actions介绍&自动构建Github Pages博客 在blog中内嵌在线PPT 记一次net http内存泄漏 使用TravisCI自动部署Blog 使用Goland调试Go程序 一个十分边缘的gorm的bug Httprouter介绍及源码阅读 Gin源码阅读 从0.1开始
Go工程化(八) 单元测试
Mohuishou · 2021-03-09 · via Mohuishou

注:本文已发布超过一年,请注意您所使用工具的相关版本是否适用

本系列为 Go 进阶训练营 笔记,访问 博客: 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](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

关注我获取更新

猜你喜欢