单元测试
如果把开发程序比作盖房子,那么咱们必须确保所有的用料都是合格的,否则盖起来的房子就会存在问题。对于程序而言,咱们能够将盖房子的砖头、钢筋、水泥等当做一个个性能单元,如果每个单元是合格的,咱们将有信念认为程序是强壮的。单元测试(Unit Test,UT)就是测验性能单元是否合格的工具。
一个没有 UT 的我的项目,它的代码品质与工程保障是堪忧的。但在理论开发工作中,很多程序员往往并不写测试代码,他们的开发周期可能如下图所示。
而做了充沛 UT 的程序员,他们的我的项目开发周期更大概率如下。
我的项目开发中,不写 UT 兴许能使代码交付更快,然而咱们无奈保障写进去的代码真的可能正确地执行。写 UT 能够缩小前期解决 bug 的工夫,也能让咱们释怀地应用本人写进去的代码。从久远来看,后者更能无效地节俭开发工夫。
既然 UT 这么重要,是什么起因在阻止开发人员写 UT 呢?这是因为除了开发人员的惰性习惯之外,编写 UT 代码同样存在难点。
- 代码耦合度高,短少必要的形象与拆分,以至于不晓得如何写 UT。
- 存在第三方依赖,例如依赖数据库连贯、HTTP 申请、数据缓存等。
可见,编写可测试代码的难点就在于 解耦 与依赖。
接口与 Mock
对于难点 1,咱们须要面向接口编程。在《接口 Interface——塑造强壮与可扩大的 Go 应用程序》一文中,咱们探讨了应用接口给代码带来的灵便解耦与高扩大个性。接口是对一类对象的抽象性形容,表明该类对象能提供什么样的服务,它最次要的作用就是解耦调用者和实现者,这成为了可测试代码的要害。
对于难点 2,咱们能够通过 Mock 测试来解决。Mock 测试就是在测试过程中,对于某些不容易结构或者不容易获取的对象,用一个虚构的对象来创立以便测试的测试方法。
如果咱们的代码都是面向接口编程,调用方与服务方将是松耦合的依赖关系。在测试代码中,咱们就能够 Mock 出另一种接口的实现,从而很容易地替换掉第三方的依赖。
测试工具
1. 自带测试库:testing
在介绍 Mock 测试之前,先看一下 Go 中最简略的测试单元应该如何写。假如咱们在 math.go
文件下有以下两个函数,当初咱们须要对它们写测试案例。
// math.go
package math
func Add(x, y int) int {return x + y}
func Multi(x, y int) int {return x * y}
如果咱们的 IDE 是 Goland,它有一个十分好用的一键测试代码生成性能。
如上图所示,光标置于函数名之上,右键抉择 Generate,咱们能够抉择生成整个 package、以后 file 或者以后选中函数的测试代码。以 Tests for selection 为例,Goland 会主动在以后 math.go
同级目录新建测试文件math_test.go
,内容如下。
// math_test.go
package math
import "testing"
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)
}
})
}
}
能够看到,在 Go 测试常规中,单元测试的默认组织形式就是写在以 _test.go
结尾的文件中,所有的测试方法也都是以 Test
结尾并且只承受一个 testing.T
类型的参数。同时,如果咱们要给函数名为 Add
的办法写单元测试,那么对应的测试方法个别会被写成 TestAdd
。
当测试模板生成之后,咱们只需将测试案例增加至 TODO
即可。
{
"negative + negative",
args{-1, -1},
-2,
},
{
"negative + positive",
args{-1, 1},
0,
},
{
"positive + positive",
args{1, 1},
2,
},
此时,运行测试文件,能够发现所有测试案例,均胜利通过。
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestAdd/negative_+_negative
--- PASS: TestAdd/negative_+_negative (0.00s)
=== RUN TestAdd/negative_+_positive
--- PASS: TestAdd/negative_+_positive (0.00s)
=== RUN TestAdd/positive_+_positive
--- PASS: TestAdd/positive_+_positive (0.00s)
PASS
2. 断言库:testify
简略理解了 Go 内置 testing 库的测试写法后,举荐一个好用的断言测试库:testify。testify 具备常见断言和 mock 的工具链,最重要的是,它可能与内置库 testing 很好地配合应用,其我的项目地址位于 https://github.com/stretchr/t…。
如果采纳 testify 库,须要引入"github.com/stretchr/testify/assert"
。之外,上述测试代码中以下局部
if got := Add(tt.args.x, tt.args.y); got != tt.want {t.Errorf("Add() = %v, want %v", got, tt.want)
}
更改为如下断言模式
assert.Equal(t, Add(tt.args.x, tt.args.y), tt.want, tt.name)
testify 提供的断言办法帮忙咱们疾速地对函数的返回值进行测试,从而缩小测试代码工作量。它可断言的类型十分丰盛,例如断言 Equal、断言 NIl、断言 Type、断言两个指针是否指向同一对象、断言蕴含、断言子集等。
不要小瞧这一行代码,如果咱们在测试案例中,将 ”positive + positive” 的期望值改为 3,那么测试后果中会主动提供报错信息。
...
=== RUN TestAdd/positive_+_positive
math_test.go:36:
Error Trace: math_test.go:36
Error: Not equal:
expected: 2
actual : 3
Test: TestAdd/positive_+_positive
Messages: positive + positive
--- FAIL: TestAdd/positive_+_positive (0.00s)
Expected :2
Actual :3
...
3. 接口 mock 框架:gomock
介绍完根本的测试方法的写法后,咱们须要探讨基于接口的 Mock 办法。在 Go 语言中,最通用的 Mock 伎俩是通过 Go 官网的 gomock 框架来主动生成其 Mock 办法。该我的项目地址位于 https://github.com/golang/mock。
为了不便读者了解,本文举一个小明玩手机的例子。小明喜爱玩手机,他每天都须要通过手机聊微信、玩王者、逛知乎,如果某天没有干这些事件,小明就没方法睡觉。在该情景中,咱们能够将手机形象成接口如下。
// mockDemo/equipment/phone.go
type Phone interface {WeiXin() bool
WangZhe() bool
ZhiHu() bool}
小明手上有一部十分老的 IPhone6s,咱们为该手机对象实现 Phone 接口。
// mockDemo/equipment/phone6s.go
type Iphone6s struct {
}
func NewIphone6s() *Iphone6s {return &Iphone6s{}
}
func (p *Iphone6s) WeiXin() bool {fmt.Println("Iphone6s chat wei xin!")
return true
}
func (p *Iphone6s) WangZhe() bool {fmt.Println("Iphone6s play wang zhe!")
return true
}
func (p *Iphone6s) ZhiHu() bool {fmt.Println("Iphone6s read zhi hu!")
return true
}
接着,咱们定义 Person
对象用来示意小明,并定义 Person
对象的生存函数 dayLife
和入睡函数goSleep
。
// mockDemo/person.go
type Person struct {
name string
phone equipment.Phone
}
func NewPerson(name string, phone equipment.Phone) *Person {
return &Person{
name: name,
phone: phone,
}
}
func (x *Person) goSleep() {fmt.Printf("%s go to sleep!", x.name)
}
func (x *Person) dayLife() bool {fmt.Printf("%s's daily life:\n", x.name)
if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {x.goSleep()
return true
}
return false
}
最初,咱们把小明和 iphone6s 对象实例化进去,并开启他一天的生存。
//mockDemo/main.go
func main() {phone := equipment.NewIphone6s()
xiaoMing := NewPerson("xiaoMing", phone)
xiaoMing.dayLife()}
// output
xiaoMing's daily life:
Iphone6s chat wei xin!
Iphone6s play wang zhe!
Iphone6s read zhi hu!
xiaoMing go to sleep!
因为小明每天必须刷完手机能力睡觉,即Person.goSleep
,那么小明是否睡觉依赖于手机。
依照以后代码,如果小明的手机坏了,或者小明换了一个手机,那他就没方法睡觉了,这必定是万万不行的。因而咱们须要把小明对某特定手机的依赖 Mock 掉,这个时候 gomock 框架排上了用场。
如果没有下载 gomock 库,则执行以下命令获取
GO111MODULE=on go get github.com/golang/mock/mockgen
通过执行以下命令对 phone.go 中的 Phone 接口 Mock
mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go
在执行该命令前,以后我的项目的组织构造如下
.
├── equipment
│ ├── iphone6s.go
│ └── phone.go
├── go.mod
├── go.sum
├── main.go
└── person.go
执行 mockgen
命令之后,在 equipment/phone.go
的同级目录,新生成了测试文件 mock_iphone.go
(它的代码主动生成性能,是通过 Go 自带 generate 工具实现的,感兴趣的读者能够浏览《Go 工具之 generate》一文),其局部内容如下
...
// MockPhone is a mock of Phone interface
type MockPhone struct {
ctrl *gomock.Controller
recorder *MockPhoneMockRecorder
}
// MockPhoneMockRecorder is the mock recorder for MockPhone
type MockPhoneMockRecorder struct {mock *MockPhone}
// NewMockPhone creates a new mock instance
func NewMockPhone(ctrl *gomock.Controller) *MockPhone {mock := &MockPhone{ctrl: ctrl}
mock.recorder = &MockPhoneMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {return m.recorder}
// WeiXin mocks base method
func (m *MockPhone) WeiXin() bool {m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WeiXin")
ret0, _ := ret[0].(bool)
return ret0
}
// WeiXin indicates an expected call of WeiXin
func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
}
...
此时,咱们的 person.go
中的 Person.dayLife
办法就能够测试了。
func TestPerson_dayLife(t *testing.T) {
type fields struct {
name string
phone equipment.Phone
}
// 生成 mockPhone 对象
mockCtl := gomock.NewController(t)
mockPhone := equipment.NewMockPhone(mockCtl)
// 设置 mockPhone 对象的接口办法返回值
mockPhone.EXPECT().ZhiHu().Return(true)
mockPhone.EXPECT().WeiXin().Return(true)
mockPhone.EXPECT().WangZhe().Return(true)
tests := []struct {
name string
fields fields
want bool
}{{"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
{"case2", fields{"mocked phone", mockPhone}, true},
}
for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {
x := &Person{
name: tt.fields.name,
phone: tt.fields.phone,
}
assert.Equal(t, tt.want, x.dayLife())
})
}
}
对接口进行 Mock,能够让咱们在未实现具体对象的接口性能前,或者该接口的调用代价十分高时,也能对业务代码进行测试。而且在开发过程中,咱们同样能够利用 Mock 对象,不必因为期待接口实现方实现相干性能,从而停滞后续的开发。
在这里咱们可能领会到在 Go 程序中接口对于测试的重要性。没有接口的 Go 代码,单元测试会十分难写。所以,如果一个稍大型的我的项目中,没有任何接口,那么该项目标品质肯定是堪忧的。
4. 常见三方 mock 依赖库
在上文中提到,因为存在某些存在第三方依赖,会让咱们的代码难以测试。但其实曾经有一些比拟成熟的 mock 依赖库可供咱们应用。因为篇幅起因,以下列出的一些 mock 库将不再贴出示例代码,详细信息可通过对应的我的项目地址进行理解。
- go-sqlmock
这是 Go 语言中用以测试数据库交互的 SQL 模仿驱动库,其我的项目地址为 https://github.com/DATA-DOG/g…。它而无需真正地数据库连贯,就可能在测试中模仿 sql 驱动程序行为,十分有助于保护测试驱动开发(TDD)的工作流程。
- httpmock
用于模仿内部资源的 http 响应,它应用模式匹配的形式匹配 HTTP 申请的 URL,在匹配到特定的申请时就会返回事后设置好的响应。其我的项目地址为 https://github.com/jarcoal/ht…。
- gripmock
它用于模仿 gRPC 服务的服务器,通过应用.proto 文件生成对 gRPC 服务的实现,其我的项目地址为 https://github.com/tokopedia/…。
- redismock
用于测试与 Redis 服务器的交互,其我的项目地址位于 https://github.com/elliotchan…。
5. 猴子补丁:monkey patch
如果上述的计划都不能很好的写出测试代码,这时能够思考应用猴子补丁。猴子补丁简略而言就是属性在运行时的动静替换,它在实践上能够替换运行时中的所有函数。这种测试形式在动静语言例如 Python 中比拟适合。在 Go 中,monkey 库通过在运行时重写正在运行的可执行文件并插入跳转到您要调用的函数来实现 Monkey patching。我的项目作者写道:这个操作很不平安,不倡议任何人在测试环境之外进行应用。其我的项目地址为 https://github.com/bouk/monkey。
monkey 库的 API 比较简单,例如能够通过调用 monkey.Patch(<target function>, <replacement function>)
来实现对函数的替换,以下是操作示例。
package main
import (
"fmt"
"os"
"strings"
"bou.ke/monkey"
)
func main() {monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {s := make([]interface{}, len(a))
for i, v := range a {s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
}
return fmt.Fprintln(os.Stdout, s...)
})
fmt.Println("what the hell?") // what the *bleep*?
}
须要留神的是,如果启用了内联,则 monkey 有时无奈进行 patching,因而,咱们须要尝试在禁用内联的状况下运行测试。例如以上例子,咱们须要通过以下命令执行。
$ go build -o main -gcflags=-l main.go;./main
what the *bleep*?
总结
在我的项目开发中,单元测试是重要且必须的。对于单元测试的两大难点:解耦 与依赖 ,咱们的代码能够采纳 面向接口 +mock 依赖 的形式进行组织,将依赖都做成可插拔的,那在单元测试外面隔离依赖就是一件瓜熟蒂落的事件。
另外,本文探讨了一些实用的测试工具,包含自带测试库 testing 的疾速生成测试代码,断言库 testify 的断言应用,接口 mock 框架 gomock 如何 mock 接口办法和一些常见的三方依赖 mock 库举荐,最初再介绍了测试大杀器猴子补丁,当然,不到万不得已,不要应用猴子补丁。
最初,在这些测试工具的应用上,本文的内容也只是一些浅尝辄止的介绍,心愿读者可能在理论我的项目中多写写单元测试,深刻领会 TDD 的开发思维。