Golang 高效編寫單元測試的技巧之 Mock
在項目中進行單元測試是一種重要的開發實踐。然而,當被測代碼依賴其他模塊或組件時,編寫單元測試變得複雜且不穩定。本文將介紹如何使用 mock 來編寫簡潔高效的單元測試。
引言
首先我們先來看下項目中的依賴注入文件cmd/server/wire.go
:
tip: 該文件由
google/wire
工具自動編譯生成,禁止人爲編輯
// Injectors from wire.go:
func newApp(viperViper *viper.Viper, logger *log.Logger) (*gin.Engine, func(), error) {
jwt := middleware.NewJwt(viperViper)
handlerHandler := handler.NewHandler(logger)
sidSid := sid.NewSid()
serviceService := service.NewService(logger, sidSid, jwt)
db := repository.NewDB(viperViper)
client := repository.NewRedis(viperViper)
repositoryRepository := repository.NewRepository(db, client, logger)
userRepository := repository.NewUserRepository(repositoryRepository)
userService := service.NewUserService(serviceService, userRepository)
userHandler := handler.NewUserHandler(handlerHandler, userService)
engine := server.NewServerHTTP(logger, jwt, userHandler)
return engine, func() {
}, nil
}
從這段代碼我們可以得知handler
、service
、repository
之間的依賴關係,
userHandler
依賴於userService
,而userService
又依賴於 `userRepository。
比如handler/user.go
下面的GetProfile
代碼如下:
func (h *userHandler) GetProfile(ctx *gin.Context) {
userId := GetUserIdFromCtx(ctx)
if userId == "" {
resp.HandleError(ctx, http.StatusUnauthorized, 1, "unauthorized", nil)
return
}
user, err := h.userService.GetProfile(ctx, userId)
if err != nil {
resp.HandleError(ctx, http.StatusBadRequest, 1, err.Error(), nil)
return
}
resp.HandleSuccess(ctx, user)
}
我們會發現在它的內部調用了userService.GetProfile
。
因此在編寫單元測試的時候,我們就不可避免的需要先初始化userService
實例,而當我們去初始化userService
的時候,我們又會發現它又依賴於userRepository
。
明明我們只需要測試一個最底層的handler
,卻需要先初始化執行service
、repository
等代碼。 這很明顯違背了單元測試的(單一職責原則),每個單元測試只關注一個功能點或一個代碼單元。
有什麼比較好的辦法解決該問題呢,我們的最終答案就是mock
。
Mock(依賴隔離好幫手)
在進行單元測試時,我們希望測試的是被測代碼單元的邏輯,而不希望依賴其他外部模塊或組件的狀態或行爲。這樣做可以更好地隔離被測代碼,使得測試更加可靠和可重複。
Mock 是一種測試模式,用於模擬或替代被測代碼所依賴的外部模塊或組件。通過使用 Mock 對象,我們可以控制外部模塊的行爲,使得被測代碼在測試過程中不會真正依賴和調用外部模塊,從而實現對被測代碼的隔離。
Mock 對象可以模擬外部模塊的返回值、異常、超時等,使得測試可以更加可控和可預測。它解決了以下問題:
-
依賴其他模塊:某些代碼單元可能依賴其他模塊,例如數據庫、網絡請求等。通過使用 Mock 對象,我們可以模擬這些依賴,使得測試不需要真正依賴這些模塊,從而避免測試的不穩定性和複雜性。
-
隔離外部環境:某些代碼單元可能受到外部環境的影響,例如當前時間、系統狀態等。通過使用 Mock 對象,我們可以控制這些外部環境的狀態,使得測試可以在不同環境下運行,從而增加測試的覆蓋範圍和準確性。
-
提高測試效率:某些外部模塊可能執行耗時操作,例如網絡請求、文件讀寫等。通過使用 Mock 對象,我們可以避免真實執行這些操作,從而提高測試的執行速度和效率。
在 nunu 項目中,我們採用以下 mock 庫來幫助我們編寫單元測試
-
github.com/golang/mock // google 開源的 mock 庫
-
github.com/go-redis/redismock/v9 // 提供 redis 查詢的模擬測試,兼容 github.com/redis/go-redis/v9
-
github.com/DATA-DOG/go-sqlmock // sqlmock 是一個實現 sql/driver 的模擬庫
面向接口編程
使用golang/mock
有個前提,我們需要遵循 "面向接口編程" 的方式來編寫我們的repository
和service
。
可能有的同學不瞭解 "面向接口編程" 是什麼意思,我們這兒以一段代碼舉例:
package repository
import (
"github.com/go-nunu/nunu-layout-advanced/internal/model"
)
type UserRepository interface {
FirstById(id int64) (*model.User, error)
}
type userRepository struct {
*Repository
}
func NewUserRepository(repository *Repository) *UserRepository {
return &UserRepository{
Repository: repository,
}
}
func (r *UserRepository) FirstById(id int64) (*model.User, error) {
var user model.User
if err := r.db.Where("id = ?", id).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
上面的代碼中,我們先定義一個UserRepository interface
, 然後通過userRepository struct
去實現它的所有方法。
type UserRepository interface {
FirstById(id int64) (*model.User, error)
}
type userRepository struct {
*Repository
}
func (r *UserRepository) FirstById(id int64) (*model.User, error) {
// ...
}
而不是直接寫成
type UserRepository struct {
*Repository
}
func (r *UserRepository) FirstById(id int64) (*model.User, error) {
// ...
}
這就是所謂的面向接口編程,它可以提高代碼的靈活性、可擴展性、可測試性和可維護性,是 Go 語言非常推崇的一種編程風格。
go-mock 快速上手
golang/mock
的使用其實簡單,我們首先安裝一下它:
go install github.com/golang/mock/mockgen@v1.6.0
mockgen
是go-mock
的一個命令行工具,可以解析我們代碼中的interface
定義,自動生成正確的 mock 代碼
示例:
mockgen -source=internal/service/user.go -destination mocks/service/user.go
上面的命令指定了兩個參數,interface 源文件以及最終生成 mock 代碼的目標文件,我們將目標文件放置在mocks/service
目錄下面。
生成了UserService
的mock
代碼,我們就可以去編寫UserHandler
的單元測試了。
最終的單測代碼如下:
func TestUserHandler_GetProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockUserService := mock_service.NewMockUserService(ctrl)
// 關鍵代碼,定義mockUserService.GetProfile的返回值
mockUserService.EXPECT().GetProfile(gomock.Any(), userId).Return(&model.User{
Id: 1,
UserId: userId,
Username: "xxxxx",
Nickname: "xxxxx",
Password: "xxxxx",
Email: "xxxxx@gmail.com",
}, nil)
router := setupRouter(mockUserService)
req, _ := http.NewRequest("GET", "/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, resp.Code, http.StatusOK)
// Add assertions for the response body if needed
}
完整的源碼位於: https://github.com/go-nunu/nunu-layout-advanced/blob/main/test/server/handler/user_test.go
sqlmock 與 redismock
相對於handler
和service
的單元測試,repository
的稍微有些不一樣,因爲它依賴的不再是我們自己的業務模塊,而是依賴於 rpc、redis、MySQL 這些外部數據源。
這種情況下,爲了避免連接真實的數據庫和緩存,減少測試的不確定性,我們同樣進行 mock。
代碼如下
package repository
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/go-nunu/nunu-layout-advanced/internal/model"
"github.com/go-nunu/nunu-layout-advanced/internal/repository"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func setupRepository(t *testing.T) (repository.UserRepository, sqlmock.Sqlmock) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: mockDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open gorm connection: %v", err)
}
rdb, _ := redismock.NewClientMock()
repo := repository.NewRepository(db, rdb, nil)
userRepo := repository.NewUserRepository(repo)
return userRepo, mock
}
func TestUserRepository_GetByUsername(t *testing.T) {
userRepo, mock := setupRepository(t)
ctx := context.Background()
username := "test"
// 模擬查詢測試數據
rows := sqlmock.NewRows([]string{"id", "user_id", "username", "nickname", "password", "email", "created_at", "updated_at"}).
AddRow(1, "123", "test", "Test", "password", "test@example.com", time.Now(), time.Now())
mock.ExpectQuery("SELECT \\* FROM `users`").WillReturnRows(rows)
user, err := userRepo.GetByUsername(ctx, username)
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, "test", user.Username)
assert.NoError(t, mock.ExpectationsWereMet())
}
完整代碼位於:https://github.com/go-nunu/nunu-layout-advanced/blob/main/test/server/repository/user_test.go
測試覆蓋率
Golang 官方原生支持生成測試覆蓋率報告。
go test -coverpkg=./internal/handler,./internal/service,./internal/repository -coverprofile=./coverage.out ./test/server/...
go tool cover -html=./coverage.out -o coverage.html
上面的 2 條命令將會生成一個網頁可視化的覆蓋率報告文件coverage.html
,我們可以直接使用瀏覽器打開它。
效果如下:
總結
單元測試在項目中是一種重要的開發實踐,可以確保代碼的正確性並提供自動化驗證功能。在進行單元測試時,我們需要面向接口編程,使用 mock 對象來隔離被測代碼的依賴關係。在 Go 語言中,我們可以使用 golang/mock 庫來生成 mock 代碼。對於依賴外部數據源的 repository,我們可以使用 sqlmock 和 redismock 來模擬數據庫和緩存的行爲。通過使用 mock 對象,我們可以控制外部模塊的行爲,使得被測代碼在測試過程中不會真正依賴和調用外部模塊,從而實現對被測代碼的隔離。這樣可以提高測試的可靠性、可重複性和效率。
代碼倉庫:https://github.com/go-nunu/nunu-layout-advanced/tree/main/test/server
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DYhX5nMum_u1_GytTqn3pQ