Golang 單元測試 - 介面層

LinkinStar發表於2023-03-16

上次我們已經搞定了邏輯層的單元測試,這次我們來康康介面層的單元測試。介面層主要負責的就是請求的處理,最常見的就是 HTTP 請求的處理。

但針對 介面層 的單元測試其實是可以五花八門的。它並不像邏輯層和資料層一樣的通用,對於它的測試往往有很多路可以走。

由於使用的 HTTP 框架不同,單元測試的實現方式則不同。 既可以透過程式來模擬 HTTP 請求,也可以透過真實的 HTTP 請求來測試,透過藉助外部的一些測試工具來實現。

所以本文只能給出一種思路,具體的實現方式還是要根據實際的框架來實現。

環境

本文以常用的 gin 框架為例,使用一種個人比較喜歡也非常簡單的方式來實現單元測試。特點主要有:

  1. 不需要啟動路由服務
  2. 複用已有的專案內的請求結構

程式碼

由於之前已經貼過,所以 service 層的 程式碼這裡就不贅述了

base case

package controller

import (
    "context"

    "github.com/gin-gonic/gin"
    "go-demo/m/unit-test/entity"
)

//go:generate mockgen -source=./user.go -destination=../mock/user_service_mock.go -package=mock
type UserService interface {
    AddUser(ctx context.Context, username string) (err error)
    GetUser(ctx context.Context, userID int) (user *entity.User, err error)
}

type AddUserRequest struct {
    Username string `json:"username" binding:"required"`
}

type GetUserRequest struct {
    UserID int `form:"user_id" binding:"required"`
}

type GetUserResponse struct {
    Username string `json:"username"`
}

type UserController struct {
    UserService UserService
}

func NewUserController(userService UserService) *UserController {
    return &UserController{UserService: userService}
}

func (uc *UserController) AddUser(ctx *gin.Context) {
    req := &AddUserRequest{}
    if err := ctx.BindJSON(req); err != nil {
        return
    }
    if err := uc.UserService.AddUser(ctx, req.Username); err != nil {
        ctx.JSON(400, gin.H{"error": err.Error()})
        return
    }
    ctx.JSON(200, gin.H{"message": "success"})
}

func (uc *UserController) GetUser(ctx *gin.Context) {
    req := &GetUserRequest{}
    if err := ctx.BindQuery(req); err != nil {
        return
    }
    user, err := uc.UserService.GetUser(ctx, req.UserID)
    if err != nil {
        ctx.JSON(400, gin.H{"error": err.Error()})
        return
    }
    ctx.JSON(200, &GetUserResponse{Username: user.Username})
}
  • 既然之前我們 service 的單元測試已經透過,這次我們就需要 mock 的是 service 層的介面 mockgen -source=./user.go -destination=../mock/user_service_mock.go -package=mock
  • 這裡我將請求和返回的結構 如:GetUserRequest、GetUserResponse 放在了這裡僅僅是為了方便展示程式碼

單元測試

基礎程式碼非常簡單,就是我們常見的,最重要的讓我們來看看單元測試應該怎麼寫

工具方法

在編寫實際單元測試之前,我們需要一些工具方法來幫助我們構建一些請求。

func createGetReqCtx(req interface{}, handlerFunc gin.HandlerFunc) (isSuccess bool, resp string) {
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)
    encode := structToURLValues(req).Encode()
    c.Request, _ = http.NewRequest("GET", "/?"+encode, nil)
    handlerFunc(c)
    return w.Code == http.StatusOK, w.Body.String()
}

func createPostReqCtx(req interface{}, handlerFunc gin.HandlerFunc) (isSuccess bool, resp string) {
    responseRecorder := httptest.NewRecorder()
    ctx, _ := gin.CreateTestContext(responseRecorder)
    body, _ := json.Marshal(req)
    ctx.Request, _ = http.NewRequest("POST", "/", bytes.NewBuffer(body))
    ctx.Request.Header.Set("Content-Type", "application/json")

    handlerFunc(ctx)
    return responseRecorder.Code == http.StatusOK, responseRecorder.Body.String()
}

// 將結構體轉換為 URL 引數
func structToURLValues(s interface{}) url.Values {
    v := reflect.ValueOf(s)
    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }
    t := v.Type()

    values := url.Values{}
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        tag := field.Tag.Get("form")
        if tag == "" {
            continue
        }

        value := v.Field(i).Interface()
        values.Set(tag, valueToString(value))
    }

    return values
}

// 由於 get 請求常常引數並不會特別複雜,通常的幾種型別就應該可以包括,有需要可以繼續新增
func valueToString(v interface{}) string {
    switch v := v.(type) {
    case int:
        return strconv.Itoa(v)
    case string:
        return v
    default:
        return ""
    }
}

既然我們不想啟動路由,其實最關鍵的問題就在如何構建一個 gin.Context 來模擬正常的請求。

  • 透過 gin.CreateTestContext 建立一個我們需要模擬的 context
  • 透過 http.NewRequest 來建立我們需要的請求結構

單元測試

有了我們的工具方法,那麼編寫單元測試的時候就非常方便了,mock 方法和之前類似,剩下要呼叫對應的方法就可以了。並且這裡可以複用我們已經在原有程式中使用的 請求結構 如 GetUserRequest 這樣就可以不需要重新勞動了。

package controller

import (
    "fmt"
    "testing"

    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    "go-demo/m/unit-test/entity"
    "go-demo/m/unit-test/mock"
)

func TestUserController_AddUser(t *testing.T) {
    ctl := gomock.NewController(t)
    defer ctl.Finish()

    req := &AddUserRequest{Username: "LinkinStar"}
    mockUserService := mock.NewMockUserService(ctl)
    mockUserService.EXPECT().AddUser(gomock.Any(), gomock.Any()).Return(nil)

    userController := NewUserController(mockUserService)

    success, resp := createPostReqCtx(req, userController.AddUser)
    assert.True(t, success)
    fmt.Println(resp)
}

func TestUserController_GetUser(t *testing.T) {
    ctl := gomock.NewController(t)
    defer ctl.Finish()

    req := &GetUserRequest{UserID: 1}
    user := &entity.User{Username: "LinkinStar"}
    mockUserService := mock.NewMockUserService(ctl)
    mockUserService.EXPECT().GetUser(gomock.Any(), gomock.Any()).Return(user, nil)

    userController := NewUserController(mockUserService)

    success, resp := createGetReqCtx(req, userController.GetUser)
    assert.True(t, success)
    fmt.Println(resp)
}

可以看到測試方法如出一轍,再詳細的話只需要對請求的返回值做解析然後進行斷言即可。

問題

當然以上述方式來實現單元測試的話,是會遺漏一些問題,畢竟偷懶是要有代價的。
  1. 路由路徑的問題:可以看到上述的單元測試中並沒有註冊對應的 url 地址,那麼實際中可能會由於程式碼路由的書寫錯誤而導致 404 的情況
  2. 請求結構欄位錯誤:由於我們複用了原有程式碼中的請求結構,即使單詞拼寫錯誤依然能成功,因為兩邊都一樣錯,所以即使欄位名稱與介面檔案不一致也無法發現。

針對這兩個問題,我覺得可以由更加上層的測試來保證,由於這裡僅僅是單元測試,我覺得這些代價還是可以接受的。並且,如果是使用 swagger 生成檔案的情況下,也能保證檔案和程式碼的統一性。但在此還是要出來提個醒,畢竟實際問題我還是遇到過的。

最佳化點

當然,這裡的舉例還是過於簡單,實際中的請求往往會比較複雜。

  1. 實際場景往往一些請求需要鑑權,這個可以在根據實際你的鑑權方式在前面新增中介軟體統一來處理登入就可以
  2. 其他型別的請求也是類似的如 PUT、DELETE 等
  3. 當前只是簡單的處理了正常的 200 HTTP Code 還會出現其他異常的情況也需要按實際介面進行處理

總結

通常從現象來說,這一層的測試往往發現的問題比較少,是由於這一層的邏輯少,測試下來最常見的問題往往就是欄位名稱和限制條件不滿足需求。所以其實從價效比的角度來說,單獨對這層拿出來測試往往比較低,故實際中見到的比較少。

不過話又說回來了,本文的目的不僅僅是為了讓你瞭解到可以這樣寫單元測試,其中使用的方法往往還能再某些時候讓你複用 handler 的方法來保證系統的一致性。

那麼,介面層的單元測試結束了,在下一篇,將來介紹有關單元測試的其他一些小技巧。

相關文章