Go で HTTP リクエストをテストする - httptest パッケージの使い方
HTTP ハンドラを書いたら、それが正しく動くかテストしたい。ブラウザや curl で手動確認するのは手軽だが、コードを変更するたびに同じ操作を繰り返すのは現実的ではない。Go の標準ライブラリには net/http/httptest パッケージが含まれており、HTTP ハンドラのテストをコードで自動化できる。
httptest が提供する 2 つのアプローチ
httptest パッケージには、HTTP ハンドラをテストするための道具が 2 系統ある。
実際のサーバーを起動せずに、ハンドラ関数を直接呼び出してレスポンスを検証する。単体テスト向き。
ローカルにテスト用 HTTP サーバーを起動し、実際の HTTP 通信を通じてテストする。結合テスト向き。
多くの場合は NewRecorder で十分だ。ネットワークを介さないため高速で、テストの依存関係も少ない。外部サービスとの通信をモックしたい場合や、ミドルウェアのチェーン全体を通したテストが必要な場合には NewServer が役立つ。
ResponseRecorder でハンドラを直接テストする
httptest.NewRecorder は http.ResponseWriter インターフェースを実装した構造体を返す。これをハンドラに渡せば、ハンドラが書き込んだステータスコード、ヘッダー、ボディをすべて検証できる。
まず、テスト対象のハンドラを用意する。
// handler.go
package app
import (
"encoding/json"
"net/http"
)
type HealthResponse struct {
Status string `json:"status"`
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(HealthResponse{Status: "ok"})
}このハンドラに対するテストは以下のように書ける。
// handler_test.go
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
// ステータスコードの検証
if rec.Code != http.StatusOK {
t.Errorf("ステータスコード: got %d, want %d", rec.Code, http.StatusOK)
}
// Content-Type の検証
ct := rec.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("Content-Type: got %s, want application/json", ct)
}
// ボディの検証
var body HealthResponse
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("レスポンスのパースに失敗: %v", err)
}
if body.Status != "ok" {
t.Errorf("status: got %s, want ok", body.Status)
}
}httptest.NewRequest はテスト用の *http.Request を生成する。第 1 引数が HTTP メソッド、第 2 引数がパス、第 3 引数がリクエストボディだ。httptest.NewRecorder が返す *httptest.ResponseRecorder は、Code フィールドでステータスコード、Body フィールドでレスポンスボディ、Header() メソッドでレスポンスヘッダーにアクセスできる。
テストの実行は通常の go test コマンドでよい。
go test -v ./...POST リクエストをテストする
リクエストボディを伴う POST ハンドラのテストでは、httptest.NewRequest の第 3 引数に io.Reader を渡す。
// handler.go
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "不正なリクエスト", http.StatusBadRequest)
return
}
if req.Name == "" || req.Email == "" {
http.Error(w, "name と email は必須です", http.StatusBadRequest)
return
}
resp := CreateUserResponse{ID: 1, Name: req.Name, Email: req.Email}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}テスト側では strings.NewReader や bytes.NewBuffer でリクエストボディを作る。
func TestCreateUserHandler(t *testing.T) {
body := `{"name":"Alice","email":"alice@example.com"}`
req := httptest.NewRequest("POST", "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("ステータスコード: got %d, want %d", rec.Code, http.StatusCreated)
}
var resp CreateUserResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Name != "Alice" {
t.Errorf("Name: got %s, want Alice", resp.Name)
}
if resp.Email != "alice@example.com" {
t.Errorf("Email: got %s, want alice@example.com", resp.Email)
}
}ヘッダーの設定を忘れがちだが、Content-Type: application/json を付けておかないと、サーバー側でリクエストの Content-Type を検証している場合にテストが失敗する。
テーブル駆動テストで網羅的に検証する
Go のテストでは、テーブル駆動テスト(table-driven test)のパターンがよく使われる。複数のケースを構造体のスライスで定義し、ループで回すスタイルだ。HTTP ハンドラのテストでもこのパターンは非常に相性がよい。
func TestCreateUserHandler_Validation(t *testing.T) {
tests := []struct {
name string
method string
body string
wantStatus int
}{
{
name: "正常系",
method: "POST",
body: `{"name":"Bob","email":"bob@example.com"}`,
wantStatus: http.StatusCreated,
},
{
name: "名前が空",
method: "POST",
body: `{"name":"","email":"bob@example.com"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "不正な JSON",
method: "POST",
body: `{invalid}`,
wantStatus: http.StatusBadRequest,
},
{
name: "GET メソッド",
method: "GET",
body: "",
wantStatus: http.StatusMethodNotAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bodyReader io.Reader
if tt.body != "" {
bodyReader = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, "/users", bodyReader)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("ステータスコード: got %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}t.Run を使うと各サブテストに名前が付き、失敗時にどのケースが落ちたかすぐにわかる。go test -v の出力にも名前が表示されるため、デバッグが格段に楽になる。
=== RUN TestCreateUserHandler_Validation
=== RUN TestCreateUserHandler_Validation/正常系
=== RUN TestCreateUserHandler_Validation/名前が空
=== RUN TestCreateUserHandler_Validation/不正なJSON
=== RUN TestCreateUserHandler_Validation/GETメソッド
--- PASS: TestCreateUserHandler_Validation (0.00s)httptest.NewServer でテストサーバーを起動する
httptest.NewServer は、ローカルのランダムポートで実際の HTTP サーバーを起動する。ハンドラの単体テストではなく、HTTP クライアント側のコードをテストしたい場合に威力を発揮する。
たとえば、外部 API を呼び出す関数をテストする場面を考えよう。
// client.go
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func FetchUser(baseURL string, id int) (*User, error) {
resp, err := http.Get(fmt.Sprintf("%s/users/%d", baseURL, id))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}テストでは httptest.NewServer を使って、外部 API の代わりとなるモックサーバーを立てる。
func TestFetchUser(t *testing.T) {
// モックサーバーを起動
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/42" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(User{ID: 42, Name: "Charlie"})
}))
defer ts.Close()
// モックサーバーの URL を渡してテスト
user, err := FetchUser(ts.URL, 42)
if err != nil {
t.Fatalf("FetchUser failed: %v", err)
}
if user.ID != 42 {
t.Errorf("ID: got %d, want 42", user.ID)
}
if user.Name != "Charlie" {
t.Errorf("Name: got %s, want Charlie", user.Name)
}
}ts.URL にはサーバーのアドレスが http://127.0.0.1:PORT の形式で入っている。テスト対象の関数にベースURL を外から注入できるように設計しておくと、このパターンが使えるようになる。defer ts.Close() でテスト終了時にサーバーを確実に停止させることも忘れないようにしたい。
ミドルウェアをテストする
ミドルウェアのテストにも ResponseRecorder は使える。ミドルウェアでハンドラをラップし、ラップ後のハンドラをテストすればよい。
// middleware.go
func RequireJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if ct != "application/json" {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
next.ServeHTTP(w, r)
})
}func TestRequireJSON(t *testing.T) {
// ダミーハンドラ
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := RequireJSON(inner)
t.Run("JSON ヘッダーあり", func(t *testing.T) {
req := httptest.NewRequest("POST", "/", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got %d, want %d", rec.Code, http.StatusOK)
}
})
t.Run("JSON ヘッダーなし", func(t *testing.T) {
req := httptest.NewRequest("POST", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("got %d, want %d", rec.Code, http.StatusUnsupportedMediaType)
}
})
}ミドルウェアは http.Handler を受け取って http.Handler を返す関数だ。テスト時には内側のハンドラとして何もしないダミーを渡しておけば、ミドルウェア自体のロジックだけを検証できる。ミドルウェアがリクエストを通過させた場合はダミーハンドラの StatusOK が記録され、ブロックした場合はミドルウェアが書き込んだステータスコードが記録される。
NewRecorder と NewServer の使い分け
どちらのアプローチを選ぶかは、テストの目的によって変わる。
ハンドラ関数のロジックを検証したいとき。ステータスコード、レスポンスボディ、ヘッダーの確認。ミドルウェア単体のテスト。ネットワークを介さないため実行が速く、CI でも安定する。
HTTP クライアント側のコードをテストしたいとき。外部 API のモック。リダイレクトや Cookie の挙動など、実際の HTTP 通信を通じてしか確認できない振る舞いの検証。TLS を使ったテスト(httptest.NewTLSServer)。
基本方針としては、まず NewRecorder で書けるかを検討し、それでは不十分な場合にのみ NewServer を使うのがよいだろう。テストは速ければ速いほどよく、依存が少なければ少ないほど壊れにくい。httptest パッケージはこの 2 つのアプローチを標準ライブラリとして提供しており、外部のテストフレームワークに頼らなくても Go の HTTP コードを十分にテストできる。