Go で HTTP リクエストをテストする - httptest パッケージの使い方

HTTP ハンドラを書いたら、それが正しく動くかテストしたい。ブラウザや curl で手動確認するのは手軽だが、コードを変更するたびに同じ操作を繰り返すのは現実的ではない。Go の標準ライブラリには net/http/httptest パッケージが含まれており、HTTP ハンドラのテストをコードで自動化できる。

httptest が提供する 2 つのアプローチ

httptest パッケージには、HTTP ハンドラをテストするための道具が 2 系統ある。

httptest.NewRecorder

実際のサーバーを起動せずに、ハンドラ関数を直接呼び出してレスポンスを検証する。単体テスト向き。

httptest.NewServer

ローカルにテスト用 HTTP サーバーを起動し、実際の HTTP 通信を通じてテストする。結合テスト向き。

多くの場合は NewRecorder で十分だ。ネットワークを介さないため高速で、テストの依存関係も少ない。外部サービスとの通信をモックしたい場合や、ミドルウェアのチェーン全体を通したテストが必要な場合には NewServer が役立つ。

ResponseRecorder でハンドラを直接テストする

httptest.NewRecorderhttp.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.NewReaderbytes.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 の使い分け

どちらのアプローチを選ぶかは、テストの目的によって変わる。

NewRecorder を選ぶ場面

ハンドラ関数のロジックを検証したいとき。ステータスコード、レスポンスボディ、ヘッダーの確認。ミドルウェア単体のテスト。ネットワークを介さないため実行が速く、CI でも安定する。

NewServer を選ぶ場面

HTTP クライアント側のコードをテストしたいとき。外部 API のモック。リダイレクトや Cookie の挙動など、実際の HTTP 通信を通じてしか確認できない振る舞いの検証。TLS を使ったテスト(httptest.NewTLSServer)。

基本方針としては、まず NewRecorder で書けるかを検討し、それでは不十分な場合にのみ NewServer を使うのがよいだろう。テストは速ければ速いほどよく、依存が少なければ少ないほど壊れにくい。httptest パッケージはこの 2 つのアプローチを標準ライブラリとして提供しており、外部のテストフレームワークに頼らなくても Go の HTTP コードを十分にテストできる。