What is mock testing
In normal unit testing, you often rely on external systems, which makes it difficult to write unit tests. For example, if there is a function UpdateUserInfo
in the business system to update user information, if you do unit tests on this function, you need to connect to the database, create the base data needed for the test, then execute the test, and finally clear the data update caused by the test. This makes unit testing costly and difficult to maintain.
This is where mock tests come into their own. We make a fake database operation, that is, we mock a fake database operation object, and then inject it into our business logic to use it, and then we can test the business logic.
The description may be a bit confusing, so here is an example to illustrate
An example of a mock test
This example is a simple user login where UserDBI
is the interface for user table operations and its implementation is UserDB
and our business layer has UserService
which implements the Login
method, all we have to do now is to unit test the business logic here in Login
. The project structure is as follows.
1
2
3
4
5
6
7
8
9
|
.
├── db
│ └── userdb.go
├── go.mod
├── go.sum
├── mocks
└── service
├── user.go
└── user_test.go
|
The code for UserDBI
is as follows.
1
2
3
|
type UserDBI interface {
Get(name string, password string) (*User, error)
}
|
The relevant code for UserDB
is as follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
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 Get user information based on 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
}
|
The logic of Login
is as follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
type UserService struct {
db db.UserDBI
}
// NewUserService Instantiating user services
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
}
|
As you know, NewUserService
instantiates the UserService object, and then calls Login
to realize the login logic, but the Get
method of UserDB is called in Login
, and the Get
method will query from the actual database. This is the hard part of our example: is there a way to complete the unit test without relying on the actual database?
Here our NewUserService
parameter is the UserDBI
interface. In the actual code run, we pass in the instantiated object of UserDB
, but in the test, we can pass in a fake object that does not operate on the database, and this object only needs to implement the UserDBI
interface. So we create a FakeUserDB
, and this FakeUserDB
is what we mock out. This FakeUserDB
is very simple because it doesn’t contain anything.
1
2
|
type FakeUserDB struct {
}
|
Then, this FakeUserDB
implements the Get
method, as follows.
1
2
3
4
5
6
7
|
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")
}
}
|
The Get method here can return both normal and error cases, which fully satisfies our testing needs. This way, we have completed a large part of the mock test, so let’s write the actual unit test next.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
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")
}
}
|
Execute unit tests.
1
2
|
$ go test github.com/joyme123/gomock-examples/service
ok github.com/joyme123/gomock-examples/service 0.002s
|
As you can see, we use FakeUserDB in our tests, so we get rid of the database completely, and the unit tests here take into account both successful and failed logins.
However, writing FakeUserDB
manually is also a bit of work, which is not reflected in this example for brevity. Consider that when the UserDBI
interface has many methods, the amount of extra code we need to write by hand immediately increases. Fortunately, go provides the official gomock
tool to help us do a better job of unit testing.
Use of gomock
The official repository for gomock is https://github.com/golang/mock.git. gomock is not complicated, its main job is to turn the FakeUserDB
we just wrote from manual to automatic. So I’ll use the example I just gave with gomock to demonstrate it again.
gomock installation
Installation can be done by executing the following command.
1
|
go get github.com/golang/mock/mockgen@latest
|
mockgen will be installed in the bin directory under your $GOPATH.
gomock generates code
In the above example, we implemented the UserDBI
interface with FakeUserDB
, and here we also use the mockgen program to generate the code that implements UserDBI
.
1
2
|
mkdir mocks
mockgen -package=mocks -destination=mocks/userdb_mock.go github.com/joyme123/gomock-examples/db UserDBI
|
The file generated under mocks is as follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
// 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)
}
|
Executing tests
Once the code generation is over, we start writing unit tests.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
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")
}
}
|
We added two fields in the test case: MockUser, MockErr, which is the data we mocked out, and instantiated the mocked out userDB by userDB := mocks.NewMockUserDBI(ctrl)
, where userDB is equivalent to fakeUserDB
in the previous example. Then call userDB.EXPECT().Get(testcase.Name, testcase.Password).Return(testcase.MockUser, testcase.MockErr)
to enter the parameters we want to enter and produce the output we want. This way, the Login
function will automatically generate the mock data we just set up to complete the unit test.
If you are not sure about the parameters when you pass them, you can use gomock.Any()
instead.
Summary
The focus of the mock test implementation is to make the external dependencies replaceable. The example uses the UserDBI
interface to abstract the user table operations, and then instantiates UserService
using parameters. The interface and the use of parameters to instantiate (i.e., don’t write the external dependencies to death) are missing. Just be aware of this and you can write code that is easy to mock test.