单元测试
- 1: Golang单元测试-wire依赖注入
- 2: Golang单元测试-简单示例
- 3: Golang单元测试01
- 4: Golang单元测试02
- 5: Golang单元测试03
- 6: Golang单元测试04
1 - Golang单元测试-wire依赖注入
Golang依赖注入框架wire全攻略
在前一阵介绍单元测试的系列文章中,曾经简单介绍过wire依赖注入框架。但当时的wire还处于alpha阶段,不过最近wire已经发布了首个beta版,API发生了一些变化,同时也承诺除非万不得已,将不会破坏API的兼容性。在前文中,介绍了一些wire的基本概况,本篇就不再重复,感兴趣的小伙伴们可以回看一下: 搞定Go单元测试(四)—— 依赖注入框架(wire)。本篇将具体介绍wire的使用方法和一些最佳实践。
本篇中的代码的完整示例可以在这里找到:wire-examples
Installing
go get github.com/google/wire/cmd/wire
Quick Start
我们先通过一个简单的例子,让小伙伴们对wire有一个直观的认识。下面的例子展示了一个简易wire依赖注入示例:
$ ls
main.go wire.go
main.go
package main
import "fmt"
type Message struct {
msg string
}
type Greeter struct {
Message Message
}
type Event struct {
Greeter Greeter
}
// NewMessage Message的构造函数
func NewMessage(msg string) Message {
return Message{
msg:msg,
}
}
// NewGreeter Greeter构造函数
func NewGreeter(m Message) Greeter {
return Greeter{Message: m}
}
// NewEvent Event构造函数
func NewEvent(g Greeter) Event {
return Event{Greeter: g}
}
func (e Event) Start() {
msg := e.Greeter.Greet()
fmt.Println(msg)
}
func (g Greeter) Greet() Message {
return g.Message
}
// 使用wire前
func main() {
message := NewMessage("hello world")
greeter := NewGreeter(message)
event := NewEvent(greeter)
event.Start()
}
/*
// 使用wire后
func main() {
event := InitializeEvent("hello_world")
event.Start()
}*/
wire.go
// +build wireinject
// The build tag makes sure the stub is not built in the final build.
package main
import "github.com/google/wire"
// InitializeEvent 声明injector的函数签名
func InitializeEvent(msg string) Event{
wire.Build(NewEvent, NewGreeter, NewMessage)
return Event{} //返回值没有实际意义,只需符合函数签名即可
}
调用wire命令生成依赖文件:
$ wire
wire: github.com/DrmagicE/wire-examples/quickstart: wrote XXXX\github.com\DrmagicE\wire-examples\quickstart\wire_gen.go
$ ls
main.go wire.go wire_gen.go
wire_gen.go wire生成的文件
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
package main
// Injectors from wire.go:
func InitializeEvent(msg string) Event {
message := NewMessage(msg)
greeter := NewGreeter(message)
event := NewEvent(greeter)
return event
}
使用前 V.S 使用后
...
/*
// 使用wire前
func main() {
message := NewMessage("hello world")
greeter := NewGreeter(message)
event := NewEvent(greeter)
event.Start()
}*/
// 使用wire后
func main() {
event := InitializeEvent("hello_world")
event.Start()
}
...
使用wire后,只需调一个初始化方法既可得到Event了,对比使用前,不仅减少了三行代码,并且无需再关心依赖之间的初始化顺序。
示例传送门: quickstart
Provider & Injector
provider和injector是wire的两个核心概念。
provider: a function that can produce a value. These functions are ordinary Go code. injector: a function that calls providers in dependency order. With Wire, you write the injector’s signature, then Wire generates the function’s body. github.com/google/wire…
通过提供provider函数,让wire知道如何产生这些依赖对象。wire根据我们定义的injector函数签名,生成完整的injector函数,injector函数是最终我们需要的函数,它将按依赖顺序调用provider。
在quickstart的例子中,NewMessage,NewGreeter,NewEvent都是provider,wire_gen.go中的InitializeEvent函数是injector,可以看到injector通过按依赖顺序调用provider来生成我们需要的对象Event。
上述示例在wire.go中定义了injector的函数签名,注意要在文件第一行加上
// +build wireinject
...
用于告诉编译器无需编译该文件。在injector的签名定义函数中,通过调用wire.Build方法,指定用于生成依赖的provider:
// InitializeEvent 声明injector的函数签名
func InitializeEvent(msg string) Event{
wire.Build(NewEvent, NewGreeter, NewMessage) // <--- 传入provider函数
return Event{} //返回值没有实际意义,只需符合函数签名即可
}
该方法的返回值没有实际意义,只需要符合函数签名的要求即可。
高级特性
quickstart示例展示了wire的基础功能,本节将介绍一些高级特性。
接口绑定
根据依赖倒置原则(Dependence Inversion Principle),对象应当依赖于接口,而不是直接依赖于具体实现。
抽象成接口依赖更有助于单元测试哦! 搞定Go单元测试(一)——基础原理 搞定Go单元测试(二)—— mock框架(gomock)
在quickstart的例子中的依赖均是具体实现,现在我们来看看在wire中如何处理接口依赖:
// UserService
type UserService struct {
userRepo UserRepository // <-- UserService依赖UserRepository接口
}
// UserRepository 存放User对象的数据仓库接口,比如可以是mysql,restful api ....
type UserRepository interface {
// GetUserByID 根据ID获取User, 如果找不到User返回对应错误信息
GetUserByID(id int) (*User, error)
}
// NewUserService *UserService构造函数
func NewUserService(userRepo UserRepository) *UserService {
return &UserService{
userRepo:userRepo,
}
}
// mockUserRepo 模拟一个UserRepository实现
type mockUserRepo struct {
foo string
bar int
}
// GetUserByID UserRepository接口实现
func (u *mockUserRepo) GetUserByID(id int) (*User,error){
return &User{}, nil
}
// NewMockUserRepo *mockUserRepo构造函数
func NewMockUserRepo(foo string,bar int) *mockUserRepo {
return &mockUserRepo{
foo:foo,
bar:bar,
}
}
// MockUserRepoSet 将 *mockUserRepo与UserRepository绑定
var MockUserRepoSet = wire.NewSet(NewMockUserRepo,wire.Bind(new(UserRepository), new(*mockUserRepo)))
在这个例子中,UserService依赖UserRepository接口,其中mockUserRepo是UserRepository的一个实现,由于在Go的最佳实践中,更推荐返回具体实现而不是接口。所以mockUserRepo的provider函数返回的是*mockUserRepo这一具体类型。wire无法自动将具体实现与接口进行关联,我们需要显示声明它们之间的关联关系。通过wire.NewSet和wire.Bind将*mockUserRepo与UserRepository进行绑定:
// MockUserRepoSet 将 *mockUserRepo与UserRepository绑定
var MockUserRepoSet = wire.NewSet(NewMockUserRepo,wire.Bind(new(UserRepository), new(*mockUserRepo)))
定义injector函数签名:
...
func InitializeUserService(foo string, bar int) *UserService{
wire.Build(NewUserService,MockUserRepoSet) // 使用MockUserRepoSet
return nil
}
...
示例传送门: binding-interfaces
返回错误
在前面的例子中,我们的provider函数均只有一个返回值,但在某些情况下,provider函数可能会对入参做校验,如果参数错误,则需要返回error。wire也考虑了这种情况,provider函数可以将返回值的第二个参数设置成error:
// Config 配置
type Config struct {
// RemoteAddr 连接的远程地址
RemoteAddr string
}
// APIClient API客户端
type APIClient struct {
c Config
}
// NewAPIClient APIClient构造函数,如果入参校验失败,返回错误原因
func NewAPIClient(c Config) (*APIClient,error) { // <-- 第二个参数设置成error
if c.RemoteAddr == "" {
return nil, errors.New("没有设置远程地址")
}
return &APIClient{
c:c,
},nil
}
// Service
type Service struct {
client *APIClient
}
// NewService Service构造函数
func NewService(client *APIClient) *Service{
return &Service{
client:client,
}
}
类似的,injector函数定义的时候也需要将第二个返回值设置成error:
...
func InitializeClient(config Config) (*Service, error) { // <-- 第二个参数设置成error
wire.Build(NewService,NewAPIClient)
return nil,nil
}
...
观察一下wire生成的injector:
func InitializeClient(config Config) (*Service, error) {
apiClient, err := NewAPIClient(config)
if err != nil { // <-- 在构造依赖的顺序中如果发生错误,则会返回对应的"零值"和相应错误
return nil, err
}
service := NewService(apiClient)
return service, nil
}
在构造依赖的顺序中如果发生错误,则会返回对应的"零值"和相应错误。
示例传送门: return-error
Cleanup functions
当provider生成的对象需要一些cleanup处理,比如关闭文件,关闭数据库连接等操作时,依然可以通过设置provider的返回值来达到这样的效果:
// FileReader
type FileReader struct {
f *os.File
}
// NewFileReader *FileReader 构造函数,第二个参数是cleanup function
func NewFileReader(filePath string) (*FileReader, func(), error){
f, err := os.Open(filePath)
if err != nil {
return nil,nil,err
}
fr := &FileReader{
f:f,
}
fn := func() {
log.Println("cleanup")
fr.f.Close()
}
return fr,fn,nil
}
跟返回错误类似,将provider的第二个返回参数设置成func()用于返回cleanup function,上述例子中在第三个参数中返回了error,但这是可选的:
wire对provider的返回值个数和顺序有所规定:
- 第一个参数是需要生成的依赖对象
- 如果返回2个返回值,第二个参数必须是func()或者error
- 如果返回3个返回值,第二个参数必须是func(),第三个参数则必须是error
示例传送门: cleanup-functions
Provider set
当一些provider通常是一起使用的时候,可以使用provider set将它们组织起来,以quickstart示例为模板稍作修改:
// NewMessage Message的构造函数
func NewMessage(msg string) Message {
return Message{
msg:msg,
}
}
// NewGreeter Greeter构造函数
func NewGreeter(m Message) Greeter {
return Greeter{Message: m}
}
// NewEvent Event构造函数
func NewEvent(g Greeter) Event {
return Event{Greeter: g}
}
func (e Event) Start() {
msg := e.Greeter.Greet()
fmt.Println(msg)
}
// EventSet Event通常是一起使用的一个集合,使用wire.NewSet进行组合
var EventSet = wire.NewSet(NewEvent, NewMessage, NewGreeter) // <--
上述例子中将Event和它的依赖通过wire.NewSet组合起来,作为一个整体在injector函数签名定义中使用:
func InitializeEvent(msg string) Event{
//wire.Build(NewEvent, NewGreeter, NewMessage)
wire.Build(EventSet)
return Event{}
}
这时只需将EventSet传入wire.Build即可。
示例传送门: provider-set
结构体provider
除了函数外,结构体也可以充当provider的角色,类似于setter注入:
type Foo int
type Bar int
func ProvideFoo() Foo {
return 1
}
func ProvideBar() Bar {
return 2
}
type FooBar struct {
MyFoo Foo
MyBar Bar
}
var Set = wire.NewSet(
ProvideFoo,
ProvideBar,
wire.Struct(new(FooBar), "MyFoo", "MyBar"))
通过wire.Struct来指定那些字段要被注入到结构体中,如果是全部字段,也可以简写成:
var Set = wire.NewSet(
ProvideFoo,
ProvideBar,
wire.Struct(new(FooBar), "*")) // * 表示注入全部字段
生成的injector函数:
func InitializeFooBar() FooBar {
foo := ProvideFoo()
bar := ProvideBar()
fooBar := FooBar{
MyFoo: foo,
MyBar: bar,
}
return fooBar
}
示例传送门: struct-provider
Best Practices
区分类型
由于injector的函数中,不允许出现重复的参数类型,否则wire将无法区分这些相同的参数类型,比如:
type FooBar struct {
foo string
bar string
}
func NewFooBar(foo string, bar string) FooBar {
return FooBar{
foo: foo,
bar: bar,
}
}
injector函数签名定义:
// wire无法得知入参a,b跟FooBar.foo,FooBar.bar的对应关系
func InitializeFooBar(a string, b string) FooBar {
wire.Build(NewFooBar)
return FooBar{}
}
如果使用上面的provider来生成injector,wire会报如下错误:
provider has multiple parameters of type string
因为入参均是字符串类型,wire无法得知入参a,b跟FooBar.foo,FooBar.bar的对应关系。 所以我们使用不同的类型来避免冲突:
type Foo string
type Bar string
type FooBar struct {
foo Foo
bar Bar
}
func NewFooBar(foo Foo, bar Bar) FooBar {
return FooBar{
foo: foo,
bar: bar,
}
}
injector函数签名定义:
func InitializeFooBar(a Foo, b Bar) FooBar {
wire.Build(NewFooBar)
return FooBar{}
}
其中基础类型和通用接口类型是最容易发生冲突的类型,如果它们在provider函数中出现,最好统一新建一个别名来代替它(尽管还未发生冲突),例如:
type MySQLConnectionString string
type FileReader io.Reader
示例传送门 distinguishing-types
Options Structs
如果一个provider方法包含了许多依赖,可以将这些依赖放在一个options结构体中,从而避免构造函数的参数太多:
type Message string
// Options
type Options struct {
Messages []Message
Writer io.Writer
Reader io.Reader
}
type Greeter struct {
}
// NewGreeter Greeter的provider方法使用Options以避免构造函数过长
func NewGreeter(ctx context.Context, opts *Options) (*Greeter, error) {
return nil, nil
}
// GreeterSet 使用wire.Struct设置Options为provider
var GreeterSet = wire.NewSet(wire.Struct(new(Options), "*"), NewGreeter)
injector函数签名:
func InitializeGreeter(ctx context.Context, msg []Message, w io.Writer, r io.Reader) (*Greeter, error) {
wire.Build(GreeterSet)
return nil, nil
}
示例传送门 options-structs
一些缺点和限制
额外的类型定义
由于wire自身的限制,injector中的变量类型不能重复,需要定义许多额外的基础类型别名。
mock支持暂时不够友好
目前wire命令还不能识别_test.go结尾文件中的provider函数,这样就意味着如果需要在测试中也使用wire来注入我们的mock对象,我们需要在常规代码中嵌入mock对象的provider,这对常规代码有侵入性,不过官方似乎也已经注意到了这个问题,感兴趣的小伙伴可以关注一下这条issue:github.com/google/wire…
更多参考
2 - Golang单元测试-简单示例
one.go
package unittest
func AddOne(t int32) int32 {
return t + 1
}
func MinusOne(t int32) int32 {
return t - 1
}
func MultiAddOne(t int32) int32 {
t = MinusOne(t)
t = AddOne(t)
t = AddOne(t)
return t
}
one_test.go
package unittest
import (
"testing"
. "github.com/agiledragon/gomonkey"
. "github.com/smartystreets/goconvey/convey"
)
func TestMultiAddOne(t *testing.T) {
Convey("TestApplyFunc", t, func() {
Convey("input and output param", func() {
patches := ApplyFunc(AddOne, func(t1 int32) int32 {
return 5
}) //对函数AddOne打桩
defer patches.Reset()
patches.ApplyFunc(MinusOne, func(t1 int32) int32 {
return -2
}) //对函数MinusOne打桩
result := MultiAddOne(2) //看好了我调用的是MultiAddOne函数,而MultiAddOne函数内部调用了AddOne和MinusOne。
So(result, ShouldEqual, 3)
})
})
}
3 - Golang单元测试01
搞定Go单元测试(一)——基础原理
单元测试是代码质量的保证。本系列文章将一步步由浅入深展示如何在Go中做单元测试。
Go对单元测试的支持相当友好,标准包中就支持单元测试,在开始本系阅读之前,需要对标准测试包的基本用法有所了解。
现在,我们从单元测试的基本思想和原理入手,一起来看看如何基于Go提供的标准测试包来进行单元测试。
单元测试的难点
1.掌握单元测试粒度
单元测试粒度是让人十分头疼的问题,特别是对于初尝单元测试的程序员。测试粒度做的太细,会耗费大量的开发以及维护时间,每改一个方法,都要改动其对应的测试方法。当发生代码重构的时候那简直就是噩梦(因为你所有的单元测试又都要写一遍了…)。 如单元测试粒度太粗,一个测试方法测试了n多方法,那么单元测试将显的非常臃肿,脱离了单元测试的本意,容易把单元测试写成集成测试。
2. 破除外部依赖(mock,stub 技术)
单元测试一般不允许有任何外部依赖(文件依赖,网络依赖,数据库依赖等),我们不会在测试代码中去连接数据库,调用api等。这些外部依赖在执行测试的时候需要被模拟(mock/stub)。在测试的时候,我们使用模拟的对象来模拟真实依赖下的各种行为。如何运用mock/stub来模拟系统真实行为算是单元测试道路上的一只拦路虎。别着急,本文会通过示例来展示如何在Go中使用mock/stub来完成单元测试。
有的时候模拟是有效的方便的。但我们要提防过度的mock/stub,因为其会导致单元测试主要在测模拟对象而不是实际的系统。
Costs and Benefits
在受益于单元测试的好处的同时,也必然增加了代码量以及维护成本(单元测试代码也是要维护的)。下面这张成本/价值象限图很清晰的阐述了在不同性质的系统中单元测试成本和价值之间的关系。

1.依赖很少的简单的代码(左下)
对于外部依赖少,代码又简单的代码。自然其成本和价值都是比较低的。举Go官方库里errors包为例,整个包就两个方法 New()和 Error(),没有任何外部依赖,代码也很简单,所以其单元测试起来也是相当方便。
2. 依赖较多但是很简单的代码(右下)
依赖一多,mock和stub就必然增多,单元测试的成本也就随之增加。但代码又如此简单(比如上述errors包的例子),这个时候写单元测试的成本已经大于其价值,还不如不写单元测试。
3. 依赖很少的复杂代码 (左上)
像这一类代码,是最有价值写单元测试的。比如一些独立的复杂算法(银行利息计算,保险费率计算,TCP协议解析等),像这一类代码外部依赖很少,但却很容易出错,如果没有单元测试,几乎不能保证代码质量。
4.依赖很多又很复杂(右上)
这种代码显然是单元测试的噩梦。写单元测试吧,代价高昂;不写单元测试吧,风险太高。像这种代码我们尽量在设计上将其分为两部分:1.处理复杂的逻辑部分 2.处理依赖部分 然后1部分进行单元测试
迈出单元测试第一步
1. 识别依赖,抽象成接口
识别系统中的外部依赖,普遍来说,我们遇到最常见的依赖无非下面几种:
- 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
- 数据库依赖
- I/O依赖(文件)
当然,还有可能是依赖还未开发完成的功能模块。但是处理方法都是大同小异的——抽象成接口,通过mock和stub进行模拟测试。
2. 明确需要测什么
当我们开始敲产品代码的时候,我们必然已经过初步的设计,已经了解系统中的外部依赖以及业务复杂的部分,这些部分是要优先考虑写单元测试的。在写每一个方法/结构体的时候同时思考这个方法/结构体需不需要测试?如何测试?对于什么样的方法/结构体需要测试,什么样的可以不做,除了可以从上面的成本/价值象限图中获得答案外,还可以参考以下关于单元测试粒度要做多细问题的回答:
老板为我的代码付报酬,而不是测试,所以,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我觉得这种的自信标准应该要高于业内的标准,当然,这种自信也可能是种自大)。如果我的编码生涯中不会犯这种典型的错误(如:在构造函数中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误做测试,所以,我对一些比较复杂的条件逻辑会异常地小心。当在一个团队中,我会非常小心的测试那些会让团队容易出错的代码。 coolshell.cn/articles/82…
Mock和Stub怎么做
Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。 通过Mock和Stub我们不仅可以让测试环境没有外部依赖,而且还可以模拟一些异常行为,如数据库服务不可用,没有文件的访问权限等等。
Mock和Stub的区别
在Go语言中,可以这样描述Mock和Stub:
- Mock:在测试包中创建一个结构体,满足某个外部依赖的接口
interface{} - Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法
还是有点抽象,下面举例说明。
Mock示例
Mock:在测试包中创建一个结构体,满足某个外部依赖的接口 interface{}
生产代码:
//auth.go
//假设我们有一个依赖http请求的鉴权接口
type AuthService interface{
Login(username string,password string) (token string,e error)
Logout(token string) error
}
mock代码:
//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
return "token", nil
}
func (auth *authService) Logout(token string) error{
return nil
}
在这里我们用 authService实现了 AuthService接口,这样测试 Login,Logout就不再需需要依赖网络请求了。而且我们也可以模拟一些错误的情况进行测试:
//auth_test.go
//模拟登录失败
type authLoginErr struct {
auth AuthService //可以使用组合的特性,Logout方法我们不关心,只用“覆盖”Login方法即可
}
func (auth *authLoginErr) Login (username string,password string) (string,error) {
return "", errors.New("用户名密码错误")
}
//模拟api服务器宕机
type authUnavailableErr struct {
}
func (auth *authUnavailableErr) Login (username string,password string) (string,error) {
return "", errors.New("api服务不可用")
}
func (auth *authUnavailableErr) Logout(token string) error{
return errors.New("api服务不可用")
}
Stub示例
Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法。 这是《Go语言圣经》(11.2.3)当中的一个例子: 生产代码:
//storage.go
//发送邮件
var notifyUser = func(username, msg string) { //<--将发送邮件的方法变成一个全局变量
auth := smtp.PlainAuth("", sender, password, hostname)
err := smtp.SendMail(hostname+":587", auth, sender,
[]string{username}, []byte(msg))
if err != nil {
log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
}
}
//检查quota,quota不足将发邮件
func CheckQuota(username string) {
used := bytesInUse(username)
const quota = 1000000000 // 1GB
percent := 100 * used / quota
if percent < 90 {
return // OK
}
msg := fmt.Sprintf(template, used, percent)
notifyUser(username, msg) //<---发邮件
}
显然,在跑单元测试的过程中,我们肯定不会真的给用户发邮件。在书中采用了stub的方式来进行测试:
//storage_test.go
func TestCheckQuotaNotifiesUser(t *testing.T) {
var notifiedUser, notifiedMsg string
notifyUser = func(user, msg string) { //<-看这里就够了,在测试中,覆盖了发送邮件的全局变量
notifiedUser, notifiedMsg = user, msg
}
// ...simulate a 980MB-used condition...
const user = "joe@example.org"
CheckQuota(user)
if notifiedUser == "" && notifiedMsg == "" {
t.Fatalf("notifyUser not called")
}
if notifiedUser != user {
t.Errorf("wrong user (%s) notified, want %s",
notifiedUser, user)
}
const wantSubstring = "98% of your quota"
if !strings.Contains(notifiedMsg, wantSubstring) {
t.Errorf("unexpected notification message <<%s>>, "+
"want substring %q", notifiedMsg, wantSubstring)
}
}
可以看到,在Go中,如果要用stub,那将是侵入式的,必须将生产代码设计成可以用stub方法替换的形式。上述例子体现出来的结果就是:为了测试,专门用一个全局变量 notifyUser来保存了具有外部依赖的方法。然而在不提倡使用全局变量的Go语言当中,这显然是不合适的。所以,并不提倡这种Stub方式。
Mock与Stub相结合
既然不提倡Stub方式,那是不是在Go测试当中就可以抛弃Stub了呢?原本我是这么认为的,但直到我读了这篇译文Golang 标准包布局译,虽然这篇译文讲的是包的布局,但里面的测试示例很值得学习。
//生产代码 myapp.go
package myapp
type User struct {
ID int
Name string
Address Address
}
//User的一些增删改查
type UserService interface {
User(id int) (*User, error)
Users() ([]*User, error)
CreateUser(u *User) error
DeleteUser(id int) error
}
常规Mock方式:
//测试代码 myapp_test.go
type userService struct{
}
func (u* userService) User(id int) (*User,error) {
return &User{Id:1,Name:"name",Address:"address"},nil
}
//..省略其他实现方法
//模拟user不存在
type userNotFound struct {
u UserService
}
func (u* userNotFound) User(id int) (*User,error) {
return nil,errors.New("not found")
}
//其他...
一般来说,mock结构体内部很少会放变量,针对每一个要模拟的场景(比如上面的user不存在),最政治正确的方法应该是新建一个mock结构体。这样有两个好处:
- mock出来的结构体十分简单,不需要进行额外的设置,不容易出错。
- mock出来的结构体职责单一,测试代码自说明能力更强,可读性更高。
但在刚才提到的文章中,他是这么做的:
//测试代码
// UserService 代表一个myapp.UserService.的 mock实现
type UserService struct {
UserFn func(id int) (*myapp.User, error)
UserInvoked bool
UsersFn func() ([]*myapp.User, error)
UsersInvoked bool
// 其他接口方法补全..
}
// User调用mock实现, 并标记这个方法为已调用
func (s *UserService) User(id int) (*myapp.User, error) {
s.UserInvoked = true
return s.UserFn(id)
}
这里不仅实现了接口,还通过在结构体内放置与接口方法函数签名一致的方法( UserFnUsersFn...),以及 XxxInvoked是否调用标识符来追踪方法的调用情况。这种做法其实将mock与stub相结合了起来:在mock对象的内部放置了可以被测试函数替换的函数变量( UserFn UsersFn…)。我们可以在我们的测试函数中,根据测试的需要,手动更换函数实现。
//mock与stub结合的方式
func TestUserNotFound(t *testing.T) {
userNotFound := &UserService{}
userNotFound.UserFn = func(id int) (*myapp.User, error) { //<--- 设置UserFn的期望返回结果
return nil,errors.New("not found")
}
//后续业务测试代码...
if !userNotFound.UserInvoked {
t.Fatal("没有调用User()方法")
}
}
// 常规mock方式
func TestUserNotFound(t *testing.T) {
userNotFound := &userNotFound{} //<---结构体方法已经决定了返回值
//后续业务测试代码
}
通过将mock与stub结合,不仅能在测试方法中动态的更改实现,还追踪方法的调用情况,上述例子中只是追踪了方法是否被调用,实际中,如果有需要,我们也可以追踪方法的调用次数,甚至是方法的调用顺序:
type UserService struct {
UserFn func(id int) (*myapp.User, error)
UserInvoked bool
UserInvokedTime int //<--追踪调用次数
UsersFn func() ([]*myapp.User, error)
UsersInvoked bool
// 其他接口方法补全..
FnCallStack []string //<---函数名slice,追踪调用顺序
}
// User调用mock实现, 并标记这个方法为已调用
func (s *UserService) User(id int) (*myapp.User, error) {
s.UserInvoked = true
s.UserInvokedTime++ //<--调用发次数
s.FnCallStack = append(s.FnCallStack,"User") //调用顺序
return s.UserFn(id)
}
但同时,我们也会发现我们的mock结构体更复杂了,维护成本也随之增加了。两种mock风格各有各的好处,反正要记得软件工程没有银弹,合适的场景选用合适的方法就行了。 但总体而言,mock与stub相结合的这种方式的确是一种不错的测试思路,尤其是当我们需要追踪函数是否调用,调用次数,调用顺序等信息时,mock+stub将是我们的不二选择。举个例子:
//缓存依赖
type Cache interface{
Get(id int) interface{} //获取某id的缓存
Put(id int,obj interface{}) //放入缓存
}
//数据库依赖
type UserRepository interface{
//....
}
//User结构体
type User struct {
//...
}
//userservice
type UserService interface{
cache Cache
repository UserRepository
}
func (u *UserService) Get(id int) *User {
//先从缓存找,缓存找不到在去repository里面找
}
func main() {
userService := NewUserService(xxx) //注入一些外部依赖
user := userService.Get(2) //获取id = 2的user
}
现在要测试 userService.Get(id)方法的行为:
- Cache命中之后是否还查数据库?(不应该再查了)
- Cache未命中的情况下是否会查库?
- ….
这种测试通过mock+stub结合做起来将会非常方便,作为小练习,可以尝试自己实现一下。
使用依赖注入传递接口
接口需要以依赖注入的方式注入到结构体中,这样才能为测试提供替换接口实现的可能。Why?我们先看一个反面例子,形如下面的写法是无法测试的:
type A interface {
Fun1()
}
func (f *Foo) Bar() {
a := NewInstanceOfA(...参数若干) //生成A接口的某个实现
a.Fun1() //调用接口方法
}
当你辛辛苦苦的将A接口mock出来后,却发现你根本没有办法在Bar()方法中将mock对象替换进去。下面来看看正确的写法:
type A interface {
Fun1()
}
type Foo struct {
a A // A接口
}
func (f *Foo) Bar() {
f.a.Fun1() //调用接口方法
}
// NewFoo, 通过构造函数的方式,将A接口注入
func NewFoo(a A) *Foo {
return &Foo{a: A}
}
在例子中我们使用了构造函数传参的方法来做依赖注入(当然你也可以用setter的方式做)。在测试的时候,就可以通过NewFoo()方法将我们的mock对象传递给*Foo了。
通常我们会在
main.go中进行依赖注入
总结一下
长篇大论了一大堆,稍微总结一下单元测试的几个关键步骤:
- 识别依赖(网络,文件,未完成的功能等等)
- 将依赖抽象成接口
- 在
main.go中使用依赖注入方式将接口注入
现在,我们已经对单元测试有了一个基本的认识,如果你能完成文中的小练习,那么恭喜你,你已经理解应当如何做单元测试,并成功迈出第一步了。在下一篇文章中,将介绍gomock测试框架,提高我们的测试效率。
4 - Golang单元测试02
搞定Go单元测试(二)—— mock框架(gomock)
通过阅读上一篇文章,相信你对怎么做单元测试已经有了初步的概念,可以着手对现有的项目进行改造并开展测试了。学会了走路,我们尝试跑起来,本篇主要介绍gomock测试框架,让我们的单元测试更加有效率。
表格驱动测试方法(Table Driven Tests)
当针对某方法进行单元测试的时候,通常不止写一个测试用例,我们需要测试该方法在多种入参条件下是否都能正常工作,特别是要针对边界值进行测试。通常这个时候表格驱动测试就派上用场了——当你发现你在写测试方法的时候用上了复制粘贴,这就说明你需要考虑使用表格驱动测试来构建你的测试方法了。我们依旧来举个例子:
func TestTime(t *testing.T) {
testCases := []struct { // 设计我们的测试用例
gmt string
loc string
want string
}{
{"12:31", "Europe/Zuri", "13:31"}, // incorrect location name
{"12:31", "America/New_York", "7:31"}, // should be 07:31
{"08:08", "Australia/Sydney", "18:08"},
}
for _, tc := range testCases { // 循环执行测试用例
loc, err := time.LoadLocation(tc.loc)
if err != nil {
t.Fatalf("could not load location %q", tc.loc)
}
gmt, _ := time.Parse("15:04", tc.gmt)
if got := gmt.In(loc).Format("15:04"); got != tc.want {
t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)
}
}
}
表格驱动测试方法让我们的测试方法更加清晰和简练,减少了复制粘贴,并大大提高的测试代码的可读性。
还记得上文说单元测试也是需要维护的吗?单元测试也是代码的一部分,也应当被认真对待。记得要用表格驱动测试的方法来组织你的测试用例,同时别忘了像正式代码那样,写上相应的注释。 更多参考: github.com/golang/go/w… blog.golang.org/subtests
使用测试框架——gomock
What is gomock?
gomock是Google开源的golang测试框架。或者引用官方的话来说:“GoMock is a mocking framework for the Go programming language”。
Why gomock?
上篇文章末尾介绍了mock和stub相结合的测试方法,可以感受到mock与stub结合起来功能固然强大——调用顺序检测,调用次数检测,动态控制函数的返回值等等,但同时,其带来的维护成本和复杂度缺是不可忽视的,手动维护这样一套测试代码那将是一场灾难。我们期望能用一套框架或者工具,在提供强大的测试功能的同时帮我们维护复杂的mock代码。
How does it work?
gomock通过mockgen命令生成包含mock对象的.go文件,其生成的mock对象具备mock+stub的强大功能,并将我们从写mock对象中解放了出来:
mockgen -destination foo_mock.go -source foo.go -package foo //mock foo.go里面所有的接口,将mock结果保存到foo_mock.go
gomock让我们既能使用mock与stub结合的强大功能,又不需要手动维护这些mock对象,岂不美哉?
举个栗子
在这里我们对gomock的基本功能做一个简单演示: 假设我们的接口定义在 user.go:
// user.go
package user
// User 表示一个用户
type User struct {
Name string
}
// UserRepository 用户仓库
type UserRepository interface {
// 根据用户id查询得到一个用户或是错误信息
FindOne(id int) (*User,error)
}
通过mockgen在同目录下生成mock文件user_mock.go
mockgen -source user.go -destination user_mock.go -package user
然后在该目录下新建user_test.go来写我们的测试函数,上述步骤完成之后,我们的目录结构如下:
└── user
├── user.go
├── user_mock.go
└── user_test.go
设置函数的返回值
// 静态设置返回值
func TestReturn(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := NewMockUserRepository(ctrl)
// 期望FindOne(1)返回张三用户
repo.EXPECT().FindOne(1).Return(&User{Name: "张三"}, nil)
// 期望FindOne(2)返回李四用户
repo.EXPECT().FindOne(2).Return(&User{Name: "李四"}, nil)
// 期望给FindOne(3)返回找不到用户的错误
repo.EXPECT().FindOne(3).Return(nil, errors.New("user not found"))
// 验证一下结果
log.Println(repo.FindOne(1)) // 这是张三
log.Println(repo.FindOne(2)) // 这是李四
log.Println(repo.FindOne(3)) // user not found
log.Println(repo.FindOne(4)) //没有设置4的返回值,却执行了调用,测试不通过
}
// 动态设置返回值
func TestReturnDynamic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := NewMockUserRepository(ctrl)
// 常用方法之一:DoAndReturn(),动态设置返回值
repo.EXPECT().FindOne(gomock.Any()).DoAndReturn(func(i int) (*User,error) {
if i == 0 {
return nil, errors.New("user not found")
}
if i < 100 {
return &User{
Name:"小于100",
}, nil
} else {
return &User{
Name:"大于等于100",
}, nil
}
})
log.Println(repo.FindOne(120))
//log.Println(repo.FindOne(66))
//log.Println(repo.FindOne(0))
}
调用次数检测
func TestTimes(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := NewMockUserRepository(ctrl)
// 默认期望调用一次
repo.EXPECT().FindOne(1).Return(&User{Name: "张三"}, nil)
// 期望调用2次
repo.EXPECT().FindOne(2).Return(&User{Name: "李四"}, nil).Times(2)
// 调用多少次可以,包括0次
repo.EXPECT().FindOne(3).Return(nil, errors.New("user not found")).AnyTimes()
// 验证一下结果
log.Println(repo.FindOne(1)) // 这是张三
log.Println(repo.FindOne(2)) // 这是李四
log.Println(repo.FindOne(2)) // FindOne(2) 需调用两次,注释本行代码将导致测试不通过
log.Println(repo.FindOne(3)) // user not found, 不限调用次数,注释掉本行也能通过测试
}
调用顺序检测
func TestOrder(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := NewMockUserRepository(ctrl)
o1 := repo.EXPECT().FindOne(1).Return(&User{Name: "张三"}, nil)
o2 := repo.EXPECT().FindOne(2).Return(&User{Name: "李四"}, nil)
o3 := repo.EXPECT().FindOne(3).Return(nil, errors.New("user not found"))
gomock.InOrder(o1, o2, o3) //设置调用顺序
// 按顺序调用,验证一下结果
log.Println(repo.FindOne(1)) // 这是张三
log.Println(repo.FindOne(2)) // 这是李四
log.Println(repo.FindOne(3)) // user not found
// 如果我们调整了调用顺序,将导致测试不通过:
// log.Println(repo.FindOne(2)) // 这是李四
// log.Println(repo.FindOne(1)) // 这是张三
// log.Println(repo.FindOne(3)) // user not found
}
上面的示例只展现了gomock功能的冰山一角,在本篇中不再深入讨论,更多用法请参考文档。
更多官方示例:github.com/golang/mock…
如果你完成了上一章的小练习,尝试动手使用gomock改造一下吧!
总结一下
本篇介绍了表格驱动测试与gomock测试框架。运用表格驱动测试方法不仅能使测试代码更精简易读,还能提高我们测试用例的编写能力,无形中提升了单元测试的质量。gomock的功能十分丰富,想掌握各种骚操作还是要细心阅读一下官方示例,但通常20%的常规功能也足够覆盖80%的测试场景了。
表格驱动单元测试和gomock将我们的单元测试效率与质量提升了一个档次。在下一篇文章中,将介绍 testify断言库,继续优化我们的单元测试。
5 - Golang单元测试03
搞定Go单元测试(三)—— 断言(testify)
在上一篇,介绍了表格驱动测试方法和gomock测试框架,大大提升了测试效率与质量。本篇将介绍在测试中引入断言(assertion),进一步提升测试效率与质量。
为什么需要断言库
我们先来看看Go标准包中为什么没有断言,官方在FAQ里面回答了这个问题。
总体概括一下大意就是:“Go不提供断言,我们知道这会带来一定的不便,其主要目的是为了防止你们这些程序员在错误处理上偷懒。我们知道这是一个争论点,但是我们觉得这样很coooool~~。”所以,我们引入断言库的原因也很明显了:偷懒,引入断言能为我们提供便利——提高测试效率,增强代码可读性。
testify
在断言库的选择上,我们似乎没有过多的选择,从start数和活跃度来看,基本上是testify一枝独秀。
没有对比就没有伤害,先来看看使用testify之前的测试方法:
func TestSomeFun(t *testing.T){
...
if v != want {
t.Fatalf("v值错误,期望值:%s,实际值:%s", want, v)
}
if err != nil {
t.Fatalf("非预期的错误:%s", err)
}
if objectA != objectB {
if objectA.field1 != objectB.field1 {
// t.Fatalf() field1值错误...bla bla bla
}
if objectA.field2 != objectB.field2 {
// t.Fatalf() field2值错误...bla bla bla
}
// 遍历object所有值... bla bla bla
}
...
}
上述代码充斥着大量if...else..判断,大段错误信息拼装(真·体力活…),运气不好碰到结构体判断要得将其遍历一遍——不直观,低效,实在是不fashion。
现在,我们使用 testify来改造一下上面的测试示例:
func TestSomeFun(t *testing.T){
a := assert.New(t)
...
a.Equal(v, want)
a.Nil(err,"如果你还是想输出自己拼装的错误信息,可以传第三个参数")
a.Equal(objectA, objectB)
...
}
三行搞定,测试含义一目了然——直观,高效,简短,fashion。
总结一下
testify使用简单,提升显著,可谓是用一次就会爱上的懒人神器。在结合表格驱动测试,gomock和testify后,我们已经能写出一手优雅漂亮的单元测试代码了。不过,光测试代码优雅还不够,我们还需要帮main.go也打扮打扮。在下一篇,也是本系列最后一篇文章中,我们将介绍wire依赖注入框架,帮main.go减肥瘦身。
6 - Golang单元测试04
搞定Go单元测试(四)—— 依赖注入框架(wire)
在第一篇文章中提到过,为了让代码可测,需要用依赖注入的方式来构建我们的对象,而通常我们会在main.go做依赖注入,这就导致main.go会越来越臃肿。为了让单元测试得以顺利进行,main.go牺牲了它本应该纤细苗条的身材。太胖的main.go可不是什么好的信号,本篇将介绍依赖注入框架(wire),致力于帮助main.go恢复身材。
臃肿的main
在main.go中做依赖注入,意味着在初始化代码中我们要管理:
- 依赖的初始化顺序
- 依赖之间的关系
对于小型项目而言,依赖的数量比较少,初始化代码不会很多,不需要引入依赖注入框架。但对于依赖较多的中大型项目,初始化代码又臭又长,可读性和维护性变的很差,随意感受一下:
func main() {
config := NewConfig()
// db依赖配置
db, err := ConnectDatabase(config)
if err != nil {
panic(err)
}
// PersonRepository 依赖db
personRepository := NewPersonRepository(db)
// PersonService 依赖配置 和 PersonRepository
personService := NewPersonService(config, personRepository)
// NewServer 依赖配置和PersonService
server := NewServer(config, personService)
server.Run()
}
实践表明,修改有大量依赖关系的初始化代码是一项乏味且耗时的工作。这个时候,我们就需要依赖注入框架来帮忙,简化初始化代码。
使用依赖注入框架——wire
What is wire?
wire是google开源的依赖注入框架。或者引用官方的话来说:“Wire is a code generation tool that automates connecting components using dependency injection”。
Why wire?
除了wire,Go的依赖注入框架还有Uber的dig和Facebook的inject,它们都是使用反射机制来实现运行时依赖注入(runtime dependency injection),而wire则是采用代码生成的方式来达到编译时依赖注入(compile-time dependency injection)。使用反射带来的性能损失倒是其次,更重要的是反射使得代码难以追踪和调试(反射会令Ctrl+左键失效…)。而wire生成的代码是符合程序员常规使用习惯的代码,十分容易理解和调试。 关于wire的优点,在官方博文上有更详细的的介绍: blog.golang.org/wire
How does it work?
本部分内容参考官方博文:blog.golang.org/wire
wire有两个基本的概念:provider和injector。
provider
provider就是普通的Go函数,可以把它看作是某对象的构造函数,我们通过provider告诉wire该对象的依赖情况:
// NewUserStore是*UserStore的provider,表明*UserStore依赖于*Config和 *mysql.DB.
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}
// NewDefaultConfig是*Config的provider,没有依赖
func NewDefaultConfig() *Config {...}
// NewDB是*mysql.DB的provider,依赖于ConnectionInfo
func NewDB(info ConnectionInfo) (*mysql.DB, error) {...}
// UserStoreSet 可选项,可以使用wire.NewSet将通常会一起使用的依赖组合起来。
var UserStoreSet = wire.NewSet(NewUserStore, NewDefaultConfig)
injector
injector是wire生成的函数,我们通过调用injector来获取我们所需的对象或值,injector会按照依赖关系,按顺序调用provider函数:
// File: wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
// initUserStore是由wire生成的injector
func initUserStore(info ConnectionInfo) (*UserStore, error) {
// *Config的provider函数
defaultConfig := NewDefaultConfig()
// *mysql.DB的provider函数
db, err := NewDB(info)
if err != nil {
return nil, err
}
// *UserStore的provider函数
userStore, err := NewUserStore(defaultConfig, db)
if err != nil {
return nil, err
}
return userStore, nil
}
injector帮我们把按顺序初始化依赖的步骤给做了,我们在main.go中只需要调用initUserStore方法就能得到我们想要的对象了。
那么wire是怎么知道如何生成injector的呢?我们需要写一个函数来告诉它:
- 定义injector的函数签名
- 在函数中使用
wire.Build方法列举生成injector所需的provider
例如:
// initUserStore用于声明injector的函数签名
func initUserStore(info ConnectionInfo) (*UserStore, error) {
// wire.Build声明要获取一个UserStore需要调用到哪些provider函数
wire.Build(UserStoreSet, NewDB)
return nil, nil // 这些返回值wire并不关心。
}
有了上面的函数,wire就可以得知如何生成injector了。wire生成injector的步骤描述如下:
- 确定所生成injector函数的函数签名:
func initUserStore(info ConnectionInfo) (*UserStore, error) - 感知返回值第一个参数是
*UserStore - 检查
wire.Build列表,找到*UserStore的provider:NewUserStore - 由函数签名
func NewUserStore(cfg *Config, db *mysql.DB)得知NewUserStore依赖于*Config, 和*mysql.DB - 检查
wire.Build列表,找到*Config和*mysql.DB的provider:NewDefaultConfig和NewDB - 由函数签名
func NewDefaultConfig() *Config得知*Config没有其他依赖了。 - 由函数签名
func NewDB(info *ConnectionInfo) (*mysql.DB, error)得知*mysql.DB依赖于ConnectionInfo。 - 检查
wire.Build列表,找不到ConnectionInfo的provider,但在injector函数签名中发现匹配的入参类型,直接使用该参数作为NewDB的入参。 - 感知返回值第二个参数是
error - ….
- 按依赖关系,按顺序调用provider函数,拼装injector函数。
举个栗子
栗子传送门:wire-examples
注意
截止本文发布前,官方表明wire的项目状态是alpha,还不适合到生产环境,API存在变化的可能。 虽然是alpha,但其主要作用是为我们生成依赖注入代码,其生成的代码十分通俗易懂,在做好版本控制的前提下,即使是API发生变化,也不会对生成环境造成多坏的影响。我认为还是可以放心使用的。
总结一下
本篇是本系列的最后一篇,回顾前几篇文章,我们以单元测试的原理与基本思想为基础,介绍了表格驱动测试方法,gomock,testify,wire这几样实用工具,经历了“能写单元测试”到“写好单元测试”不断优化的过程。希望本系列文章能让你有所收获。