单元测试
单元测试
单元测试不允许有任何的外部依赖,我们不会在测试代码中连接数据库,调用api等。这些外部依赖在执行的时候都需要被模拟(mock/stub)。
常见依赖
- 网络依赖-函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
- 数据库依赖
- I/o依赖(文件)
Mock和Stub区别
Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。 通过Mock和Stub我们不仅可以让测试环境没有外部依赖,而且还可以模拟一些异常行为,如数据库服务不可用,没有文件的访问权限等等。
在Go语言中,可以这样描述Mock和Stub:
- Mock:在测试包中创建一个结构体,满足某个外部依赖的接口
interface{}
、 - Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法
例子
mock
// mock
//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服务不可用")
}
Go Mock
mock/stub 测试,用于测试函数的依赖比较复杂,并且有的依赖不能直接创建,例如数据库、文件I/O等。这种场景可以使用mock对象的模拟依赖项的行为。
gomock 是官方提供的 mock 框架,同时还提供了 mockgen 工具用来辅助生成测试代码。
下载
go get -u github.com/golang/mock/gomock
go get -u github.com/golang/mock/mockgen
Demo
// db.go
type DB interface {
Get(key string) (int, error)
}
func GetFromDB(db DB, key string) int {
if value, err := db.Get(key); err == nil {
return value
}
return -1
}
假设DB
是代码中负责与数据库交互的部分(在这里用map来模拟),测试用例中不能创建真实的数据库连接。这个时候,如果我们需要测试GetFromDB
这个函数内部的逻辑,就需要mock接口DB
。
第一步:使用 mockgen
生成 db_mock.go
。一般传递三个参数。包含需要被mock的接口得到源文件source
,生成的目标文件destination
,包名package
。
$ mockgen -source=db.go -destination=db_mock.go -package=main
第二步:新建 db_test.go
,写测试用例。
func TestGetFromDB(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish() // 断言 DB.Get() 方法是否被调用
m := NewMockDB(ctrl)
m.EXPECT().Get(gomock.Eq("Tom")).Return(100, errors.New("not exist"))
if v := GetFromDB(m, "Tom"); v != -1 {
t.Fatal("expected -1, but got", v)
}
}
- 这个测试用例有2个目的,一是使用
ctrl.Finish()
断言DB.Get()
被是否被调用,如果没有被调用,后续的 mock 就失去了意义; - 二是测试方法
GetFromDB()
的逻辑是否正确(如果DB.Get()
返回 error,那么GetFromDB()
返回 -1)。 NewMockDB()
的定义在db_mock.go
中,由 mockgen 自动生成。
最终的代码结构如下:
project/
|--db.go
|--db_mock.go // generated by mockgen
|--db_test.go
执行测试:
$ go test . -cover -v
=== RUN TestGetFromDB
--- PASS: TestGetFromDB (0.00s)
PASS
coverage: 81.2% of statements
ok example 0.008s coverage: 81.2% of statements
打桩
在上面的例子中,当 Get()
的参数为 Tom,则返回 error,这称之为打桩(stub)
,有明确的参数和返回值是最简单打桩方式。除此之外,检测调用次数、调用顺序,动态设置返回值等方式也经常使用。
参数(Eq, Any, Not, Nil)
m.EXPECT().Get(gomock.Eq("Tom")).Return(0, errors.New("not exist"))
m.EXPECT().Get(gomock.Any()).Return(630, nil)
m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil)
m.EXPECT().Get(gomock.Nil()).Return(0, errors.New("nil"))
Eq(value)
表示与 value 等价的值。Any()
可以用来表示任意的入参。Not(value)
用来表示非 value 以外的值。Nil()
表示 None 值。
返回值(Return, DoAndReturn)
m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil)
m.EXPECT().Get(gomock.Any()).Do(func(key string) {
t.Log(key)
})
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string) (int, error) {
if key == "Sam" {
return 630, nil
}
return 0, errors.New("not exist")
})
Return
返回确定的值Do
Mock 方法被调用时,要执行的操作吗,忽略返回值。DoAndReturn
可以动态地控制返回值。
调用次数(Times)
func TestGetFromDB(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockDB(ctrl)
m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil).Times(2)
GetFromDB(m, "ABC")
GetFromDB(m, "DEF")
}
Times()
断言 Mock 方法被调用的次数。MaxTimes()
最大次数。MinTimes()
最小次数。AnyTimes()
任意次数(包括 0 次)。
调用顺序(InOrder)
func TestGetFromDB(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish() // 断言 DB.Get() 方法是否被调用
m := NewMockDB(ctrl)
o1 := m.EXPECT().Get(gomock.Eq("Tom")).Return(0, errors.New("not exist"))
o2 := m.EXPECT().Get(gomock.Eq("Sam")).Return(630, nil)
gomock.InOrder(o1, o2)
GetFromDB(m, "Tom")
GetFromDB(m, "Sam")
}
报错
在使用mockgen创建的时候报错,
PS J:\go-project\id-maker> mockgen -source=internal/usecase/interfaces.go -destination=internal/usecase/mock_test.go
2023/04/24 22:13:31 Loading input failed: failed parsing source file internal/usecase/interfaces: open internal/usecase/interfaces: The system cannot find the file specified.
好像是在source提供的相对路径里面无法识别.go
文件,网上找了很多方法都没办法识别,最后使用绝对路径生成了。不知道为什么。。。。。
PS J:\go-project\id-maker\internal\usecase> mockgen -source=J:\go-project\id-maker\internal\usecase\interfaces.go -destination=J:\go-project\id-maker\internal\usecase\mock_test.go -package=usecase_test
PS J:\go-project\id-maker\internal\usecase>
单元测试-断言
“Go不提供断言,我们知道这会带来一定的不便,其主要目的是为了防止你们这些程序员在错误处理上偷懒。我们知道这是一个争论点,但是我们觉得这样很coooool~~。”所以,我们引入断言库的原因也很明显了:偷懒,引入断言能为我们提供便利——提高测试效率,增强代码可读性。
在断言库的选择上,我们似乎没有过多的选择,从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。
单元测试httptest的使用
现在有一个需求那就是,我们需要使用Golang的net/http包中的http.Get(url)方法去向服务器端请求数据,但是负责服务端的同事并没有将接口实现(可能是同事太忙,把妹,喝酒,扯淡, XO等等)以至于你只知道返回数据的json格式,然而无法请求到真实的数据,但是你的工作进度并不能因为同事而耽误,需要测试你的代码的正确性,那么怎么办?办法就是通过单元测试中的httptest,实现http server,并设置好返回值,那么http.Get(url)的请求就会直接打到单元测试的http server上,同时得到你设置好的返回值,你就可以继续去处理数据,测试你的代码逻辑了。