扫码订阅《 》或入驻星球,即可阅读文章!

GOLANG ROADMAP

阅读模式

  • 沉浸
  • 自动
  • 日常
首页
Go学习
  • Go学院

    • Go小课
    • Go视界
    • Go小考
    • Go实战
  • Go资源

    • 优质课程
    • 在线宝典
    • 资源下载
    • 帮找资源
训练营 🔥
  • Go体系课&实战训练营
  • 升值加薪陪跑训练营
Go求职
  • 求职刷题

    • 企业题库
    • 面试宝典
    • 求职面经
  • 求职服务

    • 内推互助
    • 求职助力
    • 内推公司
Go友会
  • 城市
  • 校园
推广返佣
  • 返佣排行
  • 返佣规则
  • 推广学院
实验区
  • Go周边
  • Go宝典

    • 推荐图书
    • 精品博文
  • Go开源

    • Go仓库
    • Go月刊
更多
  • 用户中心

    • 我的信息
    • 我的返佣
    • 我的消息
  • 玩转星球

    • 星球介绍
    • 星主权益
    • 吐槽专区
    • 成长记录
  • 合作交流

    • 商务合作
    • 讲师招募
    • 生态伙伴
author-avatar

GOLANG ROADMAP


首页
Go学习
  • Go学院

    • Go小课
    • Go视界
    • Go小考
    • Go实战
  • Go资源

    • 优质课程
    • 在线宝典
    • 资源下载
    • 帮找资源
训练营 🔥
  • Go体系课&实战训练营
  • 升值加薪陪跑训练营
Go求职
  • 求职刷题

    • 企业题库
    • 面试宝典
    • 求职面经
  • 求职服务

    • 内推互助
    • 求职助力
    • 内推公司
Go友会
  • 城市
  • 校园
推广返佣
  • 返佣排行
  • 返佣规则
  • 推广学院
实验区
  • Go周边
  • Go宝典

    • 推荐图书
    • 精品博文
  • Go开源

    • Go仓库
    • Go月刊
更多
  • 用户中心

    • 我的信息
    • 我的返佣
    • 我的消息
  • 玩转星球

    • 星球介绍
    • 星主权益
    • 吐槽专区
    • 成长记录
  • 合作交流

    • 商务合作
    • 讲师招募
    • 生态伙伴
  • 宝典介绍

    • Go进阶训练营·学习笔记
  • Week01微服务

  • Week02Go错误处理

  • Week03Go并发编程

  • Week04Go工程化

    • Go工程化(一) 架构整洁之道阅读笔记
    • Go工程化(二) 项目目录结构
    • Go工程化(三) 依赖注入框架 wire
    • Go工程化(四) API 设计上 项目结构 & 设计
    • Go工程化(五) API 设计下 基于 protobuf 自动生成 gin 代码
    • Go工程化(六) 配置管理
    • Go工程化(七) Go Module
    • Go工程化(八) 单元测试
    • Go工程化(九) 项目重构实践

扫码订阅《 》或入驻星球,即可阅读文章!

Go工程化(八) 单元测试


mohuishou

# 序

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

[image.png

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

# 单元测试简明教程

# go test

一个简单的 🌰

项目结构

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

max.go

package max

// Int get the max
func Int(a, b int) int {
	if a > b {
		return a
	}
	return b
}
1
2
3
4
5
6
7
8
9

max_test.go

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
4
5
6
7
8
9

执行结果

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

单元测试文件说明

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

表驱动测试

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

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
11
12
13
14
15
16
17
18
19

执行结果

▶ 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
1
2
3
4
5
6
7
8
9
10

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

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)
			}
		})
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# testfiy

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

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

// 判断两个值是否相等
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
1
2
3
4
5
6
7
8
9
10

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

一个例子

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

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

=== 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
1
2
3
4
5
6
7
8
9
10

# gomock

安装

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

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

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

生成 Mock 代码

指定源文件

mockgen -source=./.go  -destination=./a_mock.go  INeedMockInterface

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

指定包路径

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

一个简单的 gomock 🌰

// 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))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

本文只是简单介绍用法,详细使用及 API 说明可以查看官方仓库,https://github.com/golang/mock

# 项目 “单元测试”

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

# service

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

这一层主要完成的测试是

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

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

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

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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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

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,
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

实际的测试,这一块主要是为了展示一个完整的单元测试所以贴的代码稍微长了一些,后面的两层具体的单元测试代码都大同小异,我就不再贴了,主要的思路就是把依赖的接口都用 gomock mock 掉,这样实际写单元测试代码的时候就会比较简单。

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)
		})
	}
}
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

# usecase

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

# repo

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

本地

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

ci/cd

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

下面给出一个配置示例

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

# 总结

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

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

# 参考文献

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

# 相关推荐

  • 评论系统架构设计(一)功能拆分&架构设计
  • Go工程化(九) 项目重构实践
  • Go工程化(七) Go Module
  • 序
  • 单元测试简明教程
  • go test
  • testfiy
  • gomock
  • 项目 “单元测试”
  • service
  • usecase
  • repo
  • 总结
  • 参考文献
  • 相关推荐