APIエラーレスポンスのテスト設計|観点と再現方法
公開:
APIのテストは、どうしても正常系が中心になりがちです。エラー系は「代表的な400を1つ確かめたら終わり」になり、401と403の取り違えや、429で Retry-After が付いていないといった問題が、リリース後に利用者側から見つかります。
エラー系のテストが後回しになる理由ははっきりしていて、エラーを起こすまでの準備が面倒だからです。トークンを失効させる、レート制限の上限までリクエストを送る、依存先のサービスを落とす——こうした準備が重いと、テストケースに書いてあっても実行されなくなります。
この記事では、APIのエラーレスポンスをQAの視点でテストするための観点、ケースの洗い出し方、自動テストの書き方、そして起こしにくいエラーの再現方法をまとめます。エラーレスポンスの形式にはRFC 9457(Problem Details)を前提にしますが、独自形式でも考え方は同じです。
エラーレスポンスで確認すべき観点
エラーレスポンスは「ステータスコードが合っていればOK」ではありません。1つのエラーについて、次の観点を確かめます。
| 観点 | 確認すること |
|---|---|
| ステータスコード | 仕様どおりか。ボディの status と一致しているか |
| Content-Type | application/problem+json になっているか(application/json や text/html になっていないか) |
type |
同じ原因のエラーが、エンドポイントをまたいで同じ type になっているか |
title |
同じ type なら固定の文言か(発生ごとに変わっていないか) |
| 拡張メンバー | 422の errors が、不正なフィールドをすべて含んでいるか |
| ヘッダー | 401に WWW-Authenticate、429に Retry-After が付いているか |
| 内部情報 | 500の detail に、スタックトレースやSQLのエラー文が含まれていないか |
特に見落とされやすいのが Content-Type と 内部情報 です。フレームワークの既定のエラーページが返ってきて text/html になっていたり、想定外の例外で内部のメッセージがそのまま出ていたりするのは、正常系のテストでは見つかりません。ステータスとヘッダーの決め方は Problem Details実装パターン|422・401/403・429の返し方 で詳しく解説しています。
エラーケースを洗い出す
エラーケースは、思いついた順に書くと抜けが出ます。エラーの原因の種類 を軸にして、エンドポイントごとに表を埋めると漏れを防げます。
| 原因の種類 | 代表的なステータス | ケースの例 |
|---|---|---|
| リクエストの形式 | 400 | JSONが壊れている、必須のヘッダーがない |
| 入力値 | 422 | 範囲外の値、必須項目の欠落、複数項目の同時エラー |
| 認証 | 401 | トークンなし、期限切れ、形式不正 |
| 権限 | 403 / 404 | 他人のリソース、権限の足りないロール |
| 状態の競合 | 409 | 二重登録、すでに処理済み |
| 回数・量の制限 | 429 | 上限ちょうど、上限を1回超えたとき |
| 依存先の障害 | 502 / 503 / 504 | 外部APIの失敗、タイムアウト |
| サーバー内部 | 500 | 想定外の例外 |
入力値の422は、同値分割と境界値分析で絞り込みます。たとえば「個数は1〜99」なら、0・1・99・100の4つを試せば、境界の実装ミスをほぼ拾えます。加えて、2つのフィールドが同時に不正な場合 も必ず1ケース入れます。最初のエラーで処理を止めてしまう実装は、このケースでしか見つからないからです。
429は「上限を超えたとき」だけでなく、上限ちょうどのときに成功すること も確かめます。上限を1つずれて数える実装は珍しくありません。
自動テストで形式を固定する
観点のうち、ステータス・Content-Type・type・title の確認は、エラーケースごとに毎回同じです。共通の確認用の関数にまとめておくと、テストが読みやすくなり、確認漏れもなくなります。
import { expect, test } from 'vitest';
async function expectProblem(res: Response, status: number, type: string) {
expect(res.status).toBe(status);
expect(res.headers.get('content-type')).toContain('application/problem+json');
const body = await res.json();
expect(body.status).toBe(status);
expect(body.type).toBe(type);
expect(typeof body.title).toBe('string');
return body;
}
test('個数が0なら422で、quantityのエラーを返す', async () => {
const res = await fetch(`${API}/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity: 0 }),
});
const body = await expectProblem(res, 422, 'https://api.example.com/problems/validation-error');
expect(body.errors.map((e: { pointer: string }) => e.pointer)).toContain('#/quantity');
});
type の値は、APIの仕様書やOpenAPIに一覧(カタログ)としてまとめ、テストもその一覧を参照するようにしておくと、type の表記ゆれをテストで検出できます。
起こしにくいエラーをどう再現するか
上の表のうち、400・422・401・403は、リクエストを変えるだけで起こせます。難しいのは次のようなエラーです。
- 429: 上限までリクエストを送る必要があり、テストが遅く、他のテストと干渉する
- 502/503/504: 依存先のサービスを実際に落とすか、遅くする必要がある
- N回目だけの失敗: 「1回目は失敗、再送すると成功」のようなリトライの確認
- タイムアウト: 応答が返ってこない状態を作る必要がある
これらは、バックエンドのテストでは依存先をテスト用の実装に差し替えて確かめます。一方、フロントエンドやアプリの側で「このエラーのときに画面がどうなるか」を確かめる には、エラーを返すモックAPIを使うのが現実的です。確認することは次のとおりです。
- 422でフォームの該当フィールドにメッセージが出るか
- 401でトークンのリフレッシュが1回だけ走り、失敗したらログイン画面に移るか
- 429や503で
Retry-Afterの秒数だけ待ってから再送するか。再送が無限に続かないか - 5xxやタイムアウトで、利用者向けの汎用メッセージが出て、画面が固まらないか
開発チームより
「エラーはステータスだけでなく本文まで、本番と同じ経路で確かめる」——これは私たち自身が最近あらためて学んだことです。
ScenarioMockの英語ブログ用に、「1回目は504、2回目は500、3回目で成功する」というリトライのデモ用モックを作り、記事に載せる前に本番環境でcurlを実行して確かめました。ステータスは想定どおり504・500・200の順に返ってきました。ところが504の本文は、モックに設定したJSONではなく、error code: 504 という短いテキストに変わっていたのです。
ScenarioMockのログを見ると、モックのサーバーは設定どおりのJSONを返していました。本文を差し替えていたのは、その手前にあるCDN(Cloudflare)でした。手元の開発環境ではCDNを経由しないので、この差し替えは起きません。
もしステータスコードしか確認していなかったら、この違いには気づかなかったはずです。このときはたまたま記事に載せる前に本番で試したので気づけましたが、テストを開発環境だけで済ませていたら見落としていました。エラー時の本文をクライアントが読んで画面に出す設計なら、本番と同じ経路での確認は欠かせません。
なお、ScenarioMockのモックで504を返すと本文が差し替わる件は、現在回避方法を調査しています。本文まで確かめたいリトライのテストには、500や503を使うことをおすすめします。
ScenarioMockでエラーを再現する
ScenarioMock では、ここまでのエラーを返すモックAPIを作り、URLを共有するだけでフロントエンドやQAの担当者が同じ条件でテストできます。
- ヘッダーでエラーを切り替える: 条件分岐のシナリオで、
header.x-test-caseが422なら422、429なら429……とルールを並べる。ルールは上から順に評価され、最初に当てはまったものが返る。どれにも当てはまらなければ、デフォルトの200を返すようにしておく - N回目だけ失敗させる: シーケンスのシナリオで、1回目は503(ヘッダーに
Retry-After: 2)、2回目以降は200を返す。回数はシナリオ画面から0に戻せる - タイムアウトを起こす: レスポンスの遅延(
delay_ms、最大30秒)を、クライアントのタイムアウトより長く設定する - 送られたリクエストを確かめる: リクエストログで、クライアントが実際に送ったヘッダーやボディを確認できる(
Authorizationなどの値は伏せて記録される)
どのレスポンスも、ヘッダーに Content-Type: application/problem+json を指定すれば、Problem Detailsの形式で返せます。
デモで試す
上の設定で作ったデモを公開しています。ブラウザで 共有ページ を開けば、そのままリクエストを送れます。開発中のアプリの接続先をこのURLに向ければ、エラー時の画面をすぐに確かめられます。
H=https://demo-mock--error-catalog.scenariomock.com
# ヘッダーで切り替え: 400 / 401 / 403 / 404 / 409 / 422 / 429 / 500 / 503
curl -i $H/simulate -H 'X-Test-Case: 503'
# N回目だけ失敗: 1回目は503(Retry-After: 2)、2回目は200
curl -i -X POST $H/payments -H 'Content-Type: application/json' -d '{"amount":9800,"currency":"JPY"}'
# タイムアウト: 10秒待ってから200が返る
curl -i $H/simulate -H 'X-Test-Case: timeout'
回数は利用者のIPアドレスごとに数え、最初のリクエストから120秒で戻ります(/payments)。本物のトークンや個人情報は送らないでください。
エラーレスポンスの形式そのものは RFC 9457対応のAPIエラーレスポンス設計、モックAPIの考え方は モックAPIとは?必要な理由から作り方、無料ツールの選び方まで で解説しています。
まとめ
- エラーレスポンスは、ステータスだけでなくContent-Type・
type・ヘッダー・内部情報の有無まで確かめる - エラーケースは原因の種類を軸に洗い出し、422は境界値と複数同時のエラー、429は上限ちょうどのケースも入れる
- 形式の確認は共通の関数にまとめ、
typeはカタログと突き合わせる - 起こしにくい429や5xx、N回目だけの失敗はモックAPIで再現し、本文まで本番と同じ経路で確かめる
まずは担当しているAPIについて、上の「原因の種類」の表を1枚埋めてみてください。テストされていないエラーが見えてくるはずです。
起こしにくい429や5xx、N回目だけの失敗を、本物のバックエンドを使わずにテストしてみませんか。ScenarioMockならエラーを返すモックAPIをURL1つでチームと共有できます。
エラー再現用のモックを無料で作る