單元測試中如何解決 HTTP 網絡依賴問題
在開發 Web 應用程序時,確保 HTTP 功能的正確性是至關重要的。然而,由於 Web 應用程序通常涉及到與外部依賴的交互,編寫 HTTP 請求和響應的有效測試變得具有挑戰性。在進行單元測試時,我們必須思考如何解決被測程序的外部依賴問題。
因此,在 Go 語言中,我們需要找到一種可靠的方法來測試 HTTP 請求和響應。本文將探討在 Go 中進行 HTTP 應用測試時,如何解決應用程序的依賴問題,以確保我們能夠編寫出可靠的測試用例。
HTTP Server 測試
首先,我們來看下,站在 HTTP Server 端的角度,如何編寫應用程序的測試代碼。
假設我們有一個 HTTP Server 對外提供服務,代碼如下:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
var users = []User{
{ID: 1, Name: "user1"},
}
func CreateUserHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
...
}
func GetUserHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
...
}
func setupRouter() *httprouter.Router {
router := httprouter.New()
router.POST("/users", CreateUserHandler)
router.GET("/users/:id", GetUserHandler)
return router
}
func main() {
router := setupRouter()
_ = http.ListenAndServe(":8000", router)
}
這個服務監聽 8000
端口,分別提供了兩個 HTTP 接口:
POST /users
用來創建用戶。
GET /users/:id
用來獲取指定 ID 對應的用戶信息。
爲了保證業務的正確性,我們需要對 CreateUserHandler
和 GetUserHandler
這兩個 Handler 進行單元測試。
我們先來看下用於創建用戶的 CreateUserHandler
函數是如何定義的:
func CreateUserHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
defer func() { _ = r.Body.Close() }()
u := User{}
if err := json.Unmarshal(body, &u); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
u.ID = users[len(users)-1].ID + 1
users = append(users, u)
w.WriteHeader(http.StatusCreated)
}
在這個 Handler 中,首先寫入響應頭 Content-Type: application/json
,表示創建用戶的響應內容爲 JSON 格式。
接着從請求體 r.Body
中讀取客戶端提交的用戶信息。
如果讀取請求體失敗,則寫入響應狀態碼 400
,表示客戶端提交的用戶信息有誤,並返回 JSON 錯誤響應。
接着,使用 json.Unmarshal
對請求體進行 JSON 解碼,將數據填入 User
結構體中。
如果 JSON 解碼失敗,則寫入響應狀態碼 500
,表示服務端出現了錯誤,並返回 JSON 錯誤響應。
最終,將新創建的用戶信息保存到 users
切片中,並寫入響應狀態碼 201
,表示用戶創建成功。注意,根據 RESTful 規範,這裏並不需要返回響應體。
下面,我們來分析下如何對這個 Handler 函數編寫單元測試代碼。
首先,我們思考下 CreateUserHandler
這個函數都有哪些外部依賴?
從函數參數來看,我們需要一個用來表示 HTTP 響應的 http.ResponseWriter
,一個用來表示 HTTP 請求的 *http.Request
,以及一個用來記錄 HTTP 請求路由參數的 httprouter.Params
。
在函數內部,則依賴了全局變量 users
。
知道了這些外部依賴,那麼,我們如何編寫單元測試才能解決這些外部依賴呢?
最直接的辦法,就是啓動這個 Web Server,然後在單元測試代碼中對 POST /users
接口發送一個 HTTP 請求,之後判斷程序的 HTTP 響應結果以及 users
變量中的數據,來驗證 CreateUserHandler
函數的正確性。
但這種做法顯然超出了單元測試的範疇,更像是在做集成測試。單元測試的一個主要特徵就是要隔離外部依賴,使用測試替身
來替換依賴。
所以,我們應該想辦法來製作測試替身
。
我們先從最簡單的 users
變量開始,想辦法在測試過程中替換掉 users
。
users
僅是一個切片變量,用來保存用戶數據,我們可以編寫一個函數,將其內容替換成測試數據,代碼如下:
func setupTestUser() func() {
defaultUsers := users
users = []User{
{ID: 1, Name: "test-user1"},
}
return func() {
users = defaultUsers
}
}
setupTestUser
函數內部爲全局變量 users
進行了重新賦值,並返回一個匿名函數,這個匿名函數可以將 users
變量值恢復。
在測試期間可以這樣使用:
func TestCreateUserHandler(t *testing.T) {
cleanup := setupTestUser()
defer cleanup()
...
}
在測試最開始時調用 setupTestUser
來初始化測試數據,使用 defer
語句實現測試函數退出時恢復 users
數據。
接下來,我們需要構造一個表示 HTTP 響應的 http.ResponseWriter
。
幸運的是,這並不需要費多少力氣,Go 語言官方早就想到了這個訴求,爲我們提供了 net/http/httptest
標準庫,這個庫實現了一些專門用來進行網絡測試的實用工具。
構造一個測試用的 HTTP 響應對象僅需一行代碼就能完成:
w := httptest.NewRecorder()
得到的 w
變量實現了 http.ResponseWriter
接口,可以直接傳遞給 Handler 函數。
要想構造一個表示 HTTP 請求的 *http.Request
對象,同樣非常簡單:
body := strings.NewReader(`{"name": "user2"}`)
req := httptest.NewRequest("POST", "/users", body)
使用 httptest.NewRequest
創建的 req
變量正是 *http.Request
類型,它包含了請求方法、路徑、請求體。
現在,我們只差一個用來記錄 HTTP 請求路由參數的 httprouter.Params
類型對象沒有構造了。
httprouter.Params
是由 httprouter
這個第三方包提供的,httprouter
是一個高性能的 HTTP 路由,兼容 net/http
標準庫。
它提供了 (*httprouter.Router).ServeHTTP
方法,可以調用請求對應的 Handler 函數。即可以根據請求對象 *http.Request
,自動調用 CreateUserHandler
函數。
在調用 Handler 函數時,httprouter
會解析請求中的路由參數保存在 httprouter.Params
對象中並傳給 Handler,所以這個對象無需我們手動構造。
現在,單元測試函數的邏輯就清晰了:
func TestCreateUserHandler(t *testing.T) {
cleanup := setupTestUser()
defer cleanup()
w := httptest.NewRecorder()
body := strings.NewReader(`{"name": "user2"}`)
req := httptest.NewRequest("POST", "/users", body)
router := setupRouter()
router.ServeHTTP(w, req)
}
根據前文的講解,我們構造了單元測試所需的依賴項。
setupRouter()
返回 *httprouter.Router
對象,當代碼執行到 router.ServeHTTP(w, req)
時,就會根據傳遞的 req
參數,自動調用與之匹配的 Handler,即被測試函數 CreateUserHandler
。
接下來,我們要做的就是判斷 CreateUserHandler
函數執行後的結果是否正確。
完整單元測試代碼如下:
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateUserHandler(t *testing.T) {
cleanup := setupTestUser()
defer cleanup()
w := httptest.NewRecorder()
body := strings.NewReader(`{"name": "user2"}`)
req := httptest.NewRequest("POST", "/users", body)
router := setupRouter()
router.ServeHTTP(w, req)
assert.Equal(t, 201, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, "", w.Body.String())
assert.Equal(t, 2, len(users))
u2, _ := json.Marshal(users[1])
assert.Equal(t, `{"id":2,"name":"user2"}`, string(u2))
}
這裏引入了第三方包 testify 用來進行斷言操作,assert.Equal
能夠判斷兩個對象是否相等,這可以簡化代碼,不再需要使用 if
來判斷了。更多關於 testify
包的使用,可以查看官方文檔。
我們首先斷言了響應狀態碼是否爲 201
。
接着又斷言了響應頭的 Content-Type
字段是否爲 application/json
。
然後判斷了響應內容是否爲空。
最後,通過 users
中的值來判斷用戶信息是否保存正確。
使用 go test
來執行測試函數:
$ go test -v -run="TestCreateUserHandler" .
=== RUN TestCreateUserHandler
--- PASS: TestCreateUserHandler (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/http/server 0.544s
測試通過。
至此,我們成功爲 CreateUserHandler
函數編寫了一個單元測試。
不過,這個單元測試僅覆蓋了正常邏輯,CreateUserHandler
方法返回 400
和 500
兩種狀態碼的邏輯沒有被測試覆蓋,這兩種場景就留做作業你自己來完成吧。
接下來,我們再爲獲取用戶信息的函數 GetUserHandler
編寫一個單元測試。
先來看下 GetUserHandler
函數的定義:
func GetUserHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
userID, _ := strconv.Atoi(ps[0].Value)
w.Header().Set("Content-Type", "application/json")
for _, u := range users {
if u.ID == userID {
user, _ := json.Marshal(u)
_, _ = w.Write(user)
return
}
}
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"msg":"notfound"}`))
}
獲取用戶信息的邏輯,相對簡單一點。
首先從 HTTP 請求的路徑參數中獲取用戶 ID。
然後判斷這個 ID 對應的用戶信息是否存在,如果存在就返回用戶信息。
不存在,則寫入 404
狀態碼,並返回 notfound
信息。
有了前文對 CreateUserHandler
函數編寫測試的經驗,想必如何對 GetUserHandler
函數進行測試你已經輕車熟路了。
以下是我爲其編寫的測試代碼:
func TestGetUserHandler(t *testing.T) {
cleanup := setupTestUser()
defer cleanup()
type want struct {
code int
body string
}
tests := []struct {
name string
args int
want want
}{
{
name: "get test-user1",
args: 1,
want: want{
code: 200,
body: `{"id":1,"name":"test-user1"}`,
},
},
{
name: "get user not found",
args: 2,
want: want{
code: 404,
body: `{"msg":"notfound"}`,
},
},
}
router := setupRouter()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", fmt.Sprintf("/users/%d", tt.args), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.want.code, w.Code)
assert.Equal(t, tt.want.body, w.Body.String())
})
}
}
獲取用戶信息的單元測試代碼,在測試執行開始,同樣使用 setupTestUser
函數來初始化測試數據,並使用 defer
來完成數據恢復。
這次爲了提高測試覆蓋率,我對 GetUserHandler
函數的正常響應以及返回 404
狀態碼的異常響應場景都進行了測試。
這裏使用了表格測試,不瞭解表格測試的讀者,可以查看我的另一篇文章《在 Go 中如何編寫測試代碼》。
除了使用表格測試的形式,其他測試邏輯與 CreateUserHandler
的單元測試邏輯基本相同,我就不過多介紹了。
使用 go test
來執行測試函數:
$ go test -v -run="TestGetUserHandler" .
=== RUN TestGetUserHandler
=== RUN TestGetUserHandler/get_test-user1
=== RUN TestGetUserHandler/get_user_not_found
--- PASS: TestGetUserHandler (0.00s)
--- PASS: TestGetUserHandler/get_test-user1 (0.00s)
--- PASS: TestGetUserHandler/get_user_not_found (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/http/server 0.516s
表格測試的兩個用例都通過了測試。
HTTP Client 測試
接下來,我們來看下,站在 HTTP Client 端的角度,如何編寫應用程序的測試代碼。
假設我們有一個進程監控程序,能夠檢測某個進程是否正在執行,如果進程退出,就發送一條消息通知到飛書羣。
代碼如下:
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"syscall"
"time"
)
func monitor(pid int) (*Result, error) {
for {
// 檢查進程是否存在
err := syscall.Kill(pid, 0)
if err != nil {
log.Printf("Process %d exited\n", pid)
webhook := os.Getenv("WEBHOOK")
return sendFeishu(fmt.Sprintf("Process %d exited", pid), webhook)
}
log.Printf("Process %d is running\n", pid)
time.Sleep(1 * time.Second)
}
}
func main() {
if len(os.Args) != 2 {
log.Println("Usage: ./monitor <pid>")
return
}
pid, err := strconv.Atoi(os.Args[1])
if err != nil {
log.Printf("Invalid pid: %s\n", os.Args[1])
return
}
result, err := monitor(pid)
if err != nil {
log.Fatal(err)
}
log.Println(result)
}
這個程序可以通過 ./monitor <pid>
形式啓動。
monitor
函數內部有一個循環,會根據傳遞進來的進程 PID 不斷的來檢測對應進程是否存在。
如果不存在,則說明進程已經停止,然後調用 sendFeishu
函數發送消息通知到指定的飛書 webhook
地址。
monitor
函數會將 sendFeishu
函數的返回結果原樣返回。
sendFeishu
函數實現如下:
type Message struct {
Content struct {
Text string `json:"text"`
} `json:"content"`
MsgType string `json:"msg_type"`
}
type Result struct {
StatusCode int `json:"StatusCode"`
StatusMessage string `json:"StatusMessage"`
Code int `json:"code"`
Data any `json:"data"`
Msg string `json:"msg"`
}
func sendFeishu(content, webhook string) (*Result, error) {
msg := Message{
Content: struct {
Text string `json:"text"`
}{
Text: content,
},
MsgType: "text",
}
body, _ := json.Marshal(msg)
resp, err := http.Post(webhook, "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
result := new(Result)
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return nil, err
}
if result.Code != 0 {
return nil, fmt.Errorf("code: %d, error: %s", result.Code, result.Msg)
}
return result, nil
}
sendFeishu
函數能夠將傳遞進來的消息發送到指定的 webhook
地址。
至於內部具體邏輯,我們並不需要關心,只當作第三方包來使用即可,僅需要知道它最終會返回 *Result
對象。
現在我們需要對 monitor
函數進行測試。
我們同樣需要先分析下 monitor
函數的外部依賴是什麼。
首先 monitor
函數的參數 pid
是一個 int
類型,不難構造。
monitor
函數內部調用了 sendFeishu
函數,並且將 sendFeishu
的返回結果原樣返回,所以 sendFeishu
函數是一個外部依賴。
另外,傳遞個給 sendFeishu
函數的 webhook
地址是從環境變量中獲取的,這也算是一個外部依賴。
所以要測試 monitor
函數,我們需要使用測試替身
來解決這兩個外部依賴項。
對於環境變量的依賴很好解決,Go 提供了 os.Setenv
可以在程序中動態設置環境變量的值。
對於另一個依賴項 sendFeishu
函數,它又依賴了 webhook
地址所對應的 HTTP Server。
所以我們需要解決 HTTP Server 的依賴問題。
針對 HTTP Server,Go 標準庫 net/http/httptest
同樣提供了對應工具。
我們可以使用 httptest.NewServer
創建一個測試用的 HTTP Server:
func newTestServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.RequestURI {
case "/success":
_, _ = fmt.Fprintf(w, `{"StatusCode":0,"StatusMessage":"success","code":0,"data":{},"msg":"success"}`)
case "/error":
_, _ = fmt.Fprintf(w, `{"code":19001,"data":{},"msg":"param invalid: incoming webhook access token invalid"}`)
}
}))
}
newTestServer
函數返回一個用於測試的 HTTP Server 對象。
在 newTestServer
函數內部,定義了兩個路由 /success
和 /error
,分別來處理成功響應和失敗響應兩種情況。
與前文介紹的 setupTestUser
函數一樣,我們需要在測試程序開始執行時準備測試數據,即啓動這個測試用的 HTTP Server,在測試程序執行完成後清理數據,即關閉 HTTP Server。
不過,這次我們不再使用 setupTestUser
函數結合 defer cleanup()
的方式,而是換種方式來實現:
var ts *httptest.Server
func TestMain(m *testing.M) {
ts = newTestServer()
m.Run()
ts.Close()
}
首先我們定義了一個全局變量 ts
,用來保存測試用的 HTTP Server 對象。
然後在 TestMain
函數中調用 newTestServer
函數爲 ts
變量賦值。
接下來執行 m.Run()
方法。
最終調用 ts.Close()
關閉 HTTP Server。
TestMain
函數名不是隨意取的,而是 Go 單元測試中的一個約定名稱,它相當於 main
函數,在使用 go test
命令執行所有測試用例前,會優先執行 TestMain
函數。
在 TestMain
函數中調用 m.Run()
,(*testing.M).Run()
方法會執行全部的測試用例。
當所有測試用例執行完成後,代碼纔會執行到 ts.Close()
。
所以,相較於 setupTestUser
函數在每個測試函數內部都要調用一次的用法,TestMain
函數更加省力。不過這也決定了二者適用場景不同。TestMain
函數粒度更大,作用於全部測試用例,setupTestUser
函數只作用於單個測試函數。
現在,我們已經解決了 monitor
函數的依賴項問題。
爲其編寫的單元測試如下:
func Test_monitor(t *testing.T) {
type args struct {
pid int
webhook string
}
tests := []struct {
name string
args args
want *Result
wantErr error
}{
{
name: "process exited and send feishu success",
args: args{
pid: 10000000,
webhook: ts.URL + "/success",
},
want: &Result{
StatusCode: 0,
StatusMessage: "success",
Code: 0,
Data: make(map[string]interface{}),
Msg: "success",
},
},
{
name: "process exited and send feishu error",
args: args{
pid: 20000000,
webhook: ts.URL + "/error",
},
wantErr: errors.New("code: 19001, error: param invalid: incoming webhook access token invalid"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = os.Setenv("WEBHOOK", tt.args.webhook)
got, err := monitor(tt.args.pid)
if err != nil {
if tt.wantErr == nil || err.Error() != tt.wantErr.Error() {
t.Errorf("monitor() error = %v, wantErr %v", err, tt.wantErr)
return
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("monitor() got = %v, want %v", got, tt.want)
}
})
}
}
這裏同樣採用表格測試的方式,有兩個測試用例,一個用於測試被檢測程序退出後發送飛書消息成功的情況,一個用於測試被檢測程序退出後發送飛書消息失敗的情況。
測試用例中 pid
被設置爲很大的值,已經超過了 Linux 系統允許的最大 pid
值,所以檢測結果一定是程序已經退出。
由於被檢測程序不退出的情況,monitor
函數會一直循環檢測,邏輯比較簡單,就沒有對這個邏輯編寫測試用例。
使用 go test
來執行測試函數:
$ go test -v -run="^Test_monitor$" .
=== RUN Test_monitor
=== RUN Test_monitor/process_exited_and_send_feishu_success
2023/07/15 13:27:46 Process 10000000 exited
=== RUN Test_monitor/process_exited_and_send_feishu_error
2023/07/15 13:27:46 Process 20000000 exited
--- PASS: Test_monitor (0.00s)
--- PASS: Test_monitor/process_exited_and_send_feishu_success (0.00s)
--- PASS: Test_monitor/process_exited_and_send_feishu_error (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/http/client 0.166s
測試通過。
以上,我們通過 net/http/httptest
提供的測試工具,在本地啓動了一個測試 HTTP Server,來解決被測試代碼依賴外部 HTTP 服務的問題。
有時候,我們不想真正的在本地啓動一個 HTTP Server,或者無法做到這一點。
那麼,我們還有另一種方案來解決這個問題,可以使用 gock
來模擬 HTTP 服務。
gock
是 Go 社區中的一個第三方包,雖然不在本地啓動一個 HTTP Server,但是它能夠攔截所有被 mock 的 HTTP 請求。所以,我們能夠利用 gock
攔截 sendFeishu
函數發送給 webhook
地址的請求,然後返回 mock 數據。這樣,就可以使用 mock 的方式來解決依賴外部 HTTP 服務的問題。
使用 gock
編寫的單元測試代碼如下:
package main
import (
"os"
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
)
func Test_monitor_by_gock(t *testing.T) {
defer gock.Off() // Flush pending mocks after test execution
gock.New("http://localhost:8080").
Post("/webhook").
Reply(200).
JSON(map[string]interface{}{
"StatusCode": 0,
"StatusMessage": "success",
"Code": 0,
"Data": make(map[string]interface{}),
"Msg": "success",
})
_ = os.Setenv("WEBHOOK", "http://localhost:8080/webhook")
got, err := monitor(30000000)
assert.NoError(t, err)
assert.Equal(t, &Result{
StatusCode: 0,
StatusMessage: "success",
Code: 0,
Data: make(map[string]interface{}),
Msg: "success",
}, got)
assert.True(t, gock.IsDone())
}
首先,在測試函數的開始,使用 defer
延遲調用 gock.Off()
,可以保證在測試完成後刷新掛起的 mock,即還原被 mock 對象的初始狀態。
然後,我們使用 gock.New()
對 http://localhost:8080
這個 URL 進行 mock,這樣 gock
會攔截測試過程中所有發送到這個地址的 HTTP 請求。
gock.New()
支持鏈式調用,.Post("/webhook")
表示攔截對 /webhook
這個 URL 的 POST 請求。
.Reply(200)
表示針對這個請求,返回 200
狀態碼。
.JSON(...)
即爲返回的 JSON 格式響應內容。
接着,我們將 webhook
地址設置爲 http://localhost:8080/webhook
,這樣,在調用 sendFeishu
函數時發送的請求就會被攔截,並返回上一步中的 .JSON(...)
內容。
之後就是調用 monitor
函數,並斷言測試結果是否正確。
最後,調用 assert.True(t, gock.IsDone())
來驗證已經沒有掛起的 mock 了。
使用 go test
來執行測試函數:
$ go test -v -run="^Test_monitor_by_gock$" .
=== RUN Test_monitor_by_gock
2023/07/15 13:28:22 Process 30000000 exited
--- PASS: Test_monitor_by_gock (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/http/client 0.574s
單元測試執行通過。
總結
本文向大家介紹了在 Go 中編寫單元測試時,如何解決 HTTP 外部依賴的問題。
我們分別站在 HTTP 服務端和 HTTP 客戶端兩個角度,使用 net/http/httptest
標準庫和 gock
第三方庫來實現測試替身
解決 HTTP 外部依賴。
並且分別介紹了使用 setupTestUser
+ defer cleanup()
以及 TestMain
兩種形式,來做測試準備和清理工作。二者作用於不同粒度,需要根據測試需要進行選擇。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
參考
-
Go testing 文檔:https://pkg.go.dev/net/http/httptest
-
Testify 源碼:https://github.com/stretchr/testify
-
gock 源碼:https://github.com/h2non/gock
-
本文 GitHub 源碼:https://github.com/jianghushinian/blog-go-example/tree/main/test/http
聯繫我
微信:jianghushinian
郵箱:jianghushinian007@outlook.com
博客地址:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/2TigdD-Il1IuYpDSme2n4w