什么是单元测试

单元测试是对一组封装良好、实现有限特定功能的代码进行正确性检验的工作。对于面向过程编程来说,一个单元可以是一个函数;对于面向对象编程来说,一个单元就是类的方法;在 Go 中,一个 package(下称包) 就是一个单元。

为什么需要单元测试

相比没有单元测试的代码,有单元测试的代码具有以下优点:

  • 最直接的当然是提高代码的正确率和可靠性;
  • 尽早发现问题。问题越早发现,解决的难度和成本就越低;
  • 提高团队各成员之间的沟通效率。新代码未经过测试就发布到测试环境,可能会带有明显的 bug,这会制造非常多繁琐的协作流程:测试人员发现 Bug → 新建 Bug 卡 → 开发人员收到 Bug 卡后开始排查并修复 → 提交代码 → 运行 CI 流水线 → 再次发布 → 开发人员修改 Bug 卡状态 → 测试人员验证;
  • 保证重构的正确性。随着功能的增加,重构(修改老代码)几乎是无法避免的,很多时候我们没有信心做代码重构,就是因为担心重构会导致引入新的 Bug。有了单元测试,只要在改完代码后运行一下单元测试就知道改动对整个系统的影响了;
  • 优化代码设计。编写单元测试需要开发人员让自己的代码通过多个测试案例,这会驱动开发人员仔细思考代码的设计和实现,有利于开发人员加深对代码功能的理解,从而形成更合理的设计和结构;
  • 即使你不测试你写的代码,总有人会!公司的 QA 团队,甚至产品的最终用户。如果我们重视自己辛勤开发的产品,就绝无可能(在知情的情况下,或者说不了解质量和产品可靠性的情况下)让用户为我们完成测试工作!

Go 单元测试特性

Go 标准库的testing包提供了单元测试的基础支持。假设我们有代码 split 包的 split.go:

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

import "strings"

// Split slices s into all substrings separated by sep and
// returns a slice of the substrings between those separators.
// "a/b/c" => ["a", "b", "c"]
func Split(s, sep string) []string {
var result []string
i := strings.Index(s, sep)
for i > -1 {
result = append(result, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
return append(result, s)
}

测试代码必须放在文件名以_test.go结尾文件里,split_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 TestSplit(t *testing.T) {
// map 的 key 是测试案例的名字,value 是一个结构体,描述了测试案例的输出参数、正确的输出参数
tests := map[string]struct {
input string
sep string
want []string
}{
"Simple": {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"Without separator": {input: "abc", sep: "/", want: []string{"abc"}},
"Wrong separator": {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
}

// 使用 if 做等值判断断言
for name, tc := range tests {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("%s FAIL, expected: %v, got: %v", name, tc.want, got)
} else {
t.Logf("%s PASS", name)
}
}
}

接下来我会使用split.gosplit_test.go介绍 Go 提供的单元测试特性。

go test 运行测试

要运行split_test.go的测试代码,在split包下运行go test -v .命令:

1
2
3
4
5
❯ go test -v .
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok .../split 0.459s

可以看到 split.goSplit(s, sep string) []string 通过了目前的测试。如果要运行整个项目或者包含有多个包的目录下的测试,在目标目录运行命令go test ./..即可。

子测试

使用 testing.TRun(name string, f func(t *T)) bool 启动一个子测试。
使用子测试的好处首先是子测试自动将测试案例名字和测试案例的运行实例关联,并且会列出所有的测试案例的测试结果,输出结果更直观;而且,子测试在单独的 goroutine 中运行,效率更高;最后,使用子测试还可以单独运行某个(指定名字)/某一类(正则匹配)测试案例,比如go test -run=TestSplit/Simple -v:

1
2
3
4
5
6
7
go test -run=TestSplit/Without_separator -v
=== RUN TestSplit
=== RUN TestSplit/Without_separator
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/Without_separator (0.00s)
PASS
ok .../split 0.116s

目前的测试案例不够完善,再加些案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestSplit(t *testing.T) {
// map 的 key 是测试案例的名字,value 是一个结构体,描述了测试案例的输出参数、正确的输出参数
tests := map[string]struct {
input string
sep string
want []string
}{
"Simple": {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"Without separator": {input: "abc", sep: "/", want: []string{"abc"}},
"Only separator": {input: "/", sep: "/", want: []string{}},
"Only multiple separators": {input: "////", sep: "/", want: []string{}},
"Wrong separator": {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
"Starts with separator": {input: "/a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"Starts with separators": {input: "//a/b//c///d", sep: "/", want: []string{"a", "b", "c", "d"}},
"Ends with separator": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
"Ends with separators": {input: "a/b//c///d///", sep: "/", want: []string{"a", "b", "c", "d"}},
"Multiple separators in the middle": {input: "a/b//c///d", sep: "/", want: []string{"a", "b", "c", "d"}},
}
...
}

运行测试:

1
2
3
4
5
6
7
❯ go test -v .
=== RUN TestSplit
split_test.go:34: Multiple separators in the middle FAIL, expected: [a b c d], got: [a b c d]
--- FAIL: TestSplit (0.00s)
FAIL
FAIL .../split 0.271s
FAIL

加上更多测试案例后 Split 函数没能通过新的测试案例,这表示这个函数还有 Bug, 需要完善。同样直接贴上完善后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Split(s, sep string) []string {
result := make([]string, 0)
i := strings.Index(s, sep)
for i > -1 {
if i != 0 {
result = append(result, s[:i])
}
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
if len(s) != 0 {
return append(result, s)
}
return result
}

再运行一次,发现通过了所有的测试案例:

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
❯ go test -v .
=== RUN TestSplit
=== RUN TestSplit/Multiple_separators_in_the_middle
=== RUN TestSplit/Only_multiple_separators
=== RUN TestSplit/Starts_with_separator
=== RUN TestSplit/Starts_with_separators
=== RUN TestSplit/Ends_with_separator
=== RUN TestSplit/Ends_with_separators
=== RUN TestSplit/Simple
=== RUN TestSplit/Without_separator
=== RUN TestSplit/Wrong_separator
=== RUN TestSplit/Only_separator
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/Multiple_separators_in_the_middle (0.00s)
--- PASS: TestSplit/Only_multiple_separators (0.00s)
--- PASS: TestSplit/Starts_with_separator (0.00s)
--- PASS: TestSplit/Starts_with_separators (0.00s)
--- PASS: TestSplit/Ends_with_separator (0.00s)
--- PASS: TestSplit/Ends_with_separators (0.00s)
--- PASS: TestSplit/Simple (0.00s)
--- PASS: TestSplit/Without_separator (0.00s)
--- PASS: TestSplit/Wrong_separator (0.00s)
--- PASS: TestSplit/Only_separator (0.00s)
PASS
ok .../split (cached)

性能测试

性能测试函数同样需要放在_test.go文件里,同时性能测试函数需要以Benchmark开头。使用go test -bench=.

1
2
3
4
5
6
7
8
❯ go test -bench=. -benchmem
goos: windows
goarch: amd64
pkg: .../split
cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
BenchmarkSplit-12 6357198 179.9 ns/op 112 B/op 3 allocs/op
PASS
ok .../split 1.841s

测试结果解析:

  • BenchmarkSplit-12 中的 12 即GOMAXPROCS数值 , 可以通过 -cpu 参数指定;
  • 6357198 是 for 循环执行的次数;
  • 179.9 ns/op 每次调用耗时;
  • 112 B/op 每次调用申请了 112 字节的内存;
  • 3 allocs/op 表示每次调用发生了 3 次内存分配。

其他相关参数:

  • -benchmem 参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销;
  • -cpu 参数改变 GOMAXPROCS,支持传入一个列表作为参数:-cpu=2,4,6;
  • -benchmark 参数指定基准测试的时间,默认时间是 1s;
  • -count 参数可以用来设置 benchmark 的轮数。

测试覆盖率

go test命令加上-coverprofile即可输出测试覆盖率,同时go tool支持生成 HTML 格式的覆盖率报告:

1
2
3
4
5
6
7
8
9
# 运行测试并输出 .out 文件
❯ go test -coverprofile cover.out .
ok .../split 0.430s coverage: 100.0% of statements

# 生成测试报告
go tool cover -html=cover.out -o cover.html

# 打开测试报告
explorer cover.html

覆盖率报告:

split 包测试覆盖率

单元测试技巧

了解过 Go 单元测试特性后,我们再来了解一些常见的测试技巧。

测试表

测试表技术使用一个容器类数据结构(List, Map, Set 等)存放测试案例,容器里的每个元素即为一个测试案例。元素一般是一个结构体,包含测试案例的名字、输入参数、期待的输出结果等。运行测试代码时遍历此容器依次执行测试案例,并对比测试代码返回的结果和期待的输出结果,从而对测试代码是否通过测试作出断言。我们之前代码片段中的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tests := map[string]struct {
input string
sep string
want []string
}{
"Simple": {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"Without separator": {input: "abc", sep: "/", want: []string{"abc"}},
"Only separator": {input: "/", sep: "/", want: []string{}},
"Only multiple separators": {input: "////", sep: "/", want: []string{}},
"Wrong separator": {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
"Starts with separator": {input: "/a/b/c", sep: "/", want: []string{"a", "b", "c"}},
"Starts with separators": {input: "//a/b//c///d", sep: "/", want: []string{"a", "b", "c", "d"}},
"Ends with separator": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
"Ends with separators": {input: "a/b//c///d///", sep: "/", want: []string{"a", "b", "c", "d"}},
"Multiple separators in the middle": {input: "a/b//c///d", sep: "/", want: []string{"a", "b", "c", "d"}},
}

就是一个测试表,其中mapkey是测试案例的名字,inputsep是输入参数,want是期待的输出结果。

断言

断言简单来说就是判断代码返回的结果是否跟期望的一致。Go 标准库的testing包非常易用,可以满足日常开发的基本需求,但testing包没有提供断言语句,这使得代码中的 if !reflect.DeepEqual(tc.want, got) {} 这种啰嗦的断言片段非常多:

1
2
3
4
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("expected: %v, got: %v", tc.want, got)
}

因此一般会引进 conveytestify, 它们提供了很多方便的断言语句,可简化判断逻辑,使得程序更加易读:

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
// 使用 if 做等值判断断言
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("expected: %v, got: %v", tc.want, got)
}
})
}

// 使用 testify 断言
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := Split(tc.input, tc.sep)
Equal(t, got, tc.want)
})
}

// 使用 convey 断言
Convey("TestSplit", t, StackError, func() {
for name, tc := range tests {
Convey(name, func() {
got := Split(tc.input, tc.sep)
So(got, ShouldResemble, tc.want)
})
}
})

Convey 的输出结果更友好,所以我一般会使用 Convey.

Stub

Stub 即桩,是指用来代替复杂的依赖对象或者未实现代码的代码,从而将测试代码的复杂或者不稳定的依赖链条阻断。一般应用于全局/全包的静态变量或者函数,且需要在运行后重置为原始值。在 Go 生态里最常用的 stub 库是 gostub.

stub.go:

1
2
3
4
5
6
7
8
9
10
11
// 为静态变量打桩
var configFile = "config.json"
func GetConfig() ([]byte, error) {
return ioutil.ReadFile(configFile)
}

// 为函数打桩
var timeNow = time.Now // time.Now 是一个函数。这里将用 stub 为 time.Now() 打桩
func GetDate() int {
return timeNow().Day()
}

stub_test.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestGetConfig(t *testing.T) {
Convey("TestBulkInsert", t, StackError, func() {
stubs := gostub.Stub(&configFile, "/tmp/test.config")
defer stubs.Reset()

data, err := GetConfig()
So(err, ShouldBeNil)
t.Logf(string(data)) // 打印的是 /tmp/test.config 文件的内容
})
}

func TestGetDate(t *testing.T) {
stubs := gostub.Stub(&timeNow, func() time.Time {
return time.Date(2015, 6, 18, 0, 0, 0, 0, time.UTC)
})
defer stubs.Reset()

t.Log(GetDate()) // 打印结果是 18
}

Fake

Fake 对象实际上实现了跟待测试代码完全相同的功能,但通常采取一些捷径,这使得它们适合在单元测试中使用,但不适合在生产环境使用。比如,一个 Fake repository 把数据存储在内存中而不是存储组件,这使得依赖这个 Repository 的代码可以轻松地完成测试,无需准备一个 MySQL 之类的存储组件。
以下代码演示了 Fake 的工作方式:

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
// IUserRepository 接口定义了一个 SelectOne 方法,查询指定 ID 的用户
type IUserRepository interface {
SelectOne(ctx context.Context, id int64) (*model.User, error)
}

// UserRepository 实现了 IUserRepository, 会向真实的数据库发起查询
type UserRepository struct {
DB *sql.DB
}

func (r *UserRepository) SelectOne(ctx context.Context, id int64) (*model.User, error) {
return model.FindUser(ctx, r.DB, id)
}

// FakeUserRepository 也实现了 IUserRepository, 与 UserRepository 不同,它从内存查找。
// 切片其实可以很好地模拟数据库表
type FakeUserRepository struct {
Users []*model.User
}

func (r *FakeUserRepository) SelectOne(ctx context.Context, id int6$$4) (*model.User, error) {
for i := range r.Users {
if r.Users[i].ID == id {
return r.Users[i], nil
}
}
return nil, sql.ErrNoRows
}

func fn() {
// repo 变量的类型是 IUserRepository 接口类型
var repo IUserRepository = new(UserRepository)
// new(FakeUserRepository) 新建了一个 FakeUserRepository 类型的变量,
// 因为它也实现了 IUserRepository 接口,所以可以赋值给 repo 变量, 也就是说 100% 兼容地互相取代。
repo = new(FakeUserRepository)
_ = repo
}

Mock

Mock 对象实现了与被测试对象相同的接口。当我们需要进行行为验证时,我们可以使用 Mock 对象作为观察点,通过简单地调整 Mock 对象的行为,可以避免被测试对象难以触发的执行路径。

Mock 对象的使用场景是当我们并不希望真的调用生产环境下的代码或者在测试中难于验证真实代码执行效果的时候,我们会用 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
func TestBulkInsert(t *testing.T) {
users := make([]*model.User, 0, len(Users))
for _, user := range Users {
users = append(users, user)
}

Convey("TestBulkInsert", t, StackError, func() {
Convey("Begin transaction failed", func() {
db, mock, err := sqlmock.New()
So(err, ShouldBeNil)
defer db.Close()

e := errors.New("begin transaction failed")
mock.ExpectBegin().WillReturnError(e) // 模拟开始事务时遇到错误
mock.ExpectExec(fmt.Sprintf("INSERT INTO %s", model.TableNames.Users)).WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
...
})

Convey("Commit transaction failed", func() {
db, mock, err := sqlmock.New()
So(err, ShouldBeNil)
defer db.Close()

mock.ExpectBegin()
mock.ExpectExec(`INSERT INTO users (name,email,password,gender,date_of_birth)`).WithArgs(args)
mock.ExpectRollback()
e := errors.New("commit transaction failed")
mock.ExpectCommit().WillReturnError(e) // 模拟提交事务时遇到错误
err = New(db).BulkInsert(context.Background(), users)
So(err, ShouldNotBeNil)
})
})
}

其中数据库开始事务和提交事务语句有可能返回 error, 但我们可能很难触发 error, 让 Mock 模拟这一行为最为合适不过。

Fake, Stub 和 Mock 的区别

  • Fake 一般是手动完成整个实现,如果要调整 Fake 对象的行为需要修改代码,同时 Fake 对象本身的正确性也需要验证;Mock 一般是使用工具自动生成,且生成代码后 Mock 可以轻松地调整 Mock 整个对象的行为,这让 Mock 对象能轻松地完成被测试对象的执行路径。
  • Stub 需要手动实现,Mock 可以借助自动化工具;Mock 一般应用于面向对象编程,在测试时将整个对象替换成 Mock 对象,从而达到了一次性给测试对象的所有方法打桩的目的,Stub 则适常用于少数几个变量或者函数打桩。

另外,如果要顺利地使用 Mock, 还得为你依赖的组件找到 Mock 库,比如依赖 SQL 得有 sqlmock 库,依赖 Redis 得有 redismock, 依赖 Kafka 得有 brokermock, 如果没有只能自己实现或者不使用 Mock.

总结,Mock 更像是 Fake 的超集且更方便快捷,所以更推荐组合使用 Mock 和 Stub.

如何在项目中应用这些技巧

认识上文介绍到的技术和技巧是一件事,将它们应用到项目中又是另一件事,而且颇有挑战,但网络上介绍应用这些技巧和技术的文章更少之又少。

在项目实战中一般喜欢采用这样的分层:

  1. Controller: 负责接收请求,做一些请求合法性验证工作,然后调用 service 层的业务逻辑接口,得到处理结果后稍加处理并返回给 client;
  2. Service: 负责接收 controller 层的请求,然后调用 repository 层进行存/取/发送数据,最后将处理结果返回给 controller 层;
  3. Repository: 负责通过访问存储组件完成 service 层的存/取/发送数据请求,并返回结果;
  4. Model: 模型层。

此外,我还会遵循一条简单的规则:只从上层调用下层,不会从下层调用上层。

这个分层模式受领域驱动开发所启发。按照职责、类型、业务去合理地分层是非常关键的,如果所有代码都在 controller 层实现,代码之间复杂的依赖关系会让我们无法编写单元测试代码。

如何把上文提到的单元测试技术和技巧应用到这种结构的项目中呢?从项目分层的角度去分析,由于上层依赖下层,所以下层需要为上层提供很好的测试支持。首先一个层作为一个个体,需要让自己的若干个代码单元能独立地通过单元测试,其次一个层作为整个调用链上的一环,需要能够方便地完成初始化以便为上层提供支持,以便不阻碍上层代码完成测试。基于此,我得出了以下方式:

  1. Controller: Go 提供了 httptest 包,结合 web 框架可以方便地编写接口测试。接口测试的工作主要包括构造参数,然后请求对应的接口,最后断言请求响应是否与期望的一致。通过微调请求参数可以构造出一个测试表,依次运行测试表里的测试案例即可;
  2. Service: service 调用 repository 层,有少数情况下可能会有同层其它 service 的调用。这一层的单元测试可以这样子写:
    • 对 Repository 层的调用使用 Fake 技术完成测试,每个 repository 接口都可以要求有一套自己的 Fake repository,避免多个 service 发生数据竞争;
    • 对于无法验证结果的第三方服务调用(比如邮件发送服务),使用 Mock 完成测试。
  3. Repository: 使用存储组件完成单元测试,不建议使用 Fake/Mock,因为使用 Fake/Mock 无法发现代码在访问存储组件时可能出现的问题:
    • 关系型数据库是非常复杂的一个环节。关系型数据库是强 schema 的,字段的名字、类型、大小非常严格,如果使用类似 gorm 之类的没有 type-safe 的 ORM,经常使用字符串字面量去表达要操作的表名、字段名,很有可能出现拼写错误,但是代码编辑器和编译器无法帮助发现这类问题,只有在实际操作数据库时才会发现错误。如果应用的 ORM 代码没有映射正确是会出错的,我们需要单元测试去找出错误;
    • 通过实际操作数据库去完成单元测试,可以防止发生没有做 schema migration 就发布新版的应用这种事情。
  4. Model: 定义了数据库表对应的模型,没有执行流程和逻辑,无需测试。

总结

给要写单元测试的你提若干建议:

  • 单元测试不应该留下”脏数据“,测试完毕应该做好清理工作;
  • 单元测试使用一套独立依赖组件,比如不同的数据库名、不同的 Redis DB。这主要是避免单元测试对本地开发环境或测试环境的数据改动;
  • 在给代码编写测试代码时,要先看看这些代码的依赖项是否提供了单元测试支持,如果没有,就先为依赖项编写测试代码。

良好的开发习惯:

  • 写代码;
  • 写对应的测试代码,准备完善的测试案例;
  • 朝着 100% 测试覆盖率努力(一般很难达到 100%, 总是会有无法测试的代码)。