詳解Go語言單元測試中如何解決MySQL存儲依賴問題
在編寫單元測試的過程中,如果被測試代碼有外部依賴,為了便于測試,我們就要想辦法來解決這些外部依賴問題。在做 Web 開發(fā)時,MySQL 存儲就是一個非常常見的外部依賴,本文就來探討在 Go 語言中編寫單元測試時,如何解決 MySQL 存儲依賴。
HTTP 服務(wù)程序示例
假設(shè)我們有一個 HTTP 服務(wù)程序?qū)ν馓峁┓?wù),代碼如下:
main.go
package main import ( "encoding/json" "fmt" "gorm.io/gorm" "io" "net/http" "strconv" "github.com/julienschmidt/httprouter" "github.com/jianghushinian/blog-go-example/test/mysql/store" ) func NewUserHandler(db *gorm.DB) *UserHandler { return &UserHandler{ store: store.NewUserStore(db), } } type UserHandler struct { store store.UserStore } func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { ... } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { ... } func setupRouter(handler *UserHandler) *httprouter.Router { router := httprouter.New() router.POST("/users", handler.CreateUser) router.GET("/users/:id", handler.GetUser) return router } func main() { mysqlDB, _ := store.NewMySQLDB("localhost", "3306", "user", "password", "test") handler := NewUserHandler(mysqlDB) router := setupRouter(handler) _ = http.ListenAndServe(":8000", router) }
這個服務(wù)監(jiān)聽 8000
端口,分別提供了兩個 HTTP 接口:
POST /users
用來創(chuàng)建用戶。
GET /users/:id
用來獲取指定 ID 對應(yīng)的用戶信息。
UserHandler
是一個結(jié)構(gòu)體,它依賴外部存儲接口 store.UserStore
,這個接口定義如下:
store/store.go
package store import "gorm.io/gorm" type UserStore interface { Create(user *User) error Get(id int) (*User, error) } func NewUserStore(db *gorm.DB) UserStore { return &userStore{db} } type userStore struct { db *gorm.DB } func (s *userStore) Create(user *User) error { return s.db.Create(user).Error } func (s *userStore) Get(id int) (*User, error) { var user User err := s.db.First(&user, id).Error return &user, err }
store.UserStore
定義了兩個方法,分別用來創(chuàng)建、獲取用戶信息。
User
模型定義如下:
store/model.go
type User struct { ID int `gorm:"id"` Name string `gorm:"name"` }
store.userStore
結(jié)構(gòu)體則實現(xiàn)了 store.UserStore
接口。
store.userStore
結(jié)構(gòu)體又依賴了 GORM 庫的 *gorm.DB
類型,表示一個數(shù)據(jù)庫連接對象。
我們可以使用 NewMySQLDB
建立數(shù)據(jù)庫連接得到 *gorm.DB
對象:
store/mysql.go
func NewMySQLDB(host, port, user, pass, dbname string) (*gorm.DB, error) { dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, pass, host, port, dbname) return gorm.Open(mysql.Open(dsn), &gorm.Config{}) }
至此,這個 HTTP 服務(wù)程序整體邏輯就基本介紹完了。
其目錄結(jié)構(gòu)如下:
$ tree
.
├── go.mod
├── go.sum
├── main.go
└── store
├── model.go
├── mysql.go
└── store.go
為了保證業(yè)務(wù)的正確性,我們應(yīng)該對 (*UserHandler).CreateUser
和 (*UserHandler).GetUser
這兩個 Handler 進行單元測試。
這兩個 Handler 定義如下:
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { w.Header().Set("Content-Type", "application/json") body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) _, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error()) return } defer func() { _ = r.Body.Close() }() u := store.User{} if err := json.Unmarshal(body, &u); err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error()) return } if err := h.store.Create(&u); err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error()) return } w.WriteHeader(http.StatusCreated) } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { id := ps[0].Value uid, _ := strconv.Atoi(id) w.Header().Set("Content-Type", "application/json") u, err := h.store.Get(uid) if err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error()) return } _, _ = fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, u.ID, u.Name) }
不過,由于文章篇幅所限,我這里僅以測試 (*UserHandler).GetUser
方法為例,演示如何在測試過程中解決 MySQL 依賴問題,對 (*UserHandler).CreateUser
方法的測試就當(dāng)做作業(yè)留給你自己來完成了(當(dāng)然,你也可以到我的 GitHub 上查看我的實現(xiàn))。
Fake 測試
我們要為 (*UserHandler).GetUser
方法編寫單元測試,首先就要分析下這個方法的外部依賴。
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { id := ps[0].Value uid, _ := strconv.Atoi(id) w.Header().Set("Content-Type", "application/json") u, err := h.store.Get(uid) if err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error()) return } _, _ = fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, u.ID, u.Name) }
UserHandler
結(jié)構(gòu)本身依賴了 store.UserStore
,這是一個接口,定義了創(chuàng)建和獲取用戶信息的兩個方法。
我們使用實現(xiàn)了 store.UserStore
接口的 store.userStore
結(jié)構(gòu)體來初始化 UserHandler
:
func NewUserHandler(db *gorm.DB) *UserHandler { return &UserHandler{ store: store.NewUserStore(db), } } func NewUserStore(db *gorm.DB) UserStore { return &userStore{db} }
store.userStore
結(jié)構(gòu)體會使用 GORM 來完成對 MySQL 數(shù)據(jù)庫的操作。所以,我們分析出 GetUser
方法的第一個外部依賴實際上就是 MySQL 存儲。
GetUser
方法還接收三個參數(shù),它們都屬于 HTTP 網(wǎng)絡(luò)相關(guān)的外部依賴,你可以在我的另一篇文章《在 Go 語言單元測試中如何解決 HTTP 網(wǎng)絡(luò)依賴問題》中找到解決方案,就不在本文中進行講解了。
所以,我們現(xiàn)在重點要關(guān)注的就只有一個問題,如何解決 MySQL 存儲依賴。
我們來整理下 MySQL 外部依賴的程序調(diào)用鏈:
可以發(fā)現(xiàn),store.UserStore
接口是 UserHandler
和 store.userStore
結(jié)構(gòu)體建立連接的橋梁,我們可以將它作為突破口,實現(xiàn)一個 Fake object,來替換 store.userStore
結(jié)構(gòu)體。
所謂 Fake object,其實就是我們同樣要定義一個結(jié)構(gòu)體,并實現(xiàn) Create
和 Get
兩個方法,以此來實現(xiàn) store.UserStore
接口。
type fakeUserStore struct{} func (f *fakeUserStore) Create(user *store.User) error { return nil } func (f *fakeUserStore) Get(id int) (*store.User, error) { return &store.User{ID: id, Name: "test"}, nil }
與 store.userStore
結(jié)構(gòu)體不同,fakeUserStore
并不依賴 *gorm.DB
,也就不涉及 MySQL 數(shù)據(jù)庫操作了,這樣就解決了 MySQL 外部存儲依賴。
(*fakeUserStore).Create
方法沒做任何操作,直接返回 nil
,(*fakeUserStore).Get
方法則根據(jù)傳進來的 id
返回固定的 User
信息。這也是 Fake object 的特點,為真實對象實現(xiàn)一個簡化版本。
這樣,我們在編寫測試代碼時,只需要取代 store.userStore
結(jié)構(gòu)體,使用 fakeUserStore
來實例化 UserHandler
,就可以避免與 MySQL 數(shù)據(jù)庫打交道了。
handler := &UserHandler{store: &fakeUserStore{}}
為 (*UserHandler).GetUser
方法編寫的單元測試完整代碼如下:
func TestUserHandler_GetUser_by_fake(t *testing.T) { handler := &UserHandler{store: &fakeUserStore{}} router := setupRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/users/1", nil) router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) assert.Equal(t, `{"id":1,"name":"test"}`, w.Body.String()) }
現(xiàn)在被測試的 (*UserHandler).GetUser
方法中通過 h.store.Get(uid)
從數(shù)據(jù)庫中獲取用戶信息時,就不用再去查詢 MySQL 了,而是由 (*fakeUserStore).Get
方法直接返回 Fake 數(shù)據(jù)。
使用 go test
來執(zhí)行測試函數(shù):
$ go test -v -run="TestUserHandler_GetUser_by_fake" === RUN TestUserHandler_GetUser_by_fake --- PASS: TestUserHandler_GetUser_by_fake (0.00s) PASS ok github.com/jianghushinian/blog-go-example/test/mysql 0.465s
測試通過。
可以發(fā)現(xiàn),使用 Fake 測試來解決 MySQL 外部依賴還是比較簡單的,我們僅需要參考 store.userStore
實現(xiàn)一個簡化版本的 fakeUserStore
,然后在測試過程中,使用簡化版本的 fakeUserStore
對象替換掉 store.userStore
即可。
Mock 測試
前文中,我們使用 fakeUserStore
來替換 store.userStore
,以此來接口 MySQL 依賴問題。
不過,這種使用 Fake object 來解決外部依賴的方式存在兩個較為常見的弊端:
一個是使用 Fake object 需要手動編寫大量代碼,這里的 store.UserStore
接口僅定義了兩個方法還好,但一個線上的復(fù)雜業(yè)務(wù),可能有幾十個接口,每個接口又有幾十個方法,此時如果還是手動來編寫這些代碼,需要消耗大量時間。
另一個是 Fake object 返回結(jié)果比較固定,如果想測試其他情況,比如查詢的 User
不存在,需要報錯的情況,就得在 (*fakeUserStore).Get
方法中編寫更多的邏輯,這增加了實現(xiàn) Fake object 的復(fù)雜度。
那么有沒有一種替代方案,來彌補 Fake object 的這兩個弊端呢?
答案是使用 Mock 測試。
Mock 和 Fake 類似,本質(zhì)上都是使用一個對象,去替代另一個對象。Fake 測試是實現(xiàn)了一個真實對象(store.userStore
)的簡化版本(fakeUserStore
),Mock 測試則是使用模擬對象來斷言真實對象被調(diào)用時的輸入符合預(yù)期,然后通過模擬對象返回指定輸出。
在 Go 中,我們可以使用 gomock 來實現(xiàn) Mock 測試。
gomock
項目起源于 Google 的 golang/mock 倉庫。不幸的是,谷歌不再維護這個項目了。幸運的是,這個項目由 Uber fork 了一份,并繼續(xù)維護。
gomock
包含兩個部分:gomock
包和 mockgen
命令行工具。gomock
包用來完成對被 Mock 對象的生命周期管理,mockgen
工具則用來自動生成 Mock 代碼。
可以通過如下方式來安裝 gomock
包和 mockgen
工具:
$ go get go.uber.org/mock/gomock@latest $ go install go.uber.org/mock/mockgen@latest
注意:在項目根目錄下通過 go get
命令獲取 gomock
包后,不要急著執(zhí)行 go mod tidy
,因為現(xiàn)在 gomock
包屬于 indirect
依賴,還沒有被使用。當(dāng)通過 mockgen
工具生成了 Mock 代碼以后,再來執(zhí)行 go mod tidy
,go.mod
文件中才不會丟失 gomock
依賴。
要想使用 gomock
來模擬 store.UserStore
接口的實現(xiàn),我們先要使用 mockgen
工具來生成 Mock 代碼:
$ mockgen -source store/store.go -destination store/mocks/gomock.go -package mocks
-source
參數(shù)指明需要 Mock 的接口文件路徑,即 store.UserStore
接口所在文件。
-destination
參數(shù)指明生成的 Mock 文件路徑。
-package
參數(shù)指明生成的 Mock 文件包名。
在項目根目錄下執(zhí)行 mockgen
命令,即可生成 Mock 文件:
// Code generated by MockGen. DO NOT EDIT. // Source: store/store.go // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" store "github.com/jianghushinian/blog-go-example/test/mysql/store" gomock "go.uber.org/mock/gomock" ) // MockUserStore is a mock of UserStore interface. type MockUserStore struct { ctrl *gomock.Controller recorder *MockUserStoreMockRecorder } // MockUserStoreMockRecorder is the mock recorder for MockUserStore. type MockUserStoreMockRecorder struct { mock *MockUserStore } // NewMockUserStore creates a new mock instance. func NewMockUserStore(ctrl *gomock.Controller) *MockUserStore { mock := &MockUserStore{ctrl: ctrl} mock.recorder = &MockUserStoreMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockUserStore) EXPECT() *MockUserStoreMockRecorder { return m.recorder } // Create mocks base method. func (m *MockUserStore) Create(user *store.User) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Create", user) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. func (mr *MockUserStoreMockRecorder) Create(user interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUserStore)(nil).Create), user) } // Get mocks base method. func (m *MockUserStore) Get(id int) (*store.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", id) ret0, _ := ret[0].(*store.User) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. func (mr *MockUserStoreMockRecorder) Get(id interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockUserStore)(nil).Get), id) }
提示:生成的 mocks 包代碼你無需全部看懂,僅知道它大概生成了什么內(nèi)容,如何使用即可。
可以發(fā)現(xiàn),mockgen
為我們生成了 mocks.MockUserStore
結(jié)構(gòu)體,并且實現(xiàn)了 Create
、Get
兩個方法,即實現(xiàn)了 store.UserStore
接口。
現(xiàn)在,我們就可以使用生成的 Mock 對象來編寫單元測試代碼了:
func TestUserHandler_GetUser_by_mock(t *testing.T) { ctrl := gomock.NewController(t) // 斷言 mockUserStore.Get 方法會被調(diào)用 defer ctrl.Finish() mockUserStore := mocks.NewMockUserStore(ctrl) mockUserStore.EXPECT().Get(2).Return(&store.User{ ID: 2, Name: "user2", }, nil) handler := &UserHandler{store: mockUserStore} router := setupRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/users/2", nil) router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) assert.Equal(t, `{"id":2,"name":"user2"}`, w.Body.String()) }
gomock.NewController(t)
用來創(chuàng)建一個 Mock 控制器,該對象可以控制整個 Mock 生命周期。
ctrl.Finish()
用來斷言 Mock 對象使用 EXPECT()
方法設(shè)置的期待執(zhí)行方法會被調(diào)用,一般使用 defer
語句來調(diào)用,防止最后忘記。不過,如果你使用的 Go 版本大于 1.14,則可以不必顯式調(diào)用 ctrl.Finish()
。
mocks.NewMockUserStore(ctrl)
使用 Mock 控制器創(chuàng)建了 *mocks.MockUserStore
對象,有了它,我們就可以模擬調(diào)用 store.UserStore
接口對應(yīng)方法的邏輯了:
mockUserStore.EXPECT().Get(2).Return(&store.User{ ID: 2, Name: "user2", }, nil)
mockUserStore
對象就相當(dāng)于我們前文中實現(xiàn)的 fakeUserStore
。
Mock 對象的 EXPECT()
方法用來設(shè)置預(yù)期被調(diào)用的方法,以及被調(diào)用方法所期望的輸入,它支持鏈?zhǔn)秸{(diào)用,.Get(2)
表示期望在測試中調(diào)用 Mock 對象 mockUserStore
的 Get
方法時,輸入?yún)?shù)是 2
,Return
方法用來設(shè)置輸出,即返回值內(nèi)容。
這就相當(dāng)于,我們實現(xiàn)了 fakeUserStore
的 Get
方法。
我們可以使用 mockUserStore
來實例化 UserHandler
對象。
在 req
請求中,我們設(shè)置請求的用戶 ID 值為 2
,即 mockUserStore
對象斷言中的參數(shù),二者參數(shù)匹配,Mock 對象才能生效。
單元測試最后,斷言了返回結(jié)果為 {"id":2,"name":"user2"}
,即 mockUserStore
對象期望的返回結(jié)果。
現(xiàn)在我們就可以測試 (*UserHandler).GetUser
方法了。
使用 go test
來執(zhí)行測試函數(shù):
$ go test -v -run="TestUserHandler_GetUser_by_mock" === RUN TestUserHandler_GetUser_by_mock --- PASS: TestUserHandler_GetUser_by_mock (0.00s) PASS ok github.com/jianghushinian/blog-go-example/test/mysql 0.220s
測試通過。
使用 Mock 測試來解決 MySQL 外部依賴問題,我們無需手動編寫 Mock 對象的代碼,可以使用 mockgen
工具為我們自動生成,簡化了 Fake 測試中編寫 fakeUserStore
的過程。
并且,如果想要測試其他情況,僅需要再次使用 Mock 對象的 EXPECT()
方法來設(shè)置 Get
方法的期望輸入和輸出即可。
比如設(shè)置預(yù)期查詢 ID 為 3
的用戶信息時,返回 user not found
錯誤:
mockUserStore.EXPECT().Get(3).Return(nil, errors.New("user not found"))
Mock 測試更方便我們測試不同業(yè)務(wù)場景。
gomock 更多用法
gomock
還有一些使用技巧值得分享。
mockgen
前文中,我們使用 mockgen
通過指定源碼文件形式生成了 Mock 代碼:
$ mockgen -source store/store.go -destination store/mocks/gomock.go -package mocks
mockgen
工具還支持通過反射模式來生成 Mock 代碼:
$ mockgen -package mocks -destination store/mocks/gomock.go github.com/jianghushinian/blog-go-example/test/mysql/store UserStore
命令最后的兩個參數(shù)分別代表需要生成 Mock 代碼的包的導(dǎo)入路徑和逗號分隔的接口列表。
執(zhí)行以上命令同樣能夠成功生成 Mock 代碼。
此外,我們還可以將 mockgen
命令寫到 Go 文件中,然后使用 Go generate
工具來生成 Mock 代碼:
store/generate.go
package store //go:generate mockgen -package mocks -destination ./mocks/gomock.go . UserStore
這次我們的 mockgen
命令又有所不同,包的導(dǎo)入路徑僅為一個 .
,表示當(dāng)前目錄,這也是被支持的。
這時候,我們只需要在項目根目錄下執(zhí)行 go generate ./...
命令即可生成 Mock 代碼。./...
表示查找項目下全部文件,go generate
會自動找到帶有 //go:generate
注釋的命令并執(zhí)行。
如果我們有多個源碼文件要生成 Mock 代碼,go generate
方式就非常合適,僅需要在 Go 文件中分多行依次寫出 mockgen
命令即可使用一條命令一次全部生成。
gomock
前文中,我們使用了 Mock 對象 mockUserStore
的 EXPECT()
方法來設(shè)置 Get
方法所期待的輸入和輸出。
mockUserStore.EXPECT().Get(2).Return(&store.User{ ID: 2, Name: "user2", }, nil)
有時候,EXPECT()
所作用的方法可能存在多個參數(shù),且有些參數(shù)不容易模擬,比如最常見的 context.Context
參數(shù),針對這些情況,gomock
提供了更多的參數(shù)匹配方法:
gomock.Any()
表示匹配任意參數(shù),適合參數(shù)模擬困難的情況。
gomock.Eq(x)
表示匹配與 x
相等的參數(shù)。
gomock.Not(x)
表示匹配與 x
不想等的參數(shù)。
gomock.Nil()
表示匹配 nil
參數(shù)。
gomock.Len(i)
表示匹配長度為 i
的參數(shù)。
gomock.All(ms)
表示傳入的所有參數(shù)都想等才能匹配。
以上這些參數(shù)匹配方法都可以像如下這樣使用:
mockUserStore.EXPECT().Get(gomock.Eq(2)).Return(&store.User{ ID: 2, Name: "user2", }, nil)
此外,我們可以約束 EXPECT()
所作用方法的執(zhí)行次數(shù):
.Return(xxx).Times(2) // 預(yù)期方法會被調(diào)用 2 次 .Return(xxx).MaxTimes(2) // 預(yù)期方法最多執(zhí)行 2 次 .Return(xxx).MinTimes(2) // 預(yù)期方法至少執(zhí)行 2 次 .Return(xxx).AnyTimes() // 預(yù)期方法執(zhí)行任意次都能匹配
還可以約束 EXPECT()
所作用方法的執(zhí)行順序:
.Return(xxx).After(preReq) // 當(dāng)前預(yù)期方法在 preReq 預(yù)期方法執(zhí)行完成之后執(zhí)行
以上便是我認為 gomock
中比較常用的功能講解,更多功能可參考官方文檔。
總結(jié)
本文向大家介紹了在 Go 中編寫單元測試時,如何解決 MySQL 外部依賴的問題。
我們分別使用了 Fake object 和 Mock 兩種方式,來替換原有的外部依賴。
Web 服務(wù)的代碼不是隨意設(shè)計的,有意將 UserHandler
依賴的類型設(shè)為 store.UserStore
接口,而不是 store.userStore
結(jié)構(gòu)體,是為了解耦。通過使用接口,解決了 UserHandler
與 store.userStore
結(jié)構(gòu)體強綁定的問題,這就給我們使用 fakeUserStore
或 mockUserStore
來替代 store.userStore
創(chuàng)造了機會。
可以發(fā)現(xiàn),本文介紹的兩種方法其實不僅能夠用于解決 MySQL 外部依賴問題。任何使用接口編寫的代碼,在測試時都可以使用這兩種方式來替換依賴。這就是 Go 面向接口編程的好處。
以上就是詳解Go語言單元測試中如何解決MySQL存儲依賴問題的詳細內(nèi)容,更多關(guān)于Go單元測試解決MySQL存儲依賴的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
GOLANG使用Context實現(xiàn)傳值、超時和取消的方法
這篇文章主要介紹了GOLANG使用Context實現(xiàn)傳值、超時和取消的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2019-01-01