什麼是單元測試?為什麼要寫單元測試?
如何在 Go 中撰寫單元測試?
這些問題的答案,網路上已有大量的資源可以參考,這裏就不再贅述。
然而,當我們的程式碼依賴外部系統(如資料庫、第三方 API)時,該如何撰寫單元測試?這個問題卻常常被忽略。
在這篇文章中,將分享我們在專案中,如何使用 mockgen
來 Mock 資料庫與其他外部依賴,並使用 testify
來撰寫單元測試。
我們以 login
為例,我們的 usecase
依賴 repository
,而後者需要與真實的資料庫進行溝通。在單元測試中,我們不希望連線一個真實的資料庫,因為這會讓測試變得:
解決方案就是模擬(Mocking)。我們將建立一個「假的」repository
,它不連接資料庫,而是完全按照我們的指令來回傳預設好的結果。
我們將使用 mockgen
來完成這項工作。
testify
而不是使用原生的 testing
套件?testify
提供了幾個非常有用的套件:
assert
和 require
:提供了豐富的斷言函式(如 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
提供了三個非常有用的套件:
assert
和 require
:提供了豐富的斷言函式(如 Equal
, NoError
, True
),讓測試的檢查語句更具可讀性。require
在斷言失敗時會立即中止測試,而 assert
則會繼續執行。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
的檔案,裡面包含了 repository
和 password
和 token
介面的 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
來比較預期值和實際值,底下也列出了傳統的比較方式,可以看到 testify
的 assert
的簡潔與可讀性。
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
為了方便我們的開發流程,我們可以將 go generate
和 go 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。