GoのRESTハンドラをテストする: httptestとテーブル駆動のチェック
httptestとテーブル駆動テストでGoのRESTハンドラを検証すると、認証、バリデーション、ステータスコード、エッジケースをリリース前に繰り返し確認できます。

リリース前に確信しておくべきこと
RESTハンドラはコンパイルできて、簡単な手動確認に合格しても、本番で失敗することがあります。多くの失敗は構文の問題ではなく、契約の問題です: ハンドラが拒否すべきものを受け入れてしまう、間違ったステータスコードを返す、あるいはエラーで内部情報を漏らすなどです。
手動テストは助けになりますが、エッジケースや回帰を見落としがちです。ハッピーパスを試して、明らかなエラーを一つ確認して終わりにしてしまうことがよくあります。するとバリデーションやミドルウェアの小さな変更が、安定だと想定していた挙動を静かに壊してしまいます。
ハンドラテストの目的はシンプルです: ハンドラの約束を再現可能にすること。認証ルール、入力バリデーション、予測可能なステータスコード、クライアントが依存できるエラーボディを含みます。
Goのhttptestパッケージは最適です。実際のサーバを起動せずにハンドラを直接実行できます。HTTPリクエストを作り、ハンドラに渡し、レスポンスのボディ、ヘッダ、ステータスコードを検査します。テストは高速で独立しており、コミットごとに簡単に実行できます。
リリース前に知っておくべきこと(願うのではなく知る)は:
- トークンがない場合、無効なトークン、権限不足での振る舞いが一貫していること
- 入力が検証されること: 必須フィールド、型、範囲、(厳格にするなら)未知フィールドの扱い
- ステータスコードが契約に合っていること(例: 401 と 403、400 と 422 の違い)
- エラー応答が安全かつ一貫していること(スタックトレースを漏らさない、形が毎回同じ)
- タイムアウト、下流の障害、空の結果など非ハッピーパスを扱っていること
「チケット作成」エンドポイントは、管理者が完全なJSONを送ると動作するかもしれません。テストは見落としがちなものを捕まえます: 期限切れトークン、クライアントが誤って送った余分なフィールド、負の優先度、依存が失敗したときの「見つからない」と「内部エラー」の違いなど。
各エンドポイントの契約を定義する
テストを書く前に、ハンドラが何を約束するかを書き出してください。明確な契約はテストの焦点を保ち、テストがコードの「意図」を推測するものになるのを防ぎます。リファクタ時にも内部を変えても挙動を維持できる安心感を与えます。
まず入力から始めます。各値がどこから来るのか、何が必須かを具体的に書きます。エンドポイントはパスのid、クエリのlimit、Authorizationヘッダ、JSONボディを受け取るかもしれません。許容フォーマット、最小/最大値、必須フィールド、欠落時の挙動など、重要なルールを明記してください。
次に出力を定義します。「JSONを返す」だけで終わらないでください。成功時の形、重要なヘッダ、エラー時の形を決めます。クライアントが安定したエラーコードや予測可能なJSON形状に依存しているなら、それも契約の一部として扱ってください。
実用的なチェックリストは:
- 入力: パス/クエリ値、必須ヘッダ、JSONフィールド、バリデーションルール
- 出力: ステータスコード、レスポンスヘッダ、成功/エラー時のJSON形
- 副作用: どのデータが変更され、何が作成されるか
- 依存関係: DB呼び出し、外部サービス、現在時刻、生成されるID
またハンドラテストがどこまで見るかを決めてください。ハンドラテストはHTTP境界で最も強力です: 認証、パース、バリデーション、ステータスコード、エラーボディまで。実際のデータベースクエリやネットワーク呼び出し、完全なルーティングは統合テストに任せます。
バックエンドが生成される場合(例えばAppMasterはGoハンドラやビジネスロジックを生成する)、契約ファーストのアプローチはさらに有用です。コードを再生成しても各エンドポイントが同じ公開挙動を保つことを検証できます。
最小限のhttptestハーネスを準備する
良いハンドラテストは、実際のリクエストを送るように感じられるべきで、サーバを起動する必要はありません。Goでは通常、httptest.NewRequestでリクエストを作り、httptest.NewRecorderでレスポンスをキャプチャし、ハンドラを呼びます。
ハンドラを直接呼ぶとテストは高速かつ焦点が定まります。これは認証チェック、バリデーション、ステータスコード、エラーボディなどハンドラ内部の挙動を検証したいときに理想的です。ルータをテストで使うのは、契約がパスパラメータ、ルートマッチ、ミドルウェアの順序に依存する場合に限るとよいでしょう。まずは直接呼び、必要になったらルータを追加してください。
ヘッダは思ったより重要です。Content-Typeがないとハンドラのボディ読み取り方法が変わることがあります。失敗がロジックのせいではなくテストのセットアップミスを示すことがないよう、各ケースで期待するヘッダを設定してください。
再利用できる最小パターンの例:
req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
アサーションを一貫させるために、レスポンスボディを読み取りデコードする小さなヘルパーを1つ用意すると便利です。ほとんどのテストでは、まずステータスコードをチェック(失敗を素早く見つけるため)、次に約束した主要ヘッダ(多くはContent-Type)、最後にボディを確認します。
バックエンドが生成される場合でも(AppMasterで生成されたGoバックエンドを含む)、このハーネスは当てはまります。ユーザーが依存するHTTP契約をテストしているのであって、内部のコードスタイルをテストしているわけではありません。
読みやすさを保つテーブル駆動ケースの設計
テーブル駆動テストは、各ケースが小さな物語のように読めるときに最も効果的です: 送るリクエストと期待する応答。ファイルを飛び回らなくてもテーブルを眺めればカバレッジがわかるべきです。
良いケースは通常、わかりやすい名前、リクエスト(メソッド、パス、ヘッダ、ボディ)、期待するステータスコード、レスポンスに対するチェックを持ちます。JSONボディに対しては、契約が厳密でない限り完全な文字列一致よりも、エラーコードなどいくつかの安定したフィールドを検証する方が好ましいです。
再利用できる単純なケース構造
ケース構造体は焦点を絞ってください。一時的なセットアップはヘルパーに出してテーブルを小さく保ちます。
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // 部分文字列かコンパクトなJSON
}
異なる入力には、違いが一目でわかる小さなボディ文字列を使ってください: 有効なペイロード、フィールドが足りないもの、型が違うもの、空文字列など。テーブル内で大量のフォーマット済みJSONを組み立てるのはノイズになります。
トークン作成や共通ヘッダ、デフォルトボディなど繰り返し出るセットアップはnewRequest(tc)やbaseHeaders()のようなヘルパーに押し出してください。
1つのテーブルがあまりに多くの意味を混ぜ始めたら分割してください。成功パス用のテーブルとエラーパス用のテーブルに分けると読みやすく、デバッグもしやすいことが多いです。
認証チェック: 見落としがちなケース
認証テストはハッピーパスでは問題なく見えるのに、本番で失敗することがよくあります。認証を契約として扱ってください: クライアントが送るもの、サーバが返すもの、決して漏らしてはいけない情報。
まずはヘッダの有無と有効性から。保護されたエンドポイントは、ヘッダがない場合と存在するが不正な場合で振る舞いが異なるべきです。短命トークンを使うなら、有効期限切れもテストしてください。テストでは「期限切れ」と返すバリデータを注入してシミュレートできます。
よくカバーすべきケース:
Authorizationヘッダがない -> 401 と安定したエラー応答- 不正なヘッダ形式(プレフィックスが違う) -> 401
- 無効なトークン(署名不正) -> 401
- 期限切れトークン -> 401(またはチームで決めたコード)かつ予測可能なメッセージ
- 有効なトークンだが権限不足 -> 403
401 と 403 の使い分けは重要です。認証されていない呼び出しには401を、認証済みだが許可されていない場合には403を使ってください。これを曖昧にするとクライアントが不必要にリトライしたり、誤ったUIを表示したりします。
「ユーザー所有」エンドポイント(例: GET /orders/{id})ではロールチェックだけでは不十分です。所有権をテストしてください: ユーザーAがユーザーBの注文を見られてはいけません。これはきれいに403(または存在を隠すなら404)で、ボディに詳細を漏らしてはいけません。「注文はユーザー42のものだ」などのヒントを出さないでください。
入力ルール: 拒否し、分かりやすく説明する
リリース前のバグの多くは入力に関するものです: 必須フィールドの欠落、型違い、予期せぬフォーマット、またはペイロードの過大。
ハンドラが受け取るすべての入力を名前で挙げてください: JSONボディのフィールド、クエリパラメータ、パスパラメータ。それぞれについて、欠落、空、壊れた形式、範囲外のときに何が起きるかを決めます。バッド入力を早期に拒否し、毎回同じ種類のエラーを返すことを証明するケースを書いてください。
少数のバリデーションケースで大部分のリスクをカバーできます:
- 必須フィールド: 欠落 vs 空文字列 vs null(nullを許可するかどうか)
- 型とフォーマット: 数値と文字列、email/日付/UUIDの形式、ブールのパース
- サイズ制限: 最大長、最大アイテム数、ペイロード過大
- 未知フィールド: 無視するか拒否するか(厳格なデコードを行う場合)
- クエリ/パスパラメータ: 欠落、パース不能、デフォルト挙動
例: POST /users が { "email": "...", "age": 0 } を受け取る場合。emailがない、emailが123、emailが"not-an-email"、ageが-1、ageが"20"などをテストします。厳密なJSONを要求するなら{ "email": "[email protected]", "extra": "x" }が失敗することも確認してください。
バリデーション失敗を予測可能にしてください。バリデーションエラーに対してあるステータスコードを選び(400を使うチームや、422を使うチームがあります)、エラーボディの形を一貫させます。テストはステータスと、失敗した入力を指し示すメッセージ(またはdetailsフィールド)を両方アサートすべきです。
ステータスコードとエラーボディ: 予測可能にする
APIの失敗は地味で一貫しているとテストが楽になります。すべてのエラーが明確なステータスにマップされ、同じJSON形状を返すことを目指してください。
まずはエラー種類からステータスへの小さな合意マップを作ります:
- 400 Bad Request: 壊れたJSON、必須クエリパラメータの欠落
- 404 Not Found: リソースIDが存在しない
- 409 Conflict: 一意制約や状態の衝突
- 422 Unprocessable Entity: JSON自体は有効だがビジネスルールに違反
- 500 Internal Server Error: 予期しない障害(DBダウン、nil参照、外部サービス障害)
次にエラーボディを安定させます。メッセージテキストが後で変わっても、クライアントが頼れるフィールドを残します:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
テストでは形をアサートし、ステータスだけでなくボディの構造も確認してください。エラー時にHTMLやプレーンテキスト、空ボディを返すことは避けてください。クライアントを壊し、バグを隠します。
またエラー応答のヘッダやエンコーディングもテストしてください:
Content-Typeがapplication/jsonであること(charsetを設定するなら一貫させる)- エラー時でもボディは有効なJSONであること
code、message、detailsが存在すること(detailsは空でもよいがランダムであってはいけない)- panicや予期しないエラーはスタックトレースを漏らさない安全な500を返すこと
recoverミドルウェアを追加しているなら、panicを強制してクリーンなJSONエラー応答が返ることを1つのテストで確認してください。
エッジケース: 失敗、時間、不具合パス
ハッピーパスはハンドラが動くことを証明します。エッジケースのテストは世界が乱れているときにも期待通りに振る舞うことを証明します。
依存先が特定の、再現可能な方法で失敗することを強制してください。ハンドラがDB、キャッシュ、外部APIを呼ぶなら、それらのレイヤが制御不能なエラーを返したときに何が起きるかを確認したいはずです。
各エンドポイントについて少なくとも一度はシミュレートすべきもの:
- 下流呼び出しのタイムアウト(
context deadline exceeded) - ストレージからのNot Found(クライアントがデータを期待していた場合)
- 作成時の一意制約違反(重複メール、重複スラグ)
- ネットワークやトランスポートのエラー(接続拒否、broken pipe)
- 予期しない内部エラー(汎用の「何かがうまくいかなかった」)
実行ごとに変動するものは制御してテストを安定させてください。フレークするテストは存在しないテストより悪く、人々に失敗を無視させてしまいます。
時間と乱数を予測可能にする
ハンドラがtime.Now()、ID、ランダム値を使うなら注入可能にしてください。ハンドラやサービスにクロック関数とIDジェネレータを渡します。テストでは固定値を返し、JSONフィールドやヘッダを正確にアサートできるようにします。
小さなフェイクを使い、「副作用がない」を検証する
完全なモックより小さなフェイクやスタブを好んでください。フェイクは呼び出しを記録し、失敗後に何も起きなかったことをアサートできます。
例: 「ユーザー作成」ハンドラでデータベース挿入が一意制約エラーで失敗した場合、ステータスコードとエラーボディが正しいことをアサートし、ウェルカムメールが送信されていないことを確認します。フェイクのメール送信機はカウンタ(sent=0)を公開しておくと、失敗経路で副作用が発生していないことを証明できます。
ハンドラテストを不安定にする一般的なミス
テストは往々にして間違った理由で失敗します。テストで作るリクエストが実際のクライアントリクエストと形が違うため、ノイズの多い失敗や誤った自信につながります。
よくある問題は、ハンドラが期待するヘッダなしでJSONを送ることです。コードがContent-Type: application/jsonをチェックしているなら、それを忘れるとハンドラがJSONデコードをスキップしたり、別のステータスを返したり、実際の本番で起きない分岐を取ることがあります。同様に認証では、Authorizationヘッダがないことは無効トークンと同じではありません。それぞれ別のケースにしてください。
別の落とし穴は、全JSONレスポンスを生の文字列としてアサートすることです。フィールド順、空白、追加フィールドの小さな変化でテストが壊れます。ボディを構造体やmap[string]anyにデコードしてから、重要な部分(ステータス、エラーコード、メッセージ、いくつかのキー項目)をアサートしてください。
ケースが共有するミュータブルな状態も不安定さの原因です。同じインメモリストア、グローバル変数、シングルトンルータをテーブル行間で再利用するとケース間でデータが漏れます。各テストケースはクリーンに開始するか、t.Cleanupで状態をリセットしてください。
壊れやすいテストを生むパターン:
- 実際のクライアントが使うヘッダやエンコーディングなしでリクエストを作る
- 全JSON文字列をそのままアサートする代わりにデコードしてフィールドをチェックしない
- ケース間で共有するデータベース/キャッシュ/グローバルハンドラ状態を再利用する
- 認証、バリデーション、ビジネスロジックのアサーションを1つの巨大なテストに詰め込む
各テストを焦点化してください。1つのケースが失敗したときに、それが認証、入力ルール、あるいはエラーフォーマットのどれかを数秒で判断できるべきです。
リリース前に使えるクイックチェックリスト
出荷前にテストが証明すべきは2つ: エンドポイントが契約に従うことと、安全で予測可能に失敗することです。
以下をテーブル駆動ケースで実行し、各ケースがレスポンスと副作用の両方をアサートするようにします:
- 認証: トークンなし、無効トークン、権限不足、正しい権限(「権限不足」ケースが詳細を漏らしていないことを確認)
- 入力: 必須フィールド欠落、型違い、境界サイズ(最小/最大)、拒否したい未知フィールド
- 出力: ステータスコード、主要ヘッダ(
Content-Typeなど)、必須JSONフィールド、一貫したエラー形 - 依存関係: 下流障害(一つ)を強制し、安全なメッセージを検証し、部分書き込みが発生していないことを確認
- 冪等性: 同じリクエストを繰り返す(またはタイムアウト後にリトライ)と重複作成が起きないことを確認
その後で1つの簡単なサニティチェックを追加してください: テストがスキップされがちな確認です。例えばバリデーション失敗のケースでレコードが作成されていないこと、メールが送信されていないことを検証します。
AppMasterのようなツールでAPIを構築する場合でも、このチェックリストは当てはまります。重要なのは公開挙動が安定することを証明する点です。
例: 1つのエンドポイント、小さなテーブル、そして捕まえられる問題
例として簡単なエンドポイントがあるとします: POST /login。emailとpasswordを含むJSONを受け取り、成功でトークンを返す(200)、入力が無効で400、認証失敗で401、認証サービスがダウンしていれば500を返す、といった仕様です。
次のようなコンパクトなテーブルは本番で壊れやすい多くのケースをカバーします。
func TestLoginHandler(t *testing.T) {
// 実際のシステムに触れずに200/401/500を強制するフェイク依存。
auth := &FakeAuth{ /* テストごとに設定 */ }
h := NewLoginHandler(auth)
tests := []struct {
name string
body string
authHeader string
setup func()
wantStatus int
wantBody string
}{
{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
}
})
}
}
「missing password」のケースを端から端まで見てみましょう: emailだけのボディを送り、Content-Typeをセットし、ServeHTTPで実行し、400とpasswordを明確に指すエラーが返ることを確認します。この単一ケースでデコーダ、バリデータ、エラー応答フォーマットが連携していることを証明できます。
APIの契約、認証モジュール、統合を標準化する速い方法を求めるなら、AppMaster (appmaster.io) はそのために設計されています。とはいえ、これらのテストはクライアントが依存する挙動を固定するために依然として有用です。


