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.