當前的實踐中問題
在項目之間依賴的時候我們往往可以通過mock一個接口的實現,以一種比較簡潔、獨立的方式,來進行測試。但是在mock使用的過程中,因為大家的風格不統一,而且很多使用minimal implement的方式來進行mock,這就導致了通過mock出的實現各個函數的返回值往往是靜態的,就無法讓caller根據返回值進行的一些復雜邏輯。
首先來舉一個例子
package task
type Task interface {
Do(int) (string, error)
}
通過minimal implement的方式來進行手動的mock
package mock
type MinimalTask struct {
// filed
}
func NewMinimalTask() *MinimalTask {
return MinimalTask{}
}
func (mt *MinimalTask) Do(idx int) (string, error) {
return "", nil
}
在其他包使用Mock出的實現的過程中,就會給測試帶來一些問題。
舉個例子,假如我們有如下的接口定義與函數定義
package pool
import "github.com/ultramesh/mock-example/task"
type TaskPool interface {
Run(times int) error
}
type NewTask func() task.Task
我們基于接口定義和接口構造函數定義,封裝了一個實現
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
}
接著我們來寫測試的話應該是下面
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)
})
}
}
這樣通過go test自帶的覆蓋率測試我們能看到TaskPoolImpl實際被測試到的路徑為

可以看到的手動實現MinimalTask的問題在于,由于對于caller來說,callee的返回值是不可控的,我們只能覆蓋到由MinimalTask所定死的返回值的路徑,此外mock在我們的實踐中往往由被依賴的項目來操作,他不知道caller怎樣根據返回值進行處理,沒有辦法封裝出一個簡單、夠用的最小實現供接口測試使用,因此我們需要改進我們mock策略,使用golang官方的mock工具——gomock來進行更好地接口測試。
gomock實踐
我們使用golang官方的mock工具的優勢在于
- 我們可以基于工具生成的mock代碼,我們可以用一種更精簡的方式,封裝出一個minimal implement,完成和手工實現一個minimal implement一樣的效果。
- 可以允許caller自己靈活地、有選擇地控制自己需要用到的那些接口方法的入參以及出參。
還是上面TaskPool的例子,我們現在使用gomock提供的工具來自動生成一個mock Task
mockgen -destination mock/mock_task.go -package mock -source task/interface.go
在mock包中生成一個mock_task.go來實現接口Task
首先基于mock_task.go,我們可以實現一個MockMinimalTask用于最簡單的測試
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
}
于是這樣我們就可以實現一個MockMinimalTask用來做一些測試
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)
})
}
}
我們使用這個新的測試文件進行覆蓋率測試

可以看到測試結果是一樣的,那當我們想要達到更高的測試覆蓋率的時候應該怎么辦呢?我們進一步修改測試
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)
}
})
}
}
這樣我們就能夠覆蓋到error的處理邏輯

甚至我們可以更trick的方式來將所有語句都覆蓋到,代碼中的testSuits改成下面這樣
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)
}
})
}
}
這樣我們就可以覆蓋到所有語句

思考Mock的意義
之前和一些同學討論過,我們為什么要使用mock這個問題,發現很多同學的覺得寫mock的是約定好接口,然后在面向接口做開發的時候能夠方便測試,因為不需要接口實際的實現,而是依賴mock的Minimal Implement就可以進行單元測試。我認為這是對的,但是同時也覺得mock的意義不僅僅是如此。
在我看來,面向接口開發的實踐中,你應該時刻對接口的輸入和輸出保持敏感,更進一步的說,在進行單元測試的時候,你需要知道在給定的用例、輸入下,你的包會對起使用的接口方法輸入什么,調用幾次,然后返回值可能是什么,什么樣的返回值對你有影響,如果你對這些不了解,那么我覺得或者你應該去做更多地嘗試和了解,這樣才能盡可能通過mock設計出更多的單測用例,做更多且謹慎的檢查,提高測試代碼的覆蓋率,確保模塊功能的完備性。

Mock與設計模式
mock與單例
客觀來講,借助go語言官方提供的同步原語sync.Once,實現單例、使用單例是很容易的事情。在使用單例實現的過程中,單例的調用者往往邏輯中依賴提供的get方法在需要的時候獲取單例,而不會在自身的數據結構中保存單例的句柄,這也就導致我們很難類比前面介紹的case,使用mock進行單元測試,因為caller沒有辦法控制通過get方法獲取的單例。
既然是因為沒有辦法更改單例返回,那么解決這個問題最簡單的方式就是我們就應改提供一個set方法來設置更改單例。假設我們需要基于上面的case實現一個單例的TaskPool。假設我們定義了PoolImpl
實現了Pool的接口,在創建單例的時候我們可能是這么做的(為了方便說明,這里我們用最早手工寫的基于MinimalTask
來寫TaskPool
的單例)
package pool
import (
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"sync"
)
var once sync.Once
var p TaskPool
func GetTaskPool() TaskPool{
once.Do(func(){
p = NewTaskPoolImpl(func() task.Task {return mock.NewMinimalTask()},10)
})
return p
}
這個時候問題就來了,假設某個依賴于TaskPool的模塊中有這么一段邏輯
package runner
import (
"fmt"
"github.com/pkg/errors"
"github.com/ultramesh/mock-example/pool"
)
func Run(times int) error {
// do something
fmt.Println("do something")
// call pool
p := pool.GetTaskPool()
err := p.Run(times)
if err != nil {
return errors.Wrap(err, "task pool run error")
}
// do something
fmt.Println("do something")
return nil
}
那么這個Run函數的單測應該怎么寫呢?這里的例子還比較簡單,要是TaskPool的實現還要依賴一些外部配置文件,實際情形就會更加復雜,當然我們在這里不討論這個情況,就是舉一個簡單的例子。在這種情況下,如果單例僅僅只提供了get方法的話是很難進行解耦測試的,如果使用GetTaskPool勢必會給測試引入不必要的復雜性,我們還需要提供一個單例的實現者提供一個set方法來解決單元測試解耦的問題。將單例的實現改成下面這樣,對外暴露一個單例的set方法,那么我們就可以通過set方法來進行mock。
import (
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/task"
"sync"
)
var once sync.Once
var p TaskPool
func SetTaskPool(tp TaskPool) {
p = tp
}
func GetTaskPool() TaskPool {
once.Do(func(){
if p != nil {
p = NewTaskPoolImpl(func() task.Task {return mock.NewMinimalTask()},10)
}
})
return p
}
使用mockgen生成一個MockTaskPool實現
mockgen -destination mock/mock_task_pool.go -package mock -source pool/interface.go
類似的,基于前面介紹的思想我們基于自動生成的代碼實現一個MockMinimalTaskPool
package mock
import "github.com/golang/mock/gomock"
func NewMockMinimalTaskPool(ctrl *gomock.Controller) *MockTaskPool {
mock := NewMockTaskPool(ctrl)
mock.EXPECT().Run(gomock.Any()).Return(nil).AnyTimes()
return mock
}
基于MockMinimalTaskPool和單例暴露出的set方法,我們就可以將TaskPool實現的邏輯拆除,在單測中只測試自己的代碼
package runner
import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/ultramesh/mock-example/mock"
"github.com/ultramesh/mock-example/pool"
"testing"
)
func TestRun(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
p := mock.NewMockMinimalTaskPool(ctrl)
pool.SetTaskPool(p)
err := Run(100)
assert.NoError(t, err)
}
到此這篇關于Go語言Mock使用基本指南詳解的文章就介紹到這了,更多相關Go語言Mock使用內容請搜索腳本之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持腳本之家!
您可能感興趣的文章:- 用gomock進行mock測試的方法示例
- 使用Gomock進行單元測試的方法示例