[toc]
Why单元测试
-
让我们对重构与修改有信心
新功能的增加,代码复杂性的提高,优化代码的需要,或新技术的出现都会导致重构代码的需求。在没有写单元测试的情况下,对代码进行大规模修改,是一件不敢想象的事情,因为写错的概率实在太大了。而如果原代码有单元测试,即使修改了代码单测依然通过,说明没有破坏程序正确性,一点都不慌! -
及早发现问题,降低定位问题的成本
bug发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,能在单测阶段发现的问题,就不用等到联调测试再暴露出来,减少解决成本。 -
代码设计的提升
为了实现功能而编码的时候,大多时候我们考虑的是函数实现,一顿编写,写好了运行成功就万事大吉了。而写单元测试的时候,我们跳出了函数,从输入输出的角度去思考函数/结构体的功能。此时我们不由得,这个函数真的需要吗?这个函数的功能是不是可以简化/抽象/拆分一下?这个函数考虑的情况似乎不够全面吧?这里的使用外部依赖是否真的合适?这些思考,能推动我们更仔细思考代码的设计,加深对代码功能的理解,从而形成更合理的设计和结构。 -
单元测试也是一种编写文档的行为
单元测试是产品代码的第⼀个使⽤者,并伴随代码⽣命周期的始终。它⽐任何⽂字⽂档更直观、更准确、更有效,⽽且永不过时。当产品代码更新时单元测试就会同步更新(否则通不过测试);而⽂字⽂档则更新往往滞后,甚⾄不更新,从⽽对后来的开发者和维护者产⽣误导,正所谓:过时的⽂档⽐没有⽂档更有害。
单元测试的时机
编码前:TDD
Test-Driven Development, 测试驱动开发,是敏捷开发的⼀项核⼼实践和技术,也是⼀种设计⽅法论。TDD原理是开发功能代码之前,先编写测试⽤例代码,然后针对测试⽤例编写功能代码,使其能够通过。其好处在于通过测试的执⾏代码,肯定满⾜需求,⽽且有助于接⼝编程,降低代码耦合,也极⼤降低bug出现⼏率。
然而TDD的坏处也显而易见:由于测试⽤例在未进⾏代码设计前写;很有可能限制开发者对代码整体设计,并且由于TDD对开发⼈员要求⾮常⾼,跟传统开发思维不⼀样,因此实施起来比较困难,在客观情况不满足的情况下,不应该盲目追求对业务代码使用TDD的开发模式。
编码后:存量
在完成业务需求后,可能由于上线时间较为紧、没有单测相关规划的历史缘故,当时只手动测试是否符合功能。
而这部分存量代码出现较大的新需求或者维护已经成为问题,需要大规模重构时,是推动补全单测的好时机。因为为存量代码补充上单测一方面能够推进重构者进一步理解原先逻辑,另一方面能够增强重构后的信心,降低风险。
但补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,甚至写单测者并不是原编码设计者。
与编码同步进行:增量
及时为增量代码写上单测是一种良好的习惯。因为此时有对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且能在单测阶段发现问题,修复的成本也是最小的,不必等到联调测试中发现。
另一方面在写单测的过程中也能够反思业务代码的正确性、合理性,能推动我们在实现的过程中更好地反思代码的设计并及时调整。
Golang单测框架选型 & 示例
主要介绍golang原生testing框架、testify框架、goconvey框架,看一下哪种框架是结合业务体验更好的。
golang原生testing框架
特点
文件形式:文件以_test.go 结尾
函数形式:func TestXxx(*testing.T)
断言:使用 t.Errorf 或相关方法来发出失败信号
运行:使用go test –v执行单元测试
示例
// 原函数 (in add.go)
func Add(a,b int) int {
return a + b
}
// 测试函数 (in add_test.go)
func TestAdd(t *testing.T) {
var (
a = 1
b = 1
expected = 2
)
var got = Add(a, b)
if got != expected {
t.Errorf("Add(%d, %d) = %d, expected %d", a, b, got, expected)
}
}
扩展:Table-Driven 的测试模式
Table-Driven 是很多 Go 语言开发者所推崇的测试代码编写方式,Go 语言标准库的测试也是通过这个结构来撰写的。简单来说其实就是将多个测试用例封装到数组中,依次执行相同的测试逻辑。值得一提的是改涉及思想并不是golang 自带testing框架特有,即使是用其他测试框架,也可以应用此种写法。
一般来说大概长这个样子:
func TestAdd(t *testing.T) {
var addTests = []struct {
a int
b int
expected int // expected result
}{
{1, -1, 0},
{3, 2, 5},
{7, 3, 10},
}
for _, tt := range addTests {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected)
}
}
}
其中可见我们通过匿名结构体构建了每一个测试用例的结构,一个输入 in 和一个我们期望的输出 out,然后在真实的测试函数中,通过 range 轮询每一个测试用例,并且调用测试函数,比较输出结果,如果输出结果不等于我们期望的结果,即报错。这种测试框架最好的一点在于,结构清晰,并且添加新的测试 case 会非常方便。而另一方面,缺点在于测试用例之间的层级关系不明显 都是平铺关系,并且各个测试用例的断言方式相对单一,mock、stub的相对不灵活。
Testify
简介
Testify基于gotesting编写,所以语法上、执行命令行与go test完全兼容,只是其是比较清晰的断言定义。它提供 assert 和 require 两种用法,分别对应失败后的执行策略,前者失败后继续执行,后者失败后立刻停止。 但是它们都是单次断言失败,当前test case就失败。
示例
import (
"testing"
"github.com/stretchr/testify/assert"
)
...
// 直观使用assert断言能力
func TestFind(t *testing.T) {
service := ...
firstName, lastName := service.find(someParams)
assert.Equal(t, "John", firstName)
assert.Equal(t, "Dow", lastName)
}
// Table-Driven的的模式使用assert
func TestCalculate(t *testing.T) {
assert := assert.New(t)
var tests = []struct {
input int
expected int
}{
{2, 4},
{-1, 1},
{10, 2},
{-5, -3},
{99999, 100001},
}
for _, test := range tests {
assert.Equal(Calculate(test.input), test.expected)
}
}
其他特性
testify工具还提供了mock功能,不过在实际过程中,不太建议使用该功能,因为相较其他成熟mock框架testify的mock使用起来较为不便
Goconvey
GoConvey是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多 Web 界面特性。
直观示范 & 解释
// 被测原函数
func StringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
if (a == nil) != (b == nil) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
// 测试代码
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual should return true when a != nil && b != nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})
}
例子对刚接触Convey的看来可能有点抽象,这里展开讲解一下:
每个测试用例必须使用Convey函数包裹起来,可以理解为一个Convey就是一个测试用例(嵌套情况下则为一组)
Convey的三个参数 分别为:
- 第一个参数为string类型的测试描述
- 第二个参数为测试函数的入参(类型为*testing.T)
- 第三个参数为不接收任何参数也不返回任何值的函数(习惯使用闭包),并且在第三个参数闭包的实现中通过So函数完成断言判断
而对于断言So 参数的理解,总共有三个参数:
- actual: 输入
- assert:断言
- expected:期望值
关于assert,Convey 包已经帮我们定义了大部分的基础断言了:
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\assertions.go
var (
ShouldEqual = assertions.ShouldEqual
ShouldNotEqual = assertions.ShouldNotEqual
ShouldAlmostEqual = assertions.ShouldAlmostEqual
ShouldNotAlmostEqual = assertions.ShouldNotAlmostEqual
ShouldResemble = assertions.ShouldResemble
ShouldNotResemble = assertions.ShouldNotResemble
.....
如果上述不满足,我们也可以自定义。
Convey的嵌套
Convey语句可以无限嵌套,以体现测试用例之间的关系。需要注意的是,只有最外层的Convey需要传入*testing.T类型的变量t。
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual should return true when a != nil && b != nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})
Convey("TestStringSliceEqual should return true when a == nil && b == nil", t, func() {
So(StringSliceEqual(nil, nil), ShouldBeTrue)
})
Convey("TestStringSliceEqual should return false when a == nil && b != nil", t, func() {
a := []string(nil)
b := []string{}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
Convey("TestStringSliceEqual should return false when a != nil && b != nil", t, func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
}
注:子Convey 的执行策略是并行的,因此前面的子Convey 执行失败,不会影响后面的Convey 执行。但是一个Convey 下的子 So,执行是串行的。
测试框架总结&选型
首先自带testing包没有断言功能,编写起来方便程度不足
Testify拥有断言能力,一般采用Table-Driven方式编写测试用例,但这样用例之间的层级关系不够明显,并且和其他mock/stub框架结合使用的灵活程度GoConvey
GoConvey能够方便清晰地体现和管理测试用例,断言能力丰富。而且层级嵌套用例的编写相较于Table-Driven的写法灵活轻量,并且和其他Stub/Mock框架的兼容性相比更好,不足之处在于理解起来可能需要一些学习成本。
总的来说GoConvey值得推荐,下方实践的例子采用的是GoConvey。
Mock & Stub
基本概念
一般来说,单元测试中是不允许有外部依赖的,那么也就是说这些外部依赖都需要被模拟。
Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。通过Mock和Stub我们不仅可以让测试环境没有外部依赖而且还可以模拟一些异常行为,普遍来说,我们遇到最常见的依赖无非下面几种:
- 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
- 数据库依赖
- I/O依赖
当然,还有可能是依赖还未开发完成的功能模块。但是处理方法都是大同小异的——抽象成接口,通过mock和stub进行模拟测试。
在Go语言中,可以这样描述Mock和Stub:
- Mock:在测试包中创建一个结构体,满足某个外部依赖的接口 interface{}
- Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法 下面简单的举例说明
Mock
直观实战案例
原逻辑背景
例如有一段逻辑,需要先通过某个storage系统的client读取一个远程系统上文件,然后经过一定处理后,再通过client删除远程系统上该文件
func demoFunc(client storage.Client, name string) {
// 其他前置逻辑 ...
// 从远程存储系统获取该文件
rsp, err := client.Get(context.Background(), name)
if err != nil{
log.Error("err:%v", err)
}
// 对读取文件rsp的处理逻辑 ...
// 从远程存储系统删除该文件
_, err = client.Delete(context.Background(),name)
if err != nil{
log.Error("err:%v", err)
}
// 其他逻辑 ...
}
显然,这里涉及到了外部依赖,我们可以mock client的行为,去避免这个外部依赖,并关注于测试能够覆盖我们的代码逻辑。
对client进行mock的过程
首先看需要mock的client interface
type Client interface {
Get(ctx context.Context, name string) ([]byte, error)
Delete(ctx context.Context, name string) (error)
// other method..
}
然后,为了在不使用外部依赖的前提下测试到通过client分别读取、删除文件成功或者失败的逻辑,我们自己实现了一个模拟的fakeClient,它实现了Client这个interface,并定义了其方法如果name是特定值 就会返回err或者特定内容,如下:
type fakeClient struct {
}
func (c *fakeClient) Get(ctx context.Context, name string) ([]byte, error) {
if name == "errName" {
return nil, errors.New("getErr")
}
if name == "sucName" {
return []byte("demo val"), nil
}
// other case ..
return nil, errors.New("unknown name")
}
func (c *fakeClient) Delete(ctx context.Context, name string) (error) {
if name == "errName" {
return errors.New("getErr")
}
if name == "sucName" {
return nil
}
// other case ..
return errors.New("unknown name")
}
// other method to implement..
在测试开始执行时, 传入的client改为我们的fakeClient对象,而非真正连接外部依赖的client。并且由于我们可以控制fakeClient的方法,根据不同入参定制不同行为;另一方面在测试的时候传入对应入参,从而达到mock效果,走到我们想要走到的测试逻辑。
func Test_demoFunc(t *testing.T) {
c := fakeClient{}
demoCosFunc(c, "sucName")
// other test logic..
}
Mock框架——GoMock
在上述案例中,我们为了模拟一些外部依赖或者错误情况时,手动实现了一个mock类,然后为该mock类注入我们希望要的逻辑,从而屏蔽依赖,达到模拟的效果。而在interface较为复杂的时候,我们可以借用一些Mock框架,例如GoMock。
GoMock是由Golang官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能。
GoMock测试框架包含了GoMock包和mockgen工具两部分
- 其中GoMock包完成对桩对象生命周期的管理
- mockgen工具用来生成interface对应的Mock类源文件。
GoMock使用示例
- 找到需要mock的interface
package tmp
type Repository interface {
Create(key string, value []byte) error
Retrieve(key string) ([]byte, error)
Update(key string, value []byte) error
Delete(key string) error
}
- 使用mockgen工具生成mock类文件
mockgen -source={file_name}.go > {mock_file_name}.go
自动化生成Mock类的代码如下:
// Code generated by MockGen. DO NOT EDIT.
// Source: gomocktest.go
// Package mock is a generated GoMock package.
package mock_repository
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockRepository is a mock of Repository interface
type MockRepository struct {
ctrl *gomock.Controller
recorder *MockRepositoryMockRecorder
}
// MockRepositoryMockRecorder is the mock recorder for MockRepository
type MockRepositoryMockRecorder struct {
mock *MockRepository
}
// NewMockRepository creates a new mock instance
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
mock := &MockRepository{ctrl: ctrl}
mock.recorder = &MockRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
return m.recorder
}
........... //省略
- Import gomock & 生成的mock的类
import (
. "github.com/golang/mock/gomock"
"test/mock_repository"
"testing"
)
- 初始化控制器 && mock对象
// 初始化控制器
ctrl := NewController(t)
defer ctrl.Finish()
// 创建mock对象
mockRepo := mock_repository.NewMockRepository(ctrl)
- mock对象的行为注入
// mock对象的行为注入
mockRepo.EXPECT().Retrieve("ray").Return(nil, errors.New("no such person"))
mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
Stub
stub(打桩)即在测试包中创建一个模拟方法,用于替换生成代码中的方法。
打桩的库
GoStub 与 Gomonkey 均为主流的打桩库
但GoStub存在如下几个问题:
- 方法(成员函数)无法通过GoStub框架打桩
- GoStub 如果对func打桩,还必须声明出 variable 才能进行stub,即使是 interface method 也需要这么来定义,对代码有侵入性。
- 另外,GoStub如果需要stub的方法,入参和返回的数量都是长度不固定的数组类型,就无法进行stub
而反观Gomokey,能够实现GoStub的功能,还能避免其缺陷,故推荐使用。
Gomonkey的基本场景为:
基本场景:为一个函数打桩
基本场景:为一个过程打桩
基本场景:为一个方法打桩
复合场景:由任意相同或不同的基本场景组合而成
下面以cos云存储删除文件的逻辑为案例,演示下如何使用gomonkey为方法打桩
import (
"context"
"net/http"
"net/url"
"os"
"github.com/tencentyun/cos-go-sdk-v5"
)
func demo() {
// 初始化cos client
urlObj, _ := url.Parse("https://<bucket>.cos.<region>.myqcloud.com")
baseUrl := &cos.BaseURL{BucketURL: urlObj}
c := cos.NewClient(baseUrl, &http.Client{
Transport: &cos.AuthorizationTransport{
SecretID: os.Getenv("COS_SECRETID"),
SecretKey: os.Getenv("COS_SECRETKEY"),
},
})
// *删除文件对象 (有外部依赖)
name := "test/object"
_,err := c.Object.Delete(context.Background(),name)
if err != nil{
panic(err)
}
}
该段代码逻辑中有向腾讯云cos删除文件,显然这里有外部网络调用的依赖,故需要进行打桩。
下面是cos-go-sdk文件中的代码节选,可见我们需要为client对象的Delete方法打桩
// Delete Object请求可以将一个文件(Object)删除。
//
// https://www.qcloud.com/document/product/436/7743
func (s *ObjectService) Delete(ctx context.Context, name string, opt ...*ObjectDeleteOptions) (*Response, error)
故demo函数的测试代码中需要有打桩代码如下:
// 1. 生成需要打桩方法的对象,即client
c := cos.NewClient(&cos.BaseURL{}, &http.Client{})
// 2. 定义好被打桩方法的返回值
stubRet := []gomonkey.OutputCell{
{Values: gomonkey.Params{nil}}, // 模拟第一次调用Delete的时候,删除成功,返回nil
}
// 3. gomonkey 进行对该方法打桩,加上patch
patch := gomonkey.ApplyMethodSeq(reflect.TypeOf(c), "Delete", stubRet)
// 4. 函数退出前及时reset patch,防止影响后续测试
defer patch.Reset()
- 生成需要打桩方法的对象
- 定义好OutputCell,期望的返回值
- 调用gomonkey.ApplyMethodSeq,第一个参数为对象的reflect.Type, 第二个位method名,第三个位期望返回值
- 该测试完成后删除补丁(patch.Reset)
关于模拟的方式、补丁的生命周期 一些思考
对于不同的场景,我们mock或者stub时具体模拟方式、补丁的生命周期可能有所不通,具体如下两种
每次仅模拟单次行为的结果,不需要考虑入参与顺序 | 模拟所有行为,需要考虑入参 | |
---|---|---|
场景 | 被模拟的对象(方法、函数等)只需要在某个case内简单调用几次 | 被模拟的对象(方法、函数等)在测试的时候对于不同的case会被调用许多次,而且期望对所有模拟的情况进行一个集中式的管理。 |
具体行为 | 那么在模拟的时候,可以只在该case需要用到该外部依赖的时进行模拟,且只模拟其返回值,不做别的逻辑,然后随后对模拟的补丁进行reset,以保证不会影响到其他case。 | 那么这时候模拟的时候需要对入参进行switch-case,模拟各个case不同入的参情况将要对应的返回。这时候模拟的补丁不是在case内马上被reset,而是在整批case都进行完毕后才会reset。 |
优点 | 方便、简单、直接。 | 更方便、集中地对被模拟对象的在有不同入参下的各种行为进行管理维护 |
缺点 | 临时性的模拟,无法根据入参或者顺序进行不同的效果。 | 模拟较为臃肿,并且若有不慎可能case之间的模拟会相互影响 |
例子 | 仅调用一下外部系统,进行某种注册 或者 记录流水的行为,这时可以不论入参,简单模拟为其返回是否成功。 | 上述的trpc selector,由于多个case需要测试select时不同的情况,需要模拟其select方法在不同target的情况下的返回值以及行为。 |
Mock&Stub的辨析与总结
对于控制被替代的方法来讲,mock如果想支持不同的输出,就需要提前实现不同的分支代码,甚至需要定义不同的mock结构体来实现,这样的mock代码会变成一个支持所有逻辑分支的一个最大集合,mock代码复杂性会变高;
另一方面,stub却能很好的控制桩函数的不同分支,因为stub替换的是函数,那么只要需要再用到这种输出的时候,定义一个函数即可,而这个函数甚至都可以是匿名函数。
引用deanyang大神对mock与stub的辨析理解:
打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。
stub可以理解为mock的子集,mock更强大一些:
- mock可以验证实现过程,验证某个函数是否被执行,被执行几次
- mock可以依条件生效,比如传入特定参数,才会使mock效果生效
- mock可以指定返回结果
- 当mock指定任何参数都返回固定的结果时,它等于stub
实践流程&技巧:Step by Step
前提准备:
IDE:GoLand
测试框架:GoConvey
stub库:GoMonkey (满足不了需求时可采用其他)
测试文件与函数的生成
-
右键函数名,Generate-> test for function
[图片上传失败...(image-d84435-1610712614274)]
-
发现即可自动生成同名测试文件_test.go,然后里面已经生成好测试函数
GoConvey Web UI生成测试代码case基本脚手架
- 运行Goconvey Web UI工具:$GOPATH/bin/goconvey
- 打开用例脚手架编写工具http://127.0.0.1:8080/composer.html
这里要考虑被测函数的各个分支逻辑,以便都能覆盖到各种情况
根据所测函数逻辑,思考和编写所有要测case,然后用脚手架编写工具,有层次地写出各个要测的case名
-
脚手架中填充具体测试逻辑
将生成的Convey脚手架复制到Goland被测函数中,填写各个case的具体测试逻辑
填充脚手架,编写每个case的具体测试逻辑:3A法则
单元测试的代码结构⼀般一个三步经典结构:准备(arrange),调⽤(action),断⾔(assert)。
- Arrange: 准备部分的⽬的是准备好调⽤所需要的外部环境,如数据,Stub,Mock,临时变量,调⽤请求,环境背景变量等等。
- Action: 调⽤部分则是实际调⽤需要测试⽅法,函数或者流程。
- Assert: 断⾔部分判断调⽤部分的返回结果是否符合预期。
例子:
运行测试 && 查看覆盖率
- 运行测试,并生成覆盖率产物:go test -gcflags=all=-l -coverprofile=coverage.out
注: -gcflags=all=-l参数是因为对内联函数的Stub一定要加上这个参数才可生效。所以,我们的命令行默认加上-gcflags=all=-l就行了
-
用网页打开覆盖率产物:go tool cover -html=coverage.out
注: Web界面绿色为覆盖到,红色为没有覆盖到
实践总结
有时写单测看着空荡荡的测试文件,一时间总觉得不知道无从下手,亦或者会产生抗拒心理。
而上述的流程就是希望能够将单测尽量能够流程化,减少开始写单测时的心里抗拒 & 提高效率
单元测试对日常编码/设计的思考&启发
合理地对外部依赖进行封装
例如可以将一组相关的外部依赖操作(例如所有访问某个特定外部系统的函数集)通过一个interface集中封装 而不是散落的许多个函数,这样便于实现mock类然后方便地替代掉其相关的所有外部依赖操作。
同时多个连续相关的外部依赖调用也可以进行进一步的封装抽象出一个函数,从而减少mock、stub的数量。降低单元模块的圈复杂度
当业务代码单元的圈复杂度过高,对其的单元测试往往是比较困难的,因为有太多的分支与路径需要去覆盖,而且后期的维护也比较苦难。
降低圈复杂度,无疑能够提高代码的可维护性,并且构建单元测试也会更加便捷。
下面总结列出一点关于降低全复杂度的实践方法:
- if-else嵌套较深的地方/switch分支较多的地方,将它们抽取提炼出小函数来,从而降低原函数的圈复杂度
- 表驱动法:利用Go闭包的特性加上Map,可以在根据Map的key来调用对应的处理逻辑函数
- 调换条件表达顺序达到简化复杂度,可以省去最后的else
- 如果一系列条件判断都得到相同结果,将这些判断合并为一个条件式
- 对复杂条件表达式(例如if A || B && C || D)提取成独立函数(提炼成 if func())
-
不要在init或者全局变量初始化的时候访问外部资源
由于golang的init或者初始化全局变量都在测试执行之前进行(甚至在TestMain之前),从而无法对其进行对应的mock或者stub,故不推荐在init或者全局变量初始化的时候访问外部资源。