Go语言原生支持测试工具go test
,省去了各类各样测试框架的学习成本。说来也惭愧,写代码这么些年,也历来没有给本身的代码写过单元测试,代码质量的确堪忧。遂花时间学习整理了一下单元测试的基本方法,以及在Go中的实践技巧。html
如下是我在尝试进行单元测试的过程当中遇到的一些难点,在下文中会介绍相应的一些应对方案。程序员
单元测试粒度是让人十分头疼的问题,特别是对于初尝单元测试的程序员(好比我)。测试粒度作的太细,会耗费大量的开发以及维护时间,每改一个方法,都要改动其对应的测试方法。当发生代码重构的时候那简直就是噩梦(由于你全部的单元测试又都要写一遍了…)。 如单元测试粒度太粗,一个测试方法测试了n多方法,那么单元测试将显的很是臃肿,脱离了单元测试的本意,容易把单元测试写成__集成测试__。算法
单元测试中是不容许有任何外部依赖的,也就是说这些外部依赖都须要被模拟(mock)。外部依赖越多,mock越复杂。如何用模拟的依赖来测试真实依赖的行为?mock写的太简单,达不到测试的目的。mock太复杂, 不只成本增长,并且又如何确保mock的正确性呢?shell
有的时候模拟是有效的方便的。可是其余一些时候,过多的模拟对象,Stub对象,假对象,致使单元测试主要在测模拟对象而不是实际的系统。数据库
在受益于单元测试的好处的同时,也必然增长了代码量以及维护成本(单元测试代码也是要维护的)。下面这张成本/价值象限图很清晰的阐述了在不一样性质的系统中单元测试__成本__和__价值__之间的关系。api
1.依赖不多的简单的代码(左下)缓存
对于外部依赖少,代码又简单的代码。天然其成本和价值都是比较低的。举Go官方库里errors包为例,整个包就两个方法New()
和Error()
,没有任何外部依赖,代码也很简单,因此其单元测试起来也是至关方便。服务器
依赖一多,mock和stub就必然增多,单元测试的成本也就随之增长。但代码又如此简单(好比上述errors包的例子),这个时候写单元测试的成本已经大于其价值,还不如不写单元测试。网络
像这一类代码,是最有价值写单元测试的。好比一些独立的复杂算法(银行利息计算,保险费率计算,TCP协议解析等),像这一类代码外部依赖不多,但却很容易出错,若是没有单元测试,几乎不能保证代码质量。app
这种代码显然是单元测试的噩梦。写单元测试吧,代价高昂;不写单元测试吧,风险过高。像这种代码咱们尽可能在设计上将其分为两部分:1.处理复杂的逻辑部分 2.处理依赖部分
而后1部分进行单元测试
原文参考:http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/
识别系统中的外部依赖,广泛来讲,咱们遇到最多见的依赖无非下面几种:
网络依赖——函数执行依赖于网络请求,好比第三方http-api,rpc服务,消息队列等等
数据库依赖
I/O依赖(文件)
固然,还有多是依赖还未开发完成的功能模块。可是处理方法都是大同小异的——抽象成接口,经过mock和stub进行模拟测试。
当咱们开始敲产品代码的时候,咱们必然已通过初步的设计,已经了解系统中的外部依赖以及业务复杂的部分,这些部分是要优先考虑写单元测试的。在写每个方法/结构体的时候同时思考这个方法/结构体需不须要测试?如何测试?对于什么样的方法/结构体须要测试,什么样的能够不作,除了能够从上面的成本/价值象限图中得到答案外,还能够参考如下关于单元测试粒度要作多细问题的回答:
老板为个人代码付报酬,而不是测试,因此,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我以为这种的自信标准应该要高于业内的标准,固然,这种自信也多是种自大)。若是个人编码生涯中不会犯这种典型的错误(如:在构造函数中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误作测试,因此,我对一些比较复杂的条件逻辑会异常地当心。当在一个团队中,我会很是当心的测试那些会让团队容易出错的代码。
https://coolshell.cn/articles/8209.html
Mock(模拟)和Stub(桩)是在测试过程当中,模拟外部依赖行为的两种经常使用的技术手段。
经过Mock和Stub咱们不只可让测试环境没有外部依赖,并且还能够模拟一些异常行为,如数据库服务不可用,没有文件的访问权限等等。
在Go语言中,能够这样描述Mock和Stub:
Mock:在测试包中建立一个结构体,知足某个外部依赖的接口interface{}
Stub:在测试包中建立一个模拟方法,用于替换生成代码中的方法
仍是有点抽象,下面举例说明。
Mock:在测试包中建立一个结构体,知足某个外部依赖的接口interface{}
生产代码:
1//auth.go
2//假设咱们有一个依赖http请求的鉴权接口
3type AuthService interface{
4 Login(username string,password string) (token string,e error)
5 Logout(token string) error
6}
mock代码:
1//auth_test.go
2type authService struct {
3}
4func (auth *authService) Login (username string,password string) (string,error) {
5 return "token", nil
6}
7func (auth *authService) Logout(token string) error{
8 return nil
9}
在这里咱们用authService
实现了AuthService
接口,这样测试Login,Logout
就再也不需须要依赖网络请求了。并且咱们也能够模拟一些错误的状况进行测试:
1//auth_test.go
2//模拟登陆失败
3type authLoginErr struct {
4 auth AuthService //可使用组合的特性,Logout方法咱们不关心,只用“覆盖”Login方法便可
5}
6func (auth *authLoginErr) Login (username string,password string) (string,error) {
7 return "", errors.New("用户名密码错误")
8}
9
10//模拟api服务器宕机
11type authUnavailableErr struct {
12}
13func (auth *authLoginErr) Login (username string,password string) (string,error) {
14 return "", errors.New("api服务不可用")
15}
16func (auth *authLoginErr) Logout(token string) error{
17 return errors.New("api服务不可用")
18}
Stub:在测试包中建立一个模拟方法,用于替换生成代码中的方法。
这是《Go语言圣经》(11.2.3)当中的一个例子:
生产代码:
1//storage.go
2//发送邮件
3var notifyUser = func(username, msg string) { //<--将发送邮件的方法变成一个全局变量
4 auth := smtp.PlainAuth("", sender, password, hostname)
5 err := smtp.SendMail(hostname+":587", auth, sender,
6 []string{username}, []byte(msg))
7 if err != nil {
8 log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
9 }
10}
11//检查quota,quota不足将发邮件
12func CheckQuota(username string) {
13 used := bytesInUse(username)
14 const quota = 1000000000 // 1GB
15 percent := 100 * used / quota
16 if percent < 90 {
17 return // OK
18 }
19 msg := fmt.Sprintf(template, used, percent)
20 notifyUser(username, msg) //<---发邮件
21}
显然,在跑单元测试的过程当中,咱们确定不会真的给用户发邮件。在书中采用了stub的方式来进行测试:
1//storage_test.go
2func TestCheckQuotaNotifiesUser(t *testing.T) {
3 var notifiedUser, notifiedMsg string
4 notifyUser = func(user, msg string) { //<-看这里就够了,在测试中,覆盖了发送邮件的全局变量
5 notifiedUser, notifiedMsg = user, msg
6 }
7
8 // ...simulate a 980MB-used condition...
9
10 const user = "joe@example.org"
11 CheckQuota(user)
12 if notifiedUser == "" && notifiedMsg == "" {
13 t.Fatalf("notifyUser not called")
14 }
15 if notifiedUser != user {
16 t.Errorf("wrong user (%s) notified, want %s",
17 notifiedUser, user)
18 }
19 const wantSubstring = "98% of your quota"
20 if !strings.Contains(notifiedMsg, wantSubstring) {
21 t.Errorf("unexpected notification message <<%s>>, "+
22 "want substring %q", notifiedMsg, wantSubstring)
23 }
24}
能够看到,在Go中,若是要用stub,那将是侵入式的,必须将生产代码设计成能够用stub方法替换的形式。上述例子体现出来的结果就是:为了测试,专门用一个全局变量notifyUser
来保存了具备外部依赖的方法。然而在不提倡使用全局变量的Go语言当中,这显然是不合适的。因此,并不提倡这种Stub方式。
既然不提倡Stub方式,那是否是在Go测试当中就能够抛弃Stub了呢?本来我是这么认为的,但直到我读了这篇译文Golang 标准包布局[译],虽然这篇译文讲的是包的布局,但里面的测试示例很值得学习。
1//生产代码 myapp.go
2package myapp
3
4type User struct {
5 ID int
6 Name string
7 Address Address
8}
9//User的一些增删改查
10type UserService interface {
11 User(id int) (*User, error)
12 Users() ([]*User, error)
13 CreateUser(u *User) error
14 DeleteUser(id int) error
15}
常规Mock方式:
1//测试代码 myapp_test.go
2type userService struct{
3}
4func (u* userService) User(id int) (*User,error) {
5 return &User{Id:1,Name:"name",Address:"address"},nil
6}
7//..省略其余实现方法
8
9//模拟user不存在
10type userNotFound struct {
11 u UserService
12}
13func (u* userNotFound) User(id int) (*User,error) {
14 return nil,errors.New("not found")
15}
16
17//其余...
通常来讲,mock结构体内部不多会放变量,针对每个要模拟的场景(好比上面的user不存在),最政治正确的方法应该是新建一个mock结构体。这样有两个好处:
mock出来的结构体十分简单,不须要进行额外的设置,不容易出错。
mock出来的结构体职责单一,测试代码自说明能力更强,可读性更高。
但在刚才提到的文章中,他是这么作的:
1//测试代码
2// UserService 表明一个myapp.UserService.的 mock实现
3type UserService struct {
4 UserFn func(id int) (*myapp.User, error)
5 UserInvoked bool
6
7 UsersFn func() ([]*myapp.User, error)
8 UsersInvoked bool
9
10 // 其余接口方法补全..
11}
12
13// User调用mock实现, 并标记这个方法为已调用
14func (s *UserService) User(id int) (*myapp.User, error) {
15 s.UserInvoked = true
16 return s.UserFn(id)
17}
这里不只实现了接口,还经过在结构体内放置与接口方法函数签名一致的方法(UserFn UsersFn ...
),以及XxxInvoked
是否调用标识符来追踪方法的调用状况。这种作法其实将mock与stub相结合了起来:在mock对象的内部放置了能够被测试函数替换的函数变量(UserFn
UsersFn
…)。咱们能够在咱们的测试函数中,根据测试的须要,手动更换函数实现:
1//mock与stub结合的方式
2func TestUserNotFound(t *testing.T) {
3 userNotFound := &UserService{}
4 userNotFound.UserFn = func(id int) (*myapp.User, error) { //<---本身实现UserFn的实现
5 return nil,errors.New("not found")
6 }
7 //后续业务测试代码...
8
9 if !userNotFound.UserInvoked {
10 t.Fatal("没有调用User()方法")
11 }
12}
1//传统的mock方式
2func TestUserNotFound(t *testing.T) {
3 userNotFound := &userNotFound{} //<---结构体方法已经决定了返回值
4 //后续业务测试代码
5}
经过将mock与stub结合,不只能在测试方法中动态的更改实现,还追踪方法的调用状况,上述例子中只是追踪了方法是否被调用,实际中,若是有须要,咱们也能够追踪方法的调用次数,甚至是方法的调用顺序:
1type UserService struct {
2 UserFn func(id int) (*myapp.User, error)
3 UserInvoked bool
4 UserInvokedTime int //<--追踪调用次数
5
6
7 UsersFn func() ([]*myapp.User, error)
8 UsersInvoked bool
9
10 // 其余接口方法补全..
11
12 FnCallStack []string //<---函数名slice,追踪调用顺序
13}
14
15// User调用mock实现, 并标记这个方法为已调用
16func (s *UserService) User(id int) (*myapp.User, error) {
17 s.UserInvoked = true
18 s.UserInvokedTime++ //<--调用发次数
19 s.FnCallStack = append(s.FnCallStack,"User") //调用顺序
20 return s.UserFn(id)
21}
但同时,咱们也会发现咱们的mock结构体更复杂了,维护成本也随之增长了。两种mock风格各有各的好处,反正要记得软件工程没有银弹,合适的场景选用合适的方法就好了。
但整体而言,mock与stub相结合的这种方式的确是一种不错的测试思路,尤为是当咱们须要追踪函数是否调用,调用次数,调用顺序等信息时,mock+stub将是咱们的不二选择。举个例子:
1//缓存依赖
2type Cache interface{
3 Get(id int) interface{} //获取某id的缓存
4 Put(id int,obj interface{}) //放入缓存
5}
6
7//数据库依赖
8type UserRepository interface{
9 //....
10}
11//User结构体
12type User struct {
13 //...
14}
15//userservice
16type UserService interface{
17 cache Cache
18 repository UserRepository
19}
20
21func (u *UserService) Get(id int) *User {
22 //先从缓存找,缓存找不到在去repository里面找
23}
24
25func main() {
26 userService := NewUserService(xxx) //注入一些外部依赖
27 user := userService.Get(2) //获取id = 2的user
28}
如今要测试userService.Get(id)
方法的行为:
Cache命中以后是否还查数据库?(不该该再查了)
Cache未命中的状况下是否会查库?
…
这种测试经过mock+stub结合作起来将会很是方便,做为小练习,能够尝试本身实现一下。