mock 测试和 gomock 的使用

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 测试的代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据