WebSocket integration test 的核心目標是驗證 client 與 server 透過真實連線互動後,協定行為是否正確。它比單元測試慢,但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。

本章目標

學完本章後,你將能夠:

  1. httptest.Server 建立真實 WebSocket 測試入口
  2. http:// 測試 URL 轉成 ws://
  3. deadline 避免 read/write 永久卡住
  4. 驗證 subscribe、push、error response 與 cleanup
  5. 分辨 integration test 與 unit test 的責任邊界

【觀察】WebSocket 的錯誤常出現在元件交界

WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過,但真實連線仍可能因為 upgrade path、read pump、write pump、send buffer 或 unregister 流程出錯。

Integration test 適合驗證這些交界:

  • client 能否成功 dial 到 /ws
  • server 是否接受 client action
  • subscribe 後是否收到 acknowledgement
  • server broadcast 是否能推到 client
  • client 關閉後 hub 是否清理連線
  • 錯誤 action 是否回 error message 而不是斷線

這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。

【判讀】integration test 補的是協作信心

Integration test 的核心責任是覆蓋協定流程,不是取代所有規則測試。Router validation、topic normalization、dedup key、state transition 應主要用單元測試;WebSocket integration test 只挑關鍵端到端流程。

建議分工:

測試類型負責內容
unit testrouter、payload validation、subscription state、TrySend
integration testdial、upgrade、read/write pump、server response、cleanup
race testhub、client state、repository 的並發存取

如果每個 validation case 都啟動 WebSocket server,測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。

【執行】用 httptest.Server 建立真實入口

WebSocket integration test 的核心起點是 httptest.Server。它提供真實 HTTP server,不需要手動管理 port。

 1func TestWebSocketSubscribe(t *testing.T) {
 2    server := httptest.NewServer(newRouter())
 3    t.Cleanup(server.Close)
 4
 5    wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
 6
 7    conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
 8    if err != nil {
 9        t.Fatalf("dial websocket: %v", err)
10    }
11    t.Cleanup(func() {
12        _ = conn.Close()
13    })
14}

httptest.NewServer 產生的是 http://127.0.0.1:port,WebSocket client 需要 ws://127.0.0.1:port/ws,所以常用字串轉換。

若 handler 需要 hub、router、fake repository,應在測試中明確組裝。這讓 integration test 的依賴可控。

【策略】測試 helper 應封裝連線樣板

Integration test 的核心樣板很多:建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複,但不要把協定斷言藏起來。

 1func newTestWebSocket(t *testing.T, handler http.Handler) (*websocket.Conn, *httptest.Server) {
 2    t.Helper()
 3
 4    server := httptest.NewServer(handler)
 5    t.Cleanup(server.Close)
 6
 7    wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
 8    conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
 9    if err != nil {
10        t.Fatalf("dial websocket: %v", err)
11    }
12    t.Cleanup(func() {
13        _ = conn.Close()
14    })
15
16    return conn, server
17}

Helper 負責重複 setup。測試本文仍應清楚寫出「送什麼 message、期待什麼 response」。

【執行】action 測試要檢查協定語意

Action 測試的核心流程是送 client message、讀 server message、檢查協定欄位。

 1func TestSubscribeActionReturnsAcknowledgement(t *testing.T) {
 2    conn, _ := newTestWebSocket(t, newRouter())
 3
 4    request := ClientMessage{
 5        Action: ActionSubscribeTopic,
 6        Data: mustJSON(t, SubscribeTopicRequest{
 7            Topic: "alerts",
 8        }),
 9    }
10
11    if err := conn.WriteJSON(request); err != nil {
12        t.Fatalf("write subscribe: %v", err)
13    }
14
15    response := readServerMessage(t, conn)
16    if response.Type != "topic_subscribed" {
17        t.Fatalf("response type = %q, want topic_subscribed", response.Type)
18    }
19    if response.Topic != "alerts" {
20        t.Fatalf("response topic = %q, want alerts", response.Topic)
21    }
22}

這個測試檢查的是協定語意,不只是連線沒有斷。Subscribe 的成功條件是 server 明確回覆訂閱成功。

【執行】每次讀取前設定 deadline

WebSocket integration test 的核心風險是永久卡住。每次等待 server message 前,都應設定 read deadline。

 1func readServerMessage(t *testing.T, conn *websocket.Conn) ServerMessage {
 2    t.Helper()
 3
 4    if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
 5        t.Fatalf("set read deadline: %v", err)
 6    }
 7
 8    var response ServerMessage
 9    if err := conn.ReadJSON(&response); err != nil {
10        t.Fatalf("read server message: %v", err)
11    }
12    return response
13}

Deadline 是測試保護。若 server 沒有送出預期訊息,測試會在合理時間內失敗,而不是卡住整個測試套件。

Timeout 不應過短。CI 可能比本機慢,測試應給足合理緩衝,但仍要能快速暴露失敗。

【執行】推送測試要先建立可觀察觸發點

Server push 的核心測試流程是先讓 client 訂閱 topic,再從 server 端觸發 broadcast,最後讀取 client 收到的 message。

 1func TestSubscribedClientReceivesBroadcast(t *testing.T) {
 2    hub := NewHub()
 3    go hub.Run()
 4
 5    conn, _ := newTestWebSocket(t, newRouterWithHub(hub))
 6
 7    writeClientMessage(t, conn, ClientMessage{
 8        Action: ActionSubscribeTopic,
 9        Data:   mustJSON(t, SubscribeTopicRequest{Topic: "alerts"}),
10    })
11    _ = readServerMessage(t, conn)
12
13    hub.Broadcast("alerts", ServerMessage{
14        Type:  "notification",
15        Topic: "alerts",
16    })
17
18    pushed := readServerMessage(t, conn)
19    if pushed.Type != "notification" {
20        t.Fatalf("pushed type = %q, want notification", pushed.Type)
21    }
22}

這個測試證明 subscribe state、hub broadcast、write pump 能透過真實 connection 協作。若只想測 Broadcast 是否檢查 topic,應寫 hub unit test,不必走 WebSocket。

【策略】非同步清理用 eventually,不用固定 sleep

連線清理測試的核心問題是 cleanup 通常非同步發生。測試應等待可觀察條件,而不是固定 sleep。

 1func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
 2    t.Helper()
 3
 4    deadline := time.Now().Add(timeout)
 5    for time.Now().Before(deadline) {
 6        if condition() {
 7            return
 8        }
 9        time.Sleep(10 * time.Millisecond)
10    }
11
12    t.Fatalf("condition was not met within %s", timeout)
13}

使用方式:

 1func TestClientIsRemovedAfterClose(t *testing.T) {
 2    hub := NewHub()
 3    conn, _ := newTestWebSocket(t, newRouterWithHub(hub))
 4
 5    eventually(t, time.Second, func() bool {
 6        return hub.ClientCount() == 1
 7    })
 8
 9    _ = conn.Close()
10
11    eventually(t, time.Second, func() bool {
12        return hub.ClientCount() == 0
13    })
14}

eventually 不是任意等待;它等待具體條件。失敗時,測試會指出 cleanup 沒發生,而不是把時間耗掉後仍然不清楚原因。

【判讀】error action 應測協定,不只測 log

WebSocket action 失敗的核心語意是單次 action 失敗,不一定代表連線失敗。Integration test 應確認 server 回 error message,並且連線仍可繼續使用。

 1func TestUnknownActionReturnsErrorMessage(t *testing.T) {
 2    conn, _ := newTestWebSocket(t, newRouter())
 3
 4    writeClientMessage(t, conn, ClientMessage{
 5        Action: "unknown_action",
 6    })
 7
 8    response := readServerMessage(t, conn)
 9    if response.Type != "error" {
10        t.Fatalf("response type = %q, want error", response.Type)
11    }
12}

若設計上 unknown action 應直接關閉連線,也應明確測出 close 行為。重點是協定行為要可驗證,不要只依賴 server log

本章不處理

本章先處理單一 Go server 內的 WebSocket 協定協作;跨節點 fan-out 與壓力測試,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 WebSocket handler、pump 與 heartbeat;如果你要先回看語言教材,可以讀:

小結

WebSocket integration test 應少量覆蓋關鍵端到端協定:dial、送 action、收 response、server push、錯誤回應與 cleanup。測試要使用真實 httptest.Server,每次 read 前設定 deadline,等待非同步清理時使用 eventually。單元測試負責大量規則,integration test 負責證明真實連線能把規則串起來。