Go 單元測試之HTTP請求與API測試

贾维斯Echo發表於2024-04-17

目錄
  • 一、httptest
    • 1.1 前置程式碼準備
    • 1.2 介紹
    • 1.3 基本用法
  • 二、gock
    • 2.1介紹
    • 2.2 安裝
    • 2.3 基本使用
    • 2.4 舉個例子
      • 2.4.1 前置程式碼
      • 2.4.2 測試用例

一、httptest

1.1 前置程式碼準備

假設我們的業務邏輯是搭建一個http server端,對外提供HTTP服務。用來處理使用者登入請求,使用者需要輸入郵箱,密碼。

package main

import (
	regexp "github.com/dlclark/regexp2"
	"github.com/gin-gonic/gin"
	"net/http"
)

type UserHandler struct {
	emailExp    *regexp.Regexp
	passwordExp *regexp.Regexp
}

func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
	ug := server.Group("/user")
	ug.POST("/login", u.Login)
}
func NewUserHandler() *UserHandler {
	const (
		emailRegexPattern    = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$"
		passwordRegexPattern = `^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{8,}$`
	)
	emailExp := regexp.MustCompile(emailRegexPattern, regexp.None)
	passwordExp := regexp.MustCompile(passwordRegexPattern, regexp.None)
	return &UserHandler{
		emailExp:    emailExp,
		passwordExp: passwordExp,
	}
}

type LoginRequest struct {
	Email string `json:"email"`
	Pwd   string `json:"pwd"`
}

func (u *UserHandler) Login(ctx *gin.Context) {
	var req LoginRequest
	if err := ctx.ShouldBindJSON(&req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"msg": "引數不正確!"})
		return
	}

	// 校驗郵箱和密碼是否為空
	if req.Email == "" || req.Pwd == "" {
		ctx.JSON(http.StatusBadRequest, gin.H{"msg": "郵箱或密碼不能為空"})
		return
	}

	// 正則校驗郵箱
	ok, err := u.emailExp.MatchString(req.Email)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"msg": "系統錯誤!"})
		return
	}
	if !ok {
		ctx.JSON(http.StatusBadRequest, gin.H{"msg": "郵箱格式不正確"})
		return
	}

	// 校驗密碼格式
	ok, err = u.passwordExp.MatchString(req.Pwd)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"msg": "系統錯誤!"})
		return
	}
	if !ok {
		ctx.JSON(http.StatusBadRequest, gin.H{"msg": "密碼必須大於8位,包含數字、特殊字元"})
		return
	}

	// 校驗郵箱和密碼是否匹配特定的值來確定登入成功與否
	if req.Email != "123@qq.com" || req.Pwd != "hello#world123" {
		ctx.JSON(http.StatusBadRequest, gin.H{"msg": "郵箱或密碼不匹配!"})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{"msg": "登入成功!"})
}

func InitWebServer(userHandler *UserHandler) *gin.Engine {
	server := gin.Default()
	userHandler.RegisterRoutes(server)
	return server
}

func main() {
	uh := &UserHandler{}
	server := InitWebServer(uh)
	server.Run(":8080") // 在8080埠啟動伺服器
}

1.2 介紹

在 Web 開發場景下,單元測試經常需要模擬 HTTP 請求和響應。使用 httptest 可以讓我們在測試程式碼中建立一個 HTTP 伺服器例項,並定義特定的請求和響應行為,從而模擬真實世界的網路互動,在Go語言中,一般都推薦使用Go標準庫 net/http/httptest 進行測試。

1.3 基本用法

使用 httptest 的基本步驟如下:

  1. 匯入 net/http/httptest 包。
  2. 建立一個 httptest.Server 例項,並指定你想要的伺服器行為。
  3. 在測試程式碼中使用 httptest.NewRequest 建立一個模擬的 HTTP 請求,並將其傳送到 httptest.Server
  4. 檢查響應內容或狀態碼是否符合預期。

以下是一個簡單的 httptest 用法示例

package main

import (
	"bytes"
	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestUserHandler_Login(t *testing.T) {
	// 定義測試用例
	testCases := []struct {
		name     string
		reqBody  string
		wantCode int
		wantBody string
	}{
		{
			name:     "登入成功",
			reqBody:  `{"email": "123@qq.com", "pwd": "hello#world123"}`,
			wantCode: http.StatusOK,
			wantBody: `{"msg": "登入成功!"}`,
		},
		{
			name:     "引數不正確",
			reqBody:  `{"email": "123@qq.com", "pwd": "hello#world123",}`,
			wantCode: http.StatusBadRequest,
			wantBody: `{"msg": "引數不正確!"}`,
		},
		{
			name:     "郵箱或密碼為空",
			reqBody:  `{"email": "", "pwd": ""}`,
			wantCode: http.StatusBadRequest,
			wantBody: `{"msg": "郵箱或密碼不能為空"}`,
		},
		{
			name:     "郵箱格式不正確",
			reqBody:  `{"email": "invalidemail", "pwd": "hello#world123"}`,
			wantCode: http.StatusBadRequest,
			wantBody: `{"msg": "郵箱格式不正確"}`,
		},
		{
			name:     "密碼格式不正確",
			reqBody:  `{"email": "123@qq.com", "pwd": "invalidpassword"}`,
			wantCode: http.StatusBadRequest,
			wantBody: `{"msg": "密碼必須大於8位,包含數字、特殊字元"}`,
		},
		{
			name:     "郵箱或密碼不匹配",
			reqBody:  `{"email": "123123@qq.com", "pwd": "hello#world123"}`,
			wantCode: http.StatusBadRequest,
			wantBody: `{"msg": "郵箱或密碼不匹配!"}`,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// 建立一個 gin 的上下文
			server := gin.Default()
			h := NewUserHandler()
			h.RegisterRoutes(server)
			// mock 建立一個 http 請求
			req, err := http.NewRequest(
				http.MethodPost,                     // 請求方法
				"/user/login",                       // 請求路徑
				bytes.NewBuffer([]byte(tc.reqBody)), // 請求體
			)
			// 斷言沒有錯誤
			assert.NoError(t, err)
			// 設定請求頭
			req.Header.Set("Content-Type", "application/json")
			// 建立一個響應
			resp := httptest.NewRecorder()
			// 服務端處理請求
			server.ServeHTTP(resp, req)
			// 斷言響應碼和響應體
			assert.Equal(t, tc.wantCode, resp.Code)
			// 斷言 JSON 字串是否相等
			assert.JSONEq(t, tc.wantBody, resp.Body.String())
		})
	}
}

在這個例子中,我們建立了一個簡單的 HTTP 請求,TestUserHandler_Login 函式定義了一個測試函式,用於測試使用者登入功能的不同情況。

  1. testCases 列表定義了多個測試用例,每個測試用例包含了測試名稱、請求體、期望的 HTTP 狀態碼和期望的響應體內容。
  2. 使用 for 迴圈遍歷測試用例列表,每次迴圈建立一個新的測試子函式,並在其中模擬 HTTP 請求傳送給登入介面。
  3. 在每個測試子函式中,先建立一個 Gin 的預設上下文和使用者處理器 UserHandler,然後註冊路由並建立一個模擬的 HTTP 請求。
  4. 透過 httptest.NewRecorder() 建立一個響應記錄器,使用 server.ServeHTTP(resp, req) 處理模擬請求,得到響應結果。
  5. 最後使用斷言來驗證實際響應的 HTTP 狀態碼和響應體是否與測試用例中的期望一致。

最後,使用Goland 執行測試,結果如下:

二、gock

2.1介紹

gock 可以幫助你在測試過程中模擬 HTTP 請求和響應,這對於測試涉及外部 API 呼叫的應用程式非常有用。它可以讓你輕鬆地定義模擬請求,並驗證你的應用程式是否正確處理了這些請求。

GitHub 地址:github.com/h2non/gock

2.2 安裝

你可以透過以下方式安裝 gock:

go get -u github.com/h2non/gock

匯入 gock 包:

import "github.com/h2non/gock"

2.3 基本使用

gock 的基本用法如下:

  1. 啟動攔截器:在測試開始前,使用 gock.New 函式啟動攔截器,並指定你想要攔截的域名和埠。
  2. 定義攔截規則:你可以使用 gock.Intercept 方法來定義攔截規則,比如攔截特定的 URL、方法、頭部資訊等。
  3. 設定響應:你可以使用 gock.NewJsongock.NewText 等方法來設定攔截後的響應內容。
  4. 執行測試:在定義了攔截規則和響應後,你可以執行測試,gock 會攔截你的 HTTP 請求,並返回你設定的響應。

2.4 舉個例子

2.4.1 前置程式碼

如果我們是在程式碼中請求外部API的場景(比如透過API呼叫其他服務獲取返回值)又該怎麼編寫單元測試呢?

例如,我們有以下業務邏輯程式碼,依賴外部API:http://your-api.com/post提供的資料。

// ReqParam API請求引數
type ReqParam struct {
	X int `json:"x"`
}

// Result API返回結果
type Result struct {
	Value int `json:"value"`
}

func GetResultByAPI(x, y int) int {
	p := &ReqParam{X: x}
	b, _ := json.Marshal(p)

	// 呼叫其他服務的API
	resp, err := http.Post(
		"http://your-api.com/post",
		"application/json",
		bytes.NewBuffer(b),
	)
	if err != nil {
		return -1
	}
	body, _ := ioutil.ReadAll(resp.Body)
	var ret Result
	if err := json.Unmarshal(body, &ret); err != nil {
		return -1
	}
	// 這裡是對API返回的資料做一些邏輯處理
	return ret.Value + y
}

在對類似上述這類業務程式碼編寫單元測試的時候,如果不想在測試過程中真正去傳送請求或者依賴的外部介面還沒有開發完成時,我們可以在單元測試中對依賴的API進行mock。

2.4.2 測試用例

使用gock對外部API進行mock,即mock指定引數返回約定好的響應內容。 下面的程式碼中mock了兩組資料,組成了兩個測試用例。

package gock_demo

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"gopkg.in/h2non/gock.v1"
)

func TestGetResultByAPI(t *testing.T) {
	defer gock.Off() // 測試執行後重新整理掛起的mock

	// mock 請求外部api時傳參x=1返回100
	gock.New("http://your-api.com").
		Post("/post").
		MatchType("json").
		JSON(map[string]int{"x": 1}).
		Reply(200).
		JSON(map[string]int{"value": 100})

	// 呼叫我們的業務函式
	res := GetResultByAPI(1, 1)
	// 校驗返回結果是否符合預期
	assert.Equal(t, res, 101)

	// mock 請求外部api時傳參x=2返回200
	gock.New("http://your-api.com").
		Post("/post").
		MatchType("json").
		JSON(map[string]int{"x": 2}).
		Reply(200).
		JSON(map[string]int{"value": 200})

	// 呼叫我們的業務函式
	res = GetResultByAPI(2, 2)
	// 校驗返回結果是否符合預期
	assert.Equal(t, res, 202)

	assert.True(t, gock.IsDone()) // 斷言mock被觸發
}

相關文章