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
}

從這段代碼我們可以得知handlerservicerepository之間的依賴關係,

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,卻需要先初始化執行servicerepository等代碼。 這很明顯違背了單元測試的(單一職責原則),每個單元測試只關注一個功能點或一個代碼單元。

有什麼比較好的辦法解決該問題呢,我們的最終答案就是mock

Mock(依賴隔離好幫手)

在進行單元測試時,我們希望測試的是被測代碼單元的邏輯,而不希望依賴其他外部模塊或組件的狀態或行爲。這樣做可以更好地隔離被測代碼,使得測試更加可靠和可重複。

Mock 是一種測試模式,用於模擬或替代被測代碼所依賴的外部模塊或組件。通過使用 Mock 對象,我們可以控制外部模塊的行爲,使得被測代碼在測試過程中不會真正依賴和調用外部模塊,從而實現對被測代碼的隔離。

Mock 對象可以模擬外部模塊的返回值、異常、超時等,使得測試可以更加可控和可預測。它解決了以下問題:

  1. 依賴其他模塊:某些代碼單元可能依賴其他模塊,例如數據庫、網絡請求等。通過使用 Mock 對象,我們可以模擬這些依賴,使得測試不需要真正依賴這些模塊,從而避免測試的不穩定性和複雜性。

  2. 隔離外部環境:某些代碼單元可能受到外部環境的影響,例如當前時間、系統狀態等。通過使用 Mock 對象,我們可以控制這些外部環境的狀態,使得測試可以在不同環境下運行,從而增加測試的覆蓋範圍和準確性。

  3. 提高測試效率:某些外部模塊可能執行耗時操作,例如網絡請求、文件讀寫等。通過使用 Mock 對象,我們可以避免真實執行這些操作,從而提高測試的執行速度和效率。

在 nunu 項目中,我們採用以下 mock 庫來幫助我們編寫單元測試

面向接口編程

使用golang/mock有個前提,我們需要遵循 "面向接口編程" 的方式來編寫我們的repositoryservice

可能有的同學不瞭解 "面向接口編程" 是什麼意思,我們這兒以一段代碼舉例:

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

mockgengo-mock的一個命令行工具,可以解析我們代碼中的interface定義,自動生成正確的 mock 代碼

示例:

mockgen -source=internal/service/user.go -destination mocks/service/user.go

上面的命令指定了兩個參數,interface 源文件以及最終生成 mock 代碼的目標文件,我們將目標文件放置在mocks/service目錄下面。

生成了UserServicemock代碼,我們就可以去編寫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

相對於handlerservice的單元測試,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