mock 测试是什么
在平常做单元测试中,常常会依赖外部的系统,这导致单元测试很难写。比如业务系统中有一个用户信息更新的函数 UpdateUserInfo
,如果对该函数做单元测试,则需要连接数据库,建立测试所需的基础数据,然后执行测试,最后清除测试导致的数据更新。这导致单元测试的成本很高,并且难以维护。
这时候,mock 测试就可以发挥它的作用了。我们将对数据库的操作做成假的,也就是 mock 出一个假的数据库操作对象,然后注入到我们的业务逻辑中使用,然后就可以对业务逻辑进行测试。
看了描述可能还是有点糊涂,下面会用一个例子来说明
一个 mock 测试的例子
这个例子是一个简单的用户登录,其中,UserDBI
是用户表操作的接口,其实现是UserDB
,我们的业务层有 UserService
,实现了 Login
方法,我们现在要做的就是对 Login
这里的业务逻辑进行单元测试。项目结构如下:
.
├── db
│ └── userdb.go
├── go.mod
├── go.sum
├── mocks
└── service
├── user.go
└── user_test.go
UserDBI
的代码如下:
type UserDBI interface {
Get(name string, password string) (*User, error)
}
UserDB
的相关代码如下:
type UserDB struct {
db *sql.DB
}
func NewUserDB(user string, password string, host string, port int, db string) (UserDBI, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", user, password, host, port, db)
var userDB UserDB
var err error
userDB.db, err = sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
return &userDB, nil
}
// Get 根据 UserID 获取用户资料
func (udb *UserDB) Get(name string, password string) (*User, error) {
s := "SELECT * FROM user WHERE name = ? AND password = ?"
stmt, err := udb.db.Prepare(s)
if err != nil {
return nil, err
}
defer stmt.Close()
var user User
err = stmt.QueryRow(name, password).Scan(&user)
if err != nil {
return nil, err
}
return &user, nil
}
Login
的逻辑如下:
type UserService struct {
db db.UserDBI
}
// NewUserService 实例化用户服务
func NewUserService(db db.UserDBI) *UserService {
var userService UserService
userService.db = db
return &userService
}
// Login 登录
func (userService *UserService) Login(name, password string) (*db.User, error) {
user, err := userService.db.Get(name, password)
if err != nil {
log.Println(err)
return nil, err
}
return user, nil
}
可以知道,通过 NewUserService
可以实例化出 UserService 对象,然后调用 Login
即可实现登录逻辑,但是在 Login
中调用了 UserDB 的 Get
方法,而 Get
方法又会从实际的数据库中去查询。这就是我们这个例子的测试难点:有没有办法不依赖实际的数据库去完成单元测试呢?
这里我们的 NewUserService
的参数是 UserDBI
这个接口,在实际的代码运行中,我们是将 UserDB
的实例化对象传进去的,但是在测试的时候,我们完全可以传入一个不操作数据库的假的对象,这个对象只需要实现了 UserDBI
的接口即可。因此我们创建了一个 FakeUserDB
,这个 FakeUserDB
就是我们 mock 出来的内容了。这个 FakeUserDB
非常简单,因为它什么也不包含。
type FakeUserDB struct {
}
然后,这个 FakeUserDB
实现了 Get
方法,如下:
func (db *FakeUserDB) Get(name string, password string) (*User, error) {
if name == "user" && password == "123456" {
return &User{ID: 1, Name: "user", Password: "123456", Age: 20, Gender: "male"}, nil
} else {
return nil, errors.New("no such user")
}
}
这里的 Get 方法中既可以返回正常情况,又可以返回错误的情况,完全满足我们的测试需求。这样,我们就完成 mock 测试的一大半内容了,接下来我们来实际写单元测试即可。
func TestUserLoginWithFakeDB(t *testing.T) {
testcases := []struct {
Name string
Password string
ExpectUser *db.User
ExpectError bool
}{
{"user", "123456", &db.User{1, "user", "123456", 20, "male"}, false},
{"user2", "123456", nil, true},
}
var fakeUserDB db.FakeUserDB
userService := NewUserService(&fakeUserDB)
for i, testcase := range testcases {
user, err := userService.Login(testcase.Name, testcase.Password)
if testcase.ExpectError {
assert.Error(t, err, "login error:", i)
} else {
assert.NoError(t, err, "login error:", i)
}
assert.Equal(t, testcase.ExpectUser, user, "user doesn't equal")
}
}
执行单元测试:
$ go test github.com/joyme123/gomock-examples/service
ok github.com/joyme123/gomock-examples/service 0.002s
可以看出,我们在测试时使用了 FakeUserDB,这样就彻底摆脱了数据库,并且这里的单元测试考虑了登录成功和登录失败的方式。
但是手写 FakeUserDB
同样也有点工作量,这个例子为了简洁所以体现不出来。考虑当 UserDBI
这个接口的方法很多的时候,我们需要额外手写的代码量立马就多了起来。还好 go 官方就提供了 gomock
这个工具,来帮我们更好的完成单元测试的工作。
gomock 的使用
gomock 的官方仓库地址是:https://github.com/golang/mock.git。gomock 并不复杂,其主要的工作是将我们刚刚的 FakeUserDB
由手动编写变成自动生成。因此我会用刚刚的例子加上 gomock 再做一遍示范。
gomock 的安装
执行以下命令即可安装:
GO111MODULE=on go get github.com/golang/mock/mockgen@latest
mockgen 会安装在你的 $GOPATH 下的 bin 目录中。
gomock 生成代码
在上面的例子中,我们用 FakeUserDB
实现了 UserDBI
这个接口,这里同样也是使用 mockgen 这个程序生成实现 UserDBI
的代码。
mkdir mocks
mockgen -package=mocks -destination=mocks/userdb_mock.go github.com/joyme123/gomock-examples/db UserDBI
在 mocks 下生成的文件如下:
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/joyme123/gomock-examples/db (interfaces: UserDBI)
// Package mocks is a generated GoMock package.
package mocks
import (
gomock "github.com/golang/mock/gomock"
db "github.com/joyme123/gomock-examples/db"
reflect "reflect"
)
// MockUserDBI is a mock of UserDBI interface
type MockUserDBI struct {
ctrl *gomock.Controller
recorder *MockUserDBIMockRecorder
}
// MockUserDBIMockRecorder is the mock recorder for MockUserDBI
type MockUserDBIMockRecorder struct {
mock *MockUserDBI
}
// NewMockUserDBI creates a new mock instance
func NewMockUserDBI(ctrl *gomock.Controller) *MockUserDBI {
mock := &MockUserDBI{ctrl: ctrl}
mock.recorder = &MockUserDBIMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockUserDBI) EXPECT() *MockUserDBIMockRecorder {
return m.recorder
}
// Get mocks base method
func (m *MockUserDBI) Get(arg0, arg1 string) (*db.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", arg0, arg1)
ret0, _ := ret[0].(*db.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get
func (mr *MockUserDBIMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockUserDBI)(nil).Get), arg0, arg1)
}
执行测试
代码生成结束之后,我们开始写单元测试了。
func TestUserLoginWithGoMock(t *testing.T) {
testcases := []struct {
Name string
Password string
MockUser *db.User
MockErr error
ExpectUser *db.User
ExpectError bool
}{
{"user", "123456", &db.User{1, "user", "123456", 20, "male"}, nil, &db.User{1, "user", "123456", 20, "male"}, false},
{"user2", "123456", nil, errors.New(""), nil, true},
}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
userDB := mocks.NewMockUserDBI(ctrl)
for i, testcase := range testcases {
userDB.EXPECT().Get(testcase.Name, testcase.Password).Return(testcase.MockUser, testcase.MockErr)
userService := NewUserService(userDB)
user, err := userService.Login(testcase.Name, testcase.Password)
if testcase.ExpectError {
assert.Error(t, err, "login error:", i)
} else {
assert.NoError(t, err, "login error:", i)
}
assert.Equal(t, testcase.ExpectUser, user, "user doesn't equal")
}
}
我们在测试用例中增加了两个字段:MockUser, MockErr,这就是我们 Mock 出来的数据,通过 userDB := mocks.NewMockUserDBI(ctrl)
实例化 mock 出来的 userDB,这里的 userDB 等价于上一个例子中的 fakeUserDB
,然后调用 userDB.EXPECT().Get(testcase.Name, testcase.Password).Return(testcase.MockUser, testcase.MockErr)
这句话,来输入我们想输入的参数,产生我们想要的输出即可。这样在 Login
函数执行时会自动产生我们刚刚设定的 Mock 数据,完成单元测试的需求。
如果传参的时候,对参数不确定,可以使用 gomock.Any()
来代替,如果希望多次调用该方法仍然返回相同的结果,可以使用 .AnyTimes()
。
总结
mock 测试在实现上的重点是将外部依赖实现成可替换的,例子中使用了 UserDBI
这个接口来抽象出用户表的操作,然后使用参数的方式来实现 UserService
的实例化。接口和使用参数来实例化(也就是不要把外部依赖写死)缺一不可。只要注意到这一点就可以写出方便 mock 测试的代码。