iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 22

[Day 22] Go 單元測試:如何 Mock 資料庫與外部依賴

  • 分享至 

  • xImage
  •  

什麼是單元測試?為什麼要寫單元測試?
如何在 Go 中撰寫單元測試?
這些問題的答案,網路上已有大量的資源可以參考,這裏就不再贅述。

然而,當我們的程式碼依賴外部系統(如資料庫、第三方 API)時,該如何撰寫單元測試?這個問題卻常常被忽略。
在這篇文章中,將分享我們在專案中,如何使用 mockgen 來 Mock 資料庫與其他外部依賴,並使用 testify 來撰寫單元測試。

為什麼要 Mock?

我們以 login 為例,我們的 usecase 依賴 repository,而後者需要與真實的資料庫進行溝通。在單元測試中,我們不希望連線一個真實的資料庫,因為這會讓測試變得:

  • 緩慢:網路和磁碟 I/O 非常耗時。
  • 不穩定:網路問題或資料庫服務的抖動都可能導致測試失敗。
  • 不獨立:測試結果依賴於資料庫中的既有狀態。

解決方案就是模擬(Mocking)。我們將建立一個「假的」repository,它不連接資料庫,而是完全按照我們的指令來回傳預設好的結果。

我們將使用 mockgen 來完成這項工作。

為什麼選擇 testify 而不是使用原生的 testing 套件?

testify 提供了幾個非常有用的套件:

  • assertrequire:提供了豐富的斷言函式(如 Equal, NoError, True),讓測試的檢查語句更具可讀性。require 在斷言失敗時會立即中止測試,而 assert 則會繼續執行。
  • mock:提供了建立測試替身(Mock 物件)的框架。(本文未使用)
  • suite:提供了測試套件的結構化方式,讓我們可以更好地組織測試程式碼。(本文未使用)

使用原生的 testing 套件,我們需要手動撰寫所有的斷言和 Mock 物件,這會增加測試的複雜度和維護成本。

準備工作

第一步:安裝 mockgen

這裡要說明一下,mockgen 一開始是由 Golang 團隊開發的 golang/mock 套件的一部分。
2023 年 6 月,golang/mock 將不再積極維護,並建議使用 uber 版本的 mockgen
因此,我們將安裝 uber 版本的 mockgen

安裝 mockgen:

go install go.uber.org/mock/mockgen@latest

添加 mock 依賴:

go get go.uber.org/mock

第二步:安裝 testify

go get github.com/stretchr/testify

testify 提供了三個非常有用的套件:

  • assertrequire:提供了豐富的斷言函式(如 Equal, NoError, True),讓測試的檢查語句更具可讀性。require 在斷言失敗時會立即中止測試,而 assert 則會繼續執行。
  • mock:提供了建立測試替身(Mock 物件)的框架。(本文未使用)

實作測試

第一步:產出 Mock 物件

// internal/usecase/api/user/login/login.go

// 添加以下這一行
//go:generate mockgen -source=login.go -destination=./login_mock_test.go -package=login

最前面的 //go:generate 是一個特殊的註解,Go 工具會識別它並在執行 go generate 命令時執行後面的指令。

mockgen 指令的參數說明:

  • -source=login.go:指定要產生 Mock 的原始檔案。
  • -destination=./login_mock_test.go:指定產生的 Mock 檔案的路徑。
  • -package=login:指定產生的 Mock 檔案所屬的套件名稱。

執行以下命令來產生 Mock 物件:

go generate ./internal/usecase/api/user/login

這會在 internal/usecase/api/user/login 目錄下產生一個名為 login_mock_test.go 的檔案,裡面包含了 repositorypasswordtoken 介面的 Mock 實作。
這是自動產生的程式碼,我們不需要手動修改它。
只要 login.go 中的介面有改變時,重新執行 go generate 命令即可。

第二步:撰寫測試程式碼

這裏只列出特別說明的部分。

// internal/usecase/api/user/login/login_test.go

// 準備 Mock Controller
ctrl := gomock.NewController(t)

// 測試結束後釋放資源
defer ctrl.Finish()

// 建立 Mock 物件
mockRepository := NewMockrepository(ctrl)
mockPassword := NewMockpassword(ctrl)
mockToken := NewMocktoken(ctrl)

// 設定 Mock 物件的行為
mockRepository.EXPECT().FindUserByEmail(gomock.Any(), "<Email>").Return(getDomainUserModel(1, "<Email>", "<HashedPassword>"), nil)
mockPassword.EXPECT().Compare("<HashedPassword>", "<Password>").Return(nil)
mockToken.EXPECT().GenerateAccessToken(1).Return("<Token>", nil)

在這段程式碼中,我們使用 gomock.NewController(t) 來建立一個新的 Mock Controller,並在測試結束後呼叫 ctrl.Finish() 來釋放資源。

接著,我們使用 NewMockrepository(ctrl)NewMockpassword(ctrl)NewMocktoken(ctrl) 來建立 Mock 物件。

最後,我們使用 EXPECT() 方法來設定 Mock 物件的行為。
例如,我們告訴 mockRepository 當呼叫 FindUserByEmail 方法時,傳入任何參數(gomock.Any()<Email>),並且回傳一個預設的使用者模型和 nil 錯誤。

// 使用 assert 來檢查結果
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.want1, got1)

// 傳統的比較方式
if !reflect.DeepEqual(got, tt.want) {
	t.Errorf("UseCase.Execute() got = %v, want %v", got, tt.want)
}
if (got1 == nil) != (tt.want1 == nil) || (got1 != nil && tt.want1 != nil && got1.Error() != tt.want1.Error()) {
	t.Errorf("UseCase.Execute() got1 = %v, want %v", got1, tt.want1)
}

在這段程式碼中,我們使用 assert.Equal 來比較預期值和實際值,底下也列出了傳統的比較方式,可以看到 testifyassert 的簡潔與可讀性。

第三步:執行測試

go test ./internal/usecase/api/user/login -v -count=1

-count=1 參數是為了避免 Go 的測試快取機制,確保每次執行測試時都會重新執行測試程式碼。

應該就可以看到測試通過的訊息。

=== RUN   TestUseCase_Execute
=== RUN   TestUseCase_Execute/normal_case
--- PASS: TestUseCase_Execute (0.00s)
    --- PASS: TestUseCase_Execute/normal_case (0.00s)
PASS
ok      github.com/nick6969/go-clean-project/internal/usecase/api/user/login    0.008s

整合 Makefile

為了方便我們的開發流程,我們可以將 go generatego test 指令整合到 Makefile 中。

# ==============================================================================
# Test
# ==============================================================================

.PHONY: buildMock unitTest showCodeCoverage showTestFailure
buildMock: ## Build mock files
	@go generate ./...

unitTest: ## Run unit tests and show coverage
	@if ! [ -d "$(TestConvertFileDir)" ]; then mkdir $(TestConvertFileDir); fi
	@go test ./... -count=1 -coverprofile=$(TestConvertFilePath) && make showCodeCoverage || make showTestFailure

showCodeCoverage:
	@echo "\033[31m\033[1m"
	@go tool cover -func=$(TestConvertFilePath) | tail -n 1 | awk '{print $$3}' | xargs echo "Test Coverage:"
	@echo "\033[0m"

showTestFailure:
	@echo "\033[1;33;41m\033[1m🥶🥶🥶🥶 Test Failure 🥶🥶🥶🥶\033[0m"

這樣,我們只需要執行 make buildMock 來產生所有的 Mock 物件,然後執行 make unitTest 來執行所有的單元測試並顯示測試覆蓋率。

總結

在這篇文章中,我們介紹了如何在 Go 中使用 mockgen 來 Mock 資料庫與其他外部依賴,並使用 testify 來撰寫單元測試。
這樣的做法可以讓我們的單元測試更快、更穩定,並且更容易維護。
希望這篇文章對你有所幫助,讓你在撰寫 Go 單元測試時更加得心應手!

詳細的程式碼,請參考 Github 這一個 commit。


上一篇
[Day 21] 優雅地處理錯誤(二):定義自己的錯誤類型
系列文
Go Clean Architecture API 開發全攻略22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言