Learning and Communication: Go Language Technology WeChat Group
Business cooperation plus WeChat: LetsFeng
Learning and sharing: GoLand2022 Genuine Activation Code for the Family Bucket Universal Edition
Textbooks, documents to learn Go language, I strongly recommend this book
Start your Go language learning journey now! Life is too short, let's Go.
When there is a dependency between projects, we can often test by mocking the implementation of an interface in a relatively concise and independent way.
However, in the process of mocking, because everyone's style is not uniform, and many use minimal implement for mocking, this leads to the fact that the return value of each function implemented by mock is often static, and the caller cannot be made according to the Some complex logic for the return value.
Let's take an example first
package task
type Task interface {
Do(int) (string, error)
}
package mock
type MinimalTask struct {
// filed
}
func NewMinimalTask() *MinimalTask {
return &MinimalTask{}
}
func (mt *MinimalTask) Do(idx int) (string, error) {
return "", nil
}
In the process of other packages using Mock's implementation, it will bring some problems to the test.
For example, if we have the following interface definition and function definition
package pool
import "github.com/ultramesh/mock-example/task"
type TaskPool interface {
Run(times int) error
}
type NewTask func() task.Task
We encapsulate an implementation based on the interface definition and interface constructor definition
package pool
import (
"fmt"
"github.com/pkg/errors"
"github.com/ultramesh/mock-example/task"
)
type TaskPoolImpl struct {
pool []task.Task
}
func NewTaskPoolImpl(newTask NewTask, size int) *TaskPoolImpl {
tp := &TaskPoolImpl{
pool: make([]task.Task, size),
}
for i := 0; i < size; i++ {
tp.pool[i] = newTask()
}
return tp
}
func (tp *TaskPoolImpl) Run(times int) error {
poolLen := len(tp.pool)
for i := 0; i < times; i++ {
ret, err := tp.pool[i%poolLen].Do(i)
if err != nil {
// process error
return errors.Wrap(err, fmt.Sprintf("error while run task %d", i%poolLen))
}
switch ret {
case "":
// process 0
fmt.Println(ret)
case "a":
// process 1
fmt.Println(ret)
case "b":
// process 2
fmt.Println(ret)
case "c":
// process 3
fmt.Println(ret)
}
}
return nil
}
Next, if we write the test, it should be as follows
package pool
import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"testing"
)
type TestSuit struct {
name string
newTask NewTask
size int
times int
}
func TestTaskPoolRunImpl(t *testing.T) {
testSuits := []TestSuit{
{
nam
e: "minimal task pool",
newTask: func() task.Task { return mock.NewMinimalTask() },
size: 100,
times: 200,
},
}
for _, suit := range testSuits {
t.Run(suit.name, func(t *testing.T) {
var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size)
err := taskPool.Run(suit.size)
assert.NoError(t, err)
})
}
}
In this way, through the coverage test that comes with go test, we can see that the actual tested path of TaskPoolImpl is
The problem with manual implementation of MinimalTask that can be seen is that since the return value of callee is uncontrollable for caller, we can only cover the path to the return value determined by MinimalTask.
In addition, mocks are often operated by dependent projects in our practice. He does not know how the caller handles the return value. There is no way to encapsulate a simple and sufficient minimal implementation for interface testing. Therefore, we need to improve our mock. Strategy, use golang's official mock tool - gomock for better interface testing.
gomock practice
The advantage of using golang's official mock tool is that
-
Based on the mock code generated by the tool, we can encapsulate a minimal implement in a more streamlined way to achieve the same effect as manually implementing a minimal implement.
-
It allows the caller to flexibly and selectively control the input and output parameters of the interface methods that it needs to use.
Still in the example of TaskPool above, we now use the tools provided by gomock to automatically generate a mock Task
mockgen -destination mock/mock_task.go -package mock -source task/interface.go
Generate a mock_task.go in the mock package to implement the interface Task
First based on mock_task.go, we can implement a MockMinimalTask for the simplest test
package mock
import "github.com/golang/mock/gomock"
func NewMockMinimalTask(ctrl *gomock.Controller) *MockTask {
mock := NewMockTask(ctrl)
mock.EXPECT().Do().Return("", nil).AnyTimes()
return mock
}
So we can implement a MockMinimalTask to do some tests
package pool
import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"testing"
)
type TestSuit struct {
name string
newTask NewTask
size int
times int
}
func TestTaskPoolRunImpl(t *testing.T) {
testSuits := []TestSuit{
//{
// name: "minimal task pool",
// newTask: func() task.Task { return mock.NewMinimalTask() },
// size: 100,
// times: 200,
//},
{
name: "mock minimal task pool",
newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) },
size: 100,
times: 200,
},
}
for _, suit := range testSuits {
t.Run(suit.name, func(t *testing.T) {
var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size)
err := taskPool.Run(suit.size)
assert.NoError(t, err)
})
}
}
We use this new test file for coverage testing
It can be seen that the test results are the same, so what should we do when we want to achieve higher test coverage? We further modify the test
package pool
import (
"errors"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"testing"
)
type TestSuit struct {
name string
newTask NewTask
size int
times int
isErr bool
}
func TestTaskPoolRunImpl_MinimalTask(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
testSuits := []TestSuit{
//{
// name: "minimal task pool",
// newTask: func() task.Task { return mock.NewMinimalTask() },
// size: 100,
// times: 200,
//},
{
name: "mock minimal task pool",
newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) },
size: 100,
times: 200,
},
{
name: "return err",
newTask: func() task.Task {
mockTask := mock.NewMockTask(ctrl)
// 加入了返回错误的逻辑
mockTask.EXPECT().Do(gomock.Any()).Return("", errors.New("return err")).AnyTimes()
return mockTask
},
size: 100,
times: 200,
isErr: true,
},
}
for _, suit := range testSuits {
t.Run(suit.name, func(t *testing.T) {
var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size)
err := taskPool.Run(suit.size)
if suit.isErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
This way we can override the error processing logic
Even we can cover all the statements in a more tricky way, change the testSuits in the code to the following
package pool
import (
"errors"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"testing"
)
type TestSuit struct {
name string
newTask NewTask
size int
times int
isErr bool
}
func TestTaskPoolRunImpl_MinimalTask(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
strs := []string{"a", "b", "c"}
count := 0
size := 3
rounds := 1
testSuits := []TestSuit{
//{
// name: "minimal task pool",
// newTask: func() task.Task { return mock.NewMinimalTask() },
// size: 100,
// times: 200,
//},
{
name: "mock minimal task pool",
newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) },
size: 100,
times: 200,
},
{
name: "return err",
newTask: func() task.Task {
mockTask := mock.NewMockTask(ctrl)
mockTask.EXPECT().Do(gomock.Any()).Return("", errors.New("return err")).AnyTimes()
return mockTask
},
size: 100,
times: 200,
isErr: true,
},
{
name: "check input and output",
newTask: func() task.Task {
mockTask := mock.NewMockTask(ctrl)
// 这里我们通过Do的设置检查了mackTask.Do调用时候的入参以及调用次数
// 通过Return来设置发生调用时的返回值
mockTask.EXPECT().Do(count).Return(strs[count%3], nil).Times(rounds)
count++
return mockTask
},
size: size,
times: size * rounds,
isErr: false,
},
}
var taskPool TaskPool
for _, suit := range testSuits {
t.Run(suit.name, func(t *testing.T) {
taskPool = NewTaskPoolImpl(suit.newTask, suit.size)
err := taskPool.Run(suit.times)
if suit.isErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
This way we can cover all statements
Think about the meaning of Mock
I discussed with some classmates before, why do we use mocks, and found that many classmates think that the way to write mocks is to agree on an interface, and then it can be convenient for testing when developing for an interface.
Because the actual implementation of the interface is not required, but the Minimal Implement of the mock can be used for unit testing. I think that's right, but at the same time I feel that mocking is more than that.
In my opinion, in the practice of interface-oriented development, you should always be sensitive to the input and output of the interface. Furthermore, when doing unit testing, you need to know that under a given use case and input, your The package will take what input to the interface method to use, call it a few times, and then what the return value might be, and what kind of return value affects you.
If you don't know about these, then I think or you should try and understand more, so that you can design more single test cases through mocking as much as possible, do more and careful inspection, and improve the coverage of test code rate to ensure the completeness of the module function.
Reference link: https://www.jb51.net/article/188480.htm
For more technical articles or video tutorials related to the Go language, please follow this official account to obtain and view, thank you for your support and trust!