Go 單元測試之mock介面測試

贾维斯Echo發表於2024-04-18

目錄
  • 一、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 命令來自動化構建過程,包括生成模擬物件。你可以建立一個 Makefilemake.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檔案。

四、介面單元測試步驟

  1. 想清楚整體邏輯
  2. 定義想要(模擬)依賴項的interface(介面)
  3. 使用mockgen命令對所需mock的interface生成mock檔案
  4. 編寫單元測試的邏輯,在測試中使用mock
  5. 進行單元測試的驗證

三、小黃書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 中,打樁通常透過設定期望的行為來實現。
例如,您可以為 myServiceMockDoSomething 方法設定一個期望的行為,並返回一個特定的錯誤。這可以透過呼叫 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

相關文章