- 一、gomock 工具介紹
- 二、安裝
- 三、使用
- 3.1 指定三個引數
- 3.2 使用命令為介面生成 mock 實現
- 3.3 使用make 命令封裝處理mock
- 四、介面單元測試步驟
- 三、小黃書Service層單元測試
- 四、flags
- 五、打樁(stub)
- 引數
- 六、總結
- 6.1 測試用例定義
- 6.2 設計測試用例
- 6.3 執行測試用例程式碼
- 6.4 執行測試用例
- 6.5 不是所有的場景都很好測試
一、gomock 工具介紹
gomock
是一個 Go 語言的測試框架,在實際專案中,需要進行單元測試的時候。卻往往發現有一大堆依賴項。這時候就是 Gomock 大顯身手的時候了,用於編寫單元測試時模擬和測試依賴於外部服務的程式碼。它允許你建立模擬物件(Mock Objects),這些物件可以預設期望的行為,以便在測試時模擬外部依賴,通常使用它對程式碼中的那些介面型別進行mock。
原本 Go 團隊提供了一個 mock 工具 https://github.com/golang/mock
,但在今年放棄維護了,改用 https://github.com/uber-go/mock
。
二、安裝
要安裝 gomock
,你可以使用 Go 包管理器 go get
:
go install go.uber.org/mock/mockgen@latest
三、使用
首先確保你已經安裝了gomock
,並且在專案中執行了go mod tidy
3.1 指定三個引數
在使用 mockgen
生成模擬物件(Mock Objects)時,通常需要指定三個主要引數:
source
:這是你想要生成模擬物件的介面定義所在的檔案路徑。destination
:這是你想要生成模擬物件程式碼的目標路徑。package
:這是生成程式碼的包名。
3.2 使用命令為介面生成 mock 實現
一旦你指定了上述引數,mockgen
就會為你提供的介面生成模擬實現。生成的模擬實現將包含一個 EXPECT
方法,用於設定預期的行為,以及一些方法實現,這些實現將返回預設值或呼叫真實的實現。
例如,如果你的介面定義在 ./webook/internal/service/user.go
檔案中,你可以使用以下命令來生成模擬物件:
mockgen -source=./webook/internal/service/user.go -package=svcmocks destination=./webook/internal/service/mocks/user.mock.go
3.3 使用make 命令封裝處理mock
在實際專案中,你可能會使用 make
命令來自動化構建過程,包括生成模擬物件。你可以建立一個 Makefile
或 make.bash
檔案,並新增一個目標來處理 mockgen
的呼叫。例如:
# Makefile 示例
# mock 目標 ,可以直接使用 make mock命令
.PHONY: mock
# 生成模擬物件
mock:
@mockgen -source=internal/service/user.go -package=svcmocks -destination=internal/service/mocks/user.mock.go
@mockgen -package=redismocks -destination=internal/repository/cache/redismocks/cmdable.mock.go github.com/redis/go-redis/v9 Cmdable
@go mod tidy
最後,只要我們執行make mock
命令,就會生成mock
檔案。
四、介面單元測試步驟
- 想清楚整體邏輯
- 定義想要(模擬)依賴項的
interface
(介面) - 使用
mockgen
命令對所需mock的interface生成mock檔案 - 編寫單元測試的邏輯,在測試中使用mock
- 進行單元測試的驗證
三、小黃書Service層單元測試
這裡我們已註冊介面為例子,程式碼如下:
// gmock/webook/backend/internal/web/user.go
func (u *UserHandler) SignUp(ctx *gin.Context) {
type SignUpReq struct {
Email string `json:"email"`
ConfirmPassword string `json:"confirmPassword"`
Password string `json:"password"`
}
var req SignUpReq
// Bind 方法會根據 Content-Type 來解析你的資料到 req 裡面
// 解析錯了,就會直接寫回一個 400 的錯誤
if err := ctx.Bind(&req); err != nil {
return
}
ok, err := u.emailExp.MatchString(req.Email)
if err != nil {
ctx.String(http.StatusOK, "系統錯誤")
return
}
if !ok {
ctx.String(http.StatusOK, "你的郵箱格式不對")
return
}
if req.ConfirmPassword != req.Password {
ctx.String(http.StatusOK, "兩次輸入的密碼不一致")
return
}
ok, err = u.passwordExp.MatchString(req.Password)
if err != nil {
// 記錄日誌
ctx.String(http.StatusOK, "系統錯誤")
return
}
if !ok {
ctx.String(http.StatusOK, "密碼必須大於8位,包含數字、特殊字元")
return
}
// 呼叫一下 svc 的方法
err = u.svc.SignUp(ctx, domain.User{
Email: req.Email,
Password: req.Password,
})
if err == service.ErrUserDuplicateEmail {
ctx.String(http.StatusOK, "郵箱衝突")
return
}
if err != nil {
ctx.String(http.StatusOK, "系統異常")
return
}
ctx.String(http.StatusOK, "註冊成功")
}
執行命令,生成mock檔案:
mockgen -source=./webook/internal/service/user.go -package=svcmocks destination=./webook/internal/service/mocks/user.mock.go
接著我們編寫單元測試,程式碼如下:
// gmock/webook/backend/internal/web/user_test.go
package web
import (
"bytes"
"context"
"errors"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"testing"
"webook/internal/domain"
"webook/internal/service"
svcmocks "webook/internal/service/mocks"
)
func TestEncrypt(t *testing.T) {
_ = NewUserHandler(nil, nil)
password := "hello#world123"
encrypted, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
err = bcrypt.CompareHashAndPassword(encrypted, []byte(password))
assert.NoError(t, err)
}
func TestNil(t *testing.T) {
testTypeAssert(nil)
}
func testTypeAssert(c any) {
_, ok := c.(*UserClaims)
println(ok)
}
func TestUserHandler_SignUp(t *testing.T) {
testCases := []struct {
name string
mock func(ctrl *gomock.Controller) service.UserService
reqBody string
wantCode int
wantBody string
}{
{
name: "註冊成功",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(nil)
// 註冊成功是 return nil
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "註冊成功",
},
{
name: "引數不對,bind 失敗",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
// 註冊成功是 return nil
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123"
`,
wantCode: http.StatusBadRequest,
},
{
name: "郵箱格式不對",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
},
reqBody: `
{
"email": "123@q",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "你的郵箱格式不對",
},
{
name: "兩次輸入密碼不匹配",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello#world1234",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "兩次輸入的密碼不一致",
},
{
name: "密碼格式不對",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello123",
"confirmPassword": "hello123"
}
`,
wantCode: http.StatusOK,
wantBody: "密碼必須大於8位,包含數字、特殊字元",
},
{
name: "郵箱衝突",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(service.ErrUserDuplicateEmail)
// 註冊成功是 return nil
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "郵箱衝突",
},
{
name: "系統異常",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(errors.New("隨便一個 error"))
// 註冊成功是 return nil
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "系統異常",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
server := gin.Default()
// 用不上 codeSvc
h := NewUserHandler(tc.mock(ctrl), nil)
h.RegisterRoutes(server)
req, err := http.NewRequest(http.MethodPost,
"/users/signup", bytes.NewBuffer([]byte(tc.reqBody)))
require.NoError(t, err)
// 資料是 JSON 格式
req.Header.Set("Content-Type", "application/json")
// 這裡你就可以繼續使用 req
resp := httptest.NewRecorder()
// 這就是 HTTP 請求進去 GIN 框架的入口。
// 當你這樣呼叫的時候,GIN 就會處理這個請求
// 響應寫回到 resp 裡
server.ServeHTTP(resp, req)
assert.Equal(t, tc.wantCode, resp.Code)
assert.Equal(t, tc.wantBody, resp.Body.String())
})
}
}
func TestMock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), gomock.Any()).
Return(errors.New("mock error"))
//usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
// Email: "124@qq.com",
//}).Return(errors.New("mock error"))
err := usersvc.SignUp(context.Background(), domain.User{
Email: "123@qq.com",
})
t.Log(err)
}
四、flags
gomock
有一些命令列標誌,可以幫助你控制生成過程。這些標誌通常在 gomock
工具的幫助下使用,例如 gomock generate
。
mockgen
命令用來為給定一個包含要mock的介面的Go原始檔,生成mock類原始碼。它支援以下標誌:
-source
:包含要mock的介面的檔案。-destination
:生成的原始碼寫入的檔案。如果不設定此項,程式碼將列印到標準輸出。-package
:用於生成的模擬類原始碼的包名。如果不設定此項包名預設在原包名前新增mock_
字首。-imports
:在生成的原始碼中使用的顯式匯入列表。值為foo=bar/baz形式的逗號分隔的元素列表,其中bar/baz是要匯入的包,foo是要在生成的原始碼中用於包的識別符號。-aux_files
:需要參考以解決的附加檔案列表,例如在不同檔案中定義的嵌入式介面。指定的值應為foo=bar/baz.go形式的以逗號分隔的元素列表,其中bar/baz.go是原始檔,foo是-source
檔案使用的檔案的包名。-build_flags
:(僅反射模式)一字不差地傳遞標誌給go build-mock_names
:生成的模擬的自定義名稱列表。這被指定為一個逗號分隔的元素列表,形式為Repository = MockSensorRepository,Endpoint=MockSensorEndpoint
,其中Repository
是介面名稱,mockSensorrepository
是所需的mock名稱(mock工廠方法和mock記錄器將以mock命名)。如果其中一個介面沒有指定自定義名稱,則將使用預設命名約定。-self_package
:生成的程式碼的完整包匯入路徑。使用此flag的目的是透過嘗試包含自己的包來防止生成程式碼中的迴圈匯入。如果mock的包被設定為它的一個輸入(通常是主輸入),並且輸出是stdio,那麼mockgen就無法檢測到最終的輸出包,這種情況就會發生。設定此標誌將告訴 mockgen 排除哪個匯入-copyright_file
:用於將版權標頭新增到生成的原始碼中的版權檔案-debug_parser
:僅列印解析器結果-exec_only
:(反射模式) 如果設定,則執行此反射程式-prog_only
:(反射模式)只生成反射程式;將其寫入標準輸出並退出。-write_package_comment
:如果為true,則寫入包文件註釋 (godoc)。(預設為true)
五、打樁(stub)
在測試中,打樁是一種測試術語,用於為函式或方法設定一個預設的返回值,而不是呼叫真實的實現。在 gomock
中,打樁通常透過設定期望的行為來實現。
例如,您可以為 myServiceMock
的 DoSomething
方法設定一個期望的行為,並返回一個特定的錯誤。這可以透過呼叫 myServiceMock.EXPECT().DoSomething().Return(error)
來實現。
在單元測試中,使用 gomock
可以幫助你更有效地模擬外部依賴,從而編寫更可靠和更高效的測試。通常用來遮蔽或補齊業務邏輯中的關鍵程式碼方便進行單元測試。
遮蔽:不想在單元測試用引入資料庫連線等重資源
補齊:依賴的上下游函式或方法還未實現
gomock
支援針對引數、返回值、呼叫次數、呼叫順序等進行打樁操作。
引數
引數相關的用法有:
- gomock.Eq(value):表示一個等價於value值的引數
- gomock.Not(value):表示一個非value值的引數
- gomock.Any():表示任意值的引數
- gomock.Nil():表示空值的引數
- SetArg(n, value):設定第n(從0開始)個引數的值,通常用於指標引數或切片
六、總結
6.1 測試用例定義
測試用例定義,最完整的情況下應該包含:
- 名字:簡明扼要說清楚你測試的場景,建議用中文。
- 預期輸入:也就是作為你方法的輸入。如果測試的是定義在型別上的方法,那麼也可以包含型別例項。
- 預期輸出:你的方法執行完畢之後,預期返回的資料。如果方法是定義在型別上的方法,那麼也可以包含執行之後的例項的狀態。
- mock:每一個測試需要使用到的mock狀態。單元測試裡面常見,整合測試一般沒有。
- 資料準備:每一個測試用例需要的資料。整合測試裡常見。
- 資料清理:每一個測試用例在執行完畢之後,需要執行一些資料清理動作。整合測試裡常見。
如果你要測試的方法很簡單,那麼你用不上全部欄位。
6.2 設計測試用例
測試用例定義和執行測試用例都是很模板化的東西。測試用例就是要根據具體的方法來設計。
- 如果是單元測試:看程式碼,最起碼做到分支覆蓋。
- 如果是整合測試:至少測完業務層面的主要正常流程和主要異常流程。
單元測試覆蓋率做到80%以上,在這個要求之下,只有極少數的異常分支沒有測試。其它測試就不是我們研發要考慮的了,讓測試團隊去搞。
6.3 執行測試用例程式碼
測試用例定義出來之後,怎麼執行這些用例,就已經呼之欲出了。
這裡分成幾個部分:
- 初始化 mock 控制器,每個測試用例都有獨立的 mock 控制器。
- 使用控制器 ctrl 呼叫 tc.mock,拿到 mock 的 UserService 和 CodeService。
- 使用 mock 的服務初始化 UserHandler,並且註冊路由。
- 構造 HTTP 請求和響應 Recorder。
- 發起呼叫 ServeHTTP。
6.4 執行測試用例
測試裡面的testCases
是一個匿名結構體的切片,所以執行的時候就是直接遍歷。
那麼針對每一個測試用例:
- 首先呼叫mock部分,或者執行before。
- 執行測試的方法。
- 比較預期結果。
- 呼叫after方法。
注意執行的時候,先呼叫了t.Run
,並且傳入了測試用例的名字。
6.5 不是所有的場景都很好測試
即便你的程式碼寫得非常好,但是有一些場景基本上不可能測試到。如圖中的error
分支,就是屬於很難測試的。
因為bcrypt
包你控制不住,Generate
這個方法只有在超時的時候才會返回error
。那麼你不測試也是可以的,程式碼review
可以確保這邊正確處理了error
。
記住:沒有測試到的程式碼,一定要認真review
。
小黃書單元測試程式碼:https://github.com/tao-xiaoxin/demo/tree/main/gotest/gmock/webook/backend