go test 測試用例那些事(二) mock

peng發表於2020-07-21

關於go的單元測試,之前有寫過一篇帖子go test測試用例那些事,但是沒有說go官方的庫mock,很有必要單獨說一下這個庫,和他的實現原理。
mock主要的功能是對介面的模擬,需要在寫程式碼的時候定義抽象很多介面,有時為了能方便go test可能會多寫一些冗餘程式碼,但這些工作會讓你的單元測試更靈活。特別是邏輯比較複雜的時候,上層要呼叫其他層的方法進行單元測試,會讓單元測試越寫越麻煩,越寫越複雜,這也是很多人不喜歡寫單元測試的原因。使用mock模擬底層的介面,能讓你只關注上層需要測試的邏輯,而不用為了測試一個功能,寫一堆呼叫的底層的相關的測試邏輯。

使用

mockgen就是mock的可執行命令。使用也很簡單

mockgen -source=src.go [other options]

比如我們有一個介面

package d1

type User interface {
	Name() string
	SetAge(age int) bool
	V(idx int, name string) (string, error)
}

執行mockgen命令

mockgen -source=user.go

這裡只指寫了-source 會直接在控制檯輸出。也可以指定輸出目錄和輸出包名稱

mockgen -source=user.go -destination ./dao/u_mock.go -package mock_data

或者使用 go generate來生成,需要在包名字上面加上下面這句。

//go:generate mockgen -destination ./dao/u_mock.go -package mock_data -source user.go

然後執行go generate ./...和上面是一樣的效果。

雖然go generate很方便,但如果目標檔案或者包名字有變動裡,就需要修改所有檔案。不如用命令來的快,直接寫一個Makefile進行指處理,下面是一個小例子,實現mock目錄daoservice下的go檔案,去掉了*_test.go和一些指定的檔案。

DAO_DIR=./dao
DAO_MOCK_DIR=$(DAO_DIR)/mock_dao
DAO_FILES=$(shell find $(DAO_DIR) -not -path "$(DAO_MOCK_DIR)/*" -type f -name "*.go" -not -name "*_test.go" -not -name "dao_init.go" -not -name "dao.go")

SERVICE_DIR=./service
SERVICE_MOCK_DIR=$(SERVICE_DIR)/mock_srv
SERVICE_FILES=$(shell find $(SERVICE_DIR) -not -path "$(SERVICE_MOCK_DIR)/*" -type f -name "*.go" -not -name "*_test.go" -not -name "service.go" -not -name "system_filter.go")

define gen-mock-file
	@for f in $(3); do \
		eval t=`echo $$f | sed 's#$(1)#$(2)#'` ; \
		mockgen -source=$$f -destination=$$t ; \
	done
endef

.PHONY: gen-mock-dao
gen-mock-dao:
	$(call gen-mock-file,$(DAO_DIR),$(DAO_MOCK_DIR),$(DAO_FILES))

.PHONY: gen-mock-service
gen-mock-service:
	$(call gen-mock-file,$(SERVICE_DIR),$(SERVICE_MOCK_DIR),$(SERVICE_FILES))

gen-mock-all:
	@echo begin gen code
	@$(MAKE) gen-mock-dao
	@$(MAKE) gen-mock-service
	@echo done

使用

使用也很簡單直接呼叫EXPECT()然後給具體的方法指定引數,引數可以是任意的如下面的V方法的第一個引數gomock.Any(),引數可以是具體的值比如下面的2,然後呼叫Return指寫返回指定的值。最後指定這個方法呼叫多少次,下面是呼叫的AnyTimes(),當然也可以呼叫MinTimes或者MaxTimes指定次數

func TestUser1(t *testing.T) {
	mockUser := mock_data.NewMockUser(gomock.NewController(t))
	mockUser.EXPECT().V(gomock.Any(), "2").Return("a", nil).AnyTimes()
	var u User = mockUser
	a, err := u.V(1, "2")
	t.Log(a, err)
}

Return如果不呼叫會返回引數的預設值,上面的方法不如果不呼叫Return會返回 "", nil
對於簡單的邏輯可以直接呼叫Return方法,返回指定的結果。但實際情況可能需要進行一些邏輯處理,返回動態的資料,可能通過DoAndReturn

	mockUser := mock_data.NewMockUser(gomock.NewController(t))
	mockUser.EXPECT().V(1, "2").DoAndReturn(func(idx int, n string) (string, error) {
		t.Log(idx, "  ", n)
		return "1", nil
	})

可以有多個DoAndReturn,但只有最後一個的 return會生效。
如果只想對傳入的引數進行邏輯處理,可以呼叫Do方法。

	mockUser.EXPECT().V(1, "2").Do(func(id int, name string) {
		t.Log(id, " ", name)
	}).Do(func(id int, name string) {
		t.Log("do2 ", id)
	}).Return("a", nil)

當然根據自己的需要可以有多個Do方法的處理。

mock實現原理

實現的原理是根據go強大的抽象語法樹實現的,說一個題外話除了mock庫,還有一個依賴注入的庫wire也是依賴抽象語法樹實現的。
抽象語法樹分析-source傳入的檔案,把提取檔案內所有的importinterface,然後遍歷所有的介面方法,判斷引數屬於哪個import,組織成結構,生成模擬結構實現提取的介面。
看一下生成的兩個struct

// MockUser is a mock of User interface
type MockUser struct {
	ctrl     *gomock.Controller
	recorder *MockUserMockRecorder
}

// MockUserMockRecorder is the mock recorder for MockUser
type MockUserMockRecorder struct {
	mock *MockUser
}

上面的MockUser具體實現了我們的介面User。下面的MockUserMockRecorder才是重頭戲,儲存著我們傳入的的指定引數傳Do方法Return方法等。

// NewMockUser creates a new mock instance
func NewMockUser(ctrl *gomock.Controller) *MockUser {
	mock := &MockUser{ctrl: ctrl}
	mock.recorder = &MockUserMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockUser) EXPECT() *MockUserMockRecorder {
	return m.recorder
}

EXPECT()方法返回的就是MockUserMockRecorder看一下我們的例子方法V

// V mocks base method
func (m *MockUser) V(idx int, name string) (string, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "V", idx, name)
	ret0, _ := ret[0].(string)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// V indicates an expected call of V
func (mr *MockUserMockRecorder) V(idx, name interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "V", reflect.TypeOf((*MockUser)(nil).V), idx, name)
}

返回的*gomock.Call就是最底層的資料結構,儲存的所有的自定義引數

type Call struct {
	t TestHelper // for triggering test failures on invalid call setup
	receiver   interface{}  // the receiver of the method call
	method     string       // the name of the method
	methodType reflect.Type // the type of the method
	args       []Matcher    // the args
	origin     string       // file and line number of call setup
	preReqs []*Call // prerequisite calls
	// Expectations
	minCalls, maxCalls int
	numCalls int // actual number made

	// actions are called when this Call is called. Each action gets the args and
	// can set the return values by returning a non-nil slice. Actions run in the
	// order they are created.
	actions []func([]interface{}) []interface{}
}
  • method``methodType儲存的方法的資訊,mock是從反射欄位methodType知道傳入引數和返回結果的資訊。
  • args用於儲存指定的引數, 是gomock.Any()還是gomock.Eq()等,進行傳入引數匹配。
  • minCalls maxCalls用於儲存呼叫次數的限制
  • actions用於儲存我們的方法自定義方法 Do Return DoReturn等。

相關文章