検証とエラーをテストする
不正な入力、欠けたフィールド、違う型、そしてよいエラーとはどういうものか。
前のレッスンでは正しいデータを送り、それが保存されたことを確かめました。このレッスンでは不正なデータを送り、それが拒否される ことを確かめます。ほとんどのプロジェクトで、バグがあるのはここです。開発者は正常系を先に書き、作りながら手で試し、 クライアントがほかに何を送ってくるかを考える前に時間切れになってしまうのです。
検証のテストには 2 つの部分があります。サーバーが正しいステータスでリクエストを拒否すること、そして何も保存しないことです。 そのうえで、エラーメッセージの質が大事になります。いつか別の開発者が、自分の何が間違っていたのかを知ろうとして読むことに なるからです。
ルールのあるフォーム
POST /api/validation/registerサインインして表示 は、ルールが明文化された登録用のエンドポイントです。リファレンスページにルールが並んでいます。ユーザー名は英字、数字、アンダースコアで 3 〜 20 文字、年齢は 18 〜 120 の整数、国は 9 つのコードのどれか、といった具合です。何も保存されないので、同じボディを何度 送ってもかまいません。有効なリクエストはこうです。
/api/validation/register{
"username": "ava_t",
"email": "ava@example.com",
"password": "Str0ng!Pass",
"confirmPassword": "Str0ng!Pass",
"age": 29,
"country": "us",
"phone": "+15550101",
"acceptTerms": true
}HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"message": "User registered successfully",
"data": {
"id": 1001,
"username": "ava_t",
"email": "ava@example.com",
"age": 29,
"country": "US",
"createdAt": "2026-09-30T18:32:12Z"
}
}"us" が "US" になって返ってきたことに注目してください。サーバーは値を拒否せずに正規化しました。それはかまいませんが、これも 1 つの振る舞いなので、誰かがうっかり変えてしまわないよう、テストで固定しておくべきです。
今度は、ほとんどすべてのルールを一度に破ってみます。
/api/validation/register{
"username": "ab",
"email": "ava@example",
"password": "password",
"confirmPassword": "Password",
"age": 17,
"country": "XX",
"acceptTerms": false
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/validation-error",
"title": "One or more fields failed validation.",
"status": 422,
"detail": "7 field(s) failed validation.",
"instance": "/api/validation/register",
"errors": {
"username": ["Username must be 3-20 characters long."],
"email": ["Email must be a valid email address."],
"password": [
"Password must contain an uppercase letter.",
"Password must contain a digit.",
"Password must contain a special character."
],
"confirmPassword": ["Passwords do not match."],
"age": ["Age must be between 18 and 120."],
"country": ["Country must be one of: US, GB, IN, DE, FR, JP, CA, AU, BR."],
"acceptTerms": ["You must accept the terms and conditions."]
}
}これはよいエラーレスポンスです。後で悪いものを見分けられるよう、なぜよいのかを見ておきましょう。
- すべての問題を一度に報告している。フォームを直すクライアントは、7 つの間違いを見つけるのに 7 回送信しなくて済む。
- 各エラーはクライアントが送ったフィールド名で整理されているので、画面では正しい入力欄の隣に表示できる。
- メッセージは、値が間違っていると言って理由を推測させるのではなく、ルール(「between 18 and 120」)を述べている。
- HTTP エラーの標準形式である RFC 7807(RFC 9457 で改訂)に従い、
type、title、status、detail、instanceのフィールドと、メディアタイプapplication/problem+jsonを使っている。この形式を必須にする必要はないが、API がこれを使うと言っているなら、フィールドがそろっていることと、ボディのstatusが実際のステータスコードと一致することを確かめる。
何を送るか
すべてのフィールドですべてを試すわけではありません。フィールドを 1 つずつ見て、次のどれが当てはまりそうかを考えます。
| 入力の種類 | 例 |
|---|---|
| 欠落 | フィールドをまるごと省く。null で送る。空文字列を送る。これらは振る舞いが違うことがある。 |
| 型の違い | 数値の代わりに "29"、文字列の代わりに 29 や true、リストが期待されるところにオブジェクト |
| 境界 | ちょうどの最小値と最大値、その両側に 1 つずつ、ゼロ、負の数、整数が期待されるところに小数 |
| 長さ | 空、1 文字、最大の長さ、それより 1 つ長いもの、とても長いもの(10,000 文字) |
| 形式 | ほとんど正しいメールアドレス、日付、電話番号、URL、コード |
| 空白と大文字・小文字 | 先頭の空白、空白だけのタイトル、admin が使われているときの Admin |
| 未知のフィールド | スペルを間違えたフィールド名、余分なフィールド、クライアントが設定できるべきでないフィールド |
| ボディそのもの | ボディなし、壊れた JSON、オブジェクトの代わりの配列、間違った Content-Type |
登録エンドポイントの境界
年齢のルールは 18 〜 120 なので、興味深い値は 17、18、120、121 です。この API では 18 と 120 が受け付けられ、17 と 121 には「Age must be between 18 and 120.」が返ります。18.5 には「Age must be an integer.」という別のメッセージが返り、 文字列の "29" も同じです。"29" を黙って 29 に変換する API もあります。どちらの振る舞いもそれ自体は間違いではありませんが、クライアントはリリースされた方に依存するようになります。
ユーザー名は 3 〜 20 文字です。abc と 20 文字の名前は通り、21 文字の名前は失敗します。空白を含むユーザー名は、使える文字についてのメッセージで拒否されます。すでに使われている admin は拒否され、Admin も拒否されるので、一意性のチェックが大文字と小文字を区別しないことがわかります。区別していたら、ほとんどの場所で同じに見える 名前を 2 人のユーザーが持ててしまいます。
メールアドレスのチェックはいつものとおり混沌としています。ava@example、ava@@example.com、ava.example.com は拒否され、先頭に空白のある " ava@example.com" も拒否されます。ava+qa@example.com と AVA@EXAMPLE.COM が受け付けられるのは正しいことです。a@b.c も受け付けられます。本当のメールアドレスの検証は誰も勝てない長い議論なので、テストは完璧なルールよりも、製品にとって大事な ケース、つまりプラスを含むアドレス、大文字、前後の空白が取り除かれるのか拒否されるのか、に重点を置きます。
空と欠落は同じではない
{} を送ると、必須フィールドはすべて「is required」と報告されます。同じフィールドを null の値で送っても結果は同じです。これは一貫していて、よいことです。/api/Booksサインインして表示 では、空白 3 つのタイトルも欠落として扱われます。多くの API は文字列があるかどうかしか確かめないので、空白だけのタイトルや空文字列が すんなり通り、後で誰かの表に空の行として現れます。
長さの上限がまったくないこともよくあります。/api/Booksサインインして表示 は 5,000 文字のタイトルを 201 で受け付けました。ドキュメントに上限はないので意図的かもしれませんが、後でデータベースの列や画面のレイアウトが異を唱える たぐいのことです。上限がいくつであるべきかを尋ね、その両側を 1 文字ずつテストしましょう。
未知のフィールド
サーバーが知らないフィールドをクライアントが送ったとき、サーバーはそれを無視することも拒否することもできます。CRUD のレッスンでは、/api/Booksサインインして表示 はスペルを間違えた year フィールドを無視し、年として 0 を保存しました。/api/validation/strictサインインして表示 は代わりに拒否します。
curl -X POST https://api.testingapis.com/api/validation/strict \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "email": "a@b.co", "quantity": 2, "discount": 50}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/unknown-fields",
"title": "Request body contains unknown fields.",
"status": 400,
"detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
"instance": "/api/validation/strict",
"unknownFields": ["discount"]
}ここではフィールド名が大文字と小文字を区別するので、name の代わりの Name も未知のフィールドです。拒否する方が安全で、打ち間違いがすぐに表に出ます。無視する方は、API が進化して古いクライアントが削除されたフィールドを送ってくるときに親切です。あなたの仕事は、API がどちらの方針をとっているか、そしてそれがエンドポイント間で一貫しているかを見極めることです。
未知のフィールドの中には、スタイルの問題ではなくセキュリティの問題になるものがあります。レコードには存在するが、クライアントが 設定するべきではないフィールドです。role、isAdmin、注文の price、id などです。サーバーが受け取ったフィールドをすべてレコードにコピーするなら、ユーザーは自分で自分を昇格させられます。セキュリティのレッスンでは、これをマスアサインメントと呼びます。
壊れたボディ
次に、リクエスト全体をテストします。次は登録エンドポイントからの本物のレスポンスです。
# No body at all
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."]},"traceId":"00-fc01be6d..."}
# {"username":"ava_t",} (trailing comma)
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"$":["The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 0 | BytePositionInLine: 20."]},"traceId":"00-e13ac011..."}
# [] instead of an object
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"status":400,"error":"Bad Request","message":"Request body must be a JSON object."}
# Valid JSON sent as Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-4cadc1e1..."}4 つとも処理されていて、間違った Content-Type に 415 を返すのはまさに正しいことです。しかし形を見てください。3 つのレスポンスは errors マップを持つ problem+json ですが、配列のケースだけは、ふつうの JSON のコンテンツタイプで {status, error, message} というまったく別の形式を返しています。一方の形でエラーを解析するクライアントは、もう一方で壊れます。これは、エラーの一部が フレームワークから、別の一部がアプリケーションのコードから来るときに起きることで、API テストでいちばんよく見つかることの 1 つです。API で目にしたエラーの形を集め、並べて比べてみましょう。
エラーに含めてはいけないもの
次は、数値を期待する /api/Booksサインインして表示 に文字列を送った例です。
curl -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{"title": "x", "author": "y", "pages": "many"}'
HTTP/1.1 400 Bad Request
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"item": ["The item field is required."],
"$.pages": ["The JSON value could not be converted to System.Int32. Path: $.pages | LineNumber: 0 | BytePositionInLine: 40."]
},
"traceId": "00-d027cc55a2925746a1bf93cc96d2306d-02234fa55d940604-00"
}ステータスは正しいのですが、中身に問題が 2 つあります。メッセージに System.Int32 とあり、部外者にサーバーが .NET で動いていることを教えてしまううえ、クライアントには「pages は整数でなければならない」ではなく型名が残されます。また、追加のエラー "The item field is required." は内部のパラメーター名を指していて、クライアントが送ったものとは関係ありません。ここではどちらも危険ではありませんが、本物の 製品なら両方を報告するでしょう。
エラーに決して現れてはいけないのは、スタックトレース、SQL の断片、ファイルパス、内部のホスト名、秘密情報の値です。一方で traceId は役に立ちます。何も公開せずに、サポートが対応するサーバーのログを見つけられるからです。
どのステータスコードか
400 か 422 かでチームの意見は分かれます。よくある分け方は、サーバーが読めなかったボディ(壊れた JSON、型の違い)には 400、読めるがルールに反するボディ(年齢 17)には 422 です。/api/validation/registerサインインして表示 は、ボディをまったく解析できないときだけ 400 を返し、年齢のところに文字列があるケースも含め、それ以外はすべて 422 です。/api/validation/annotationsサインインして表示 はすべてに 400 を使います。一貫していて文書化されていればどちらの方針もよく、同じ API の 2 つのエンドポイントで扱いが違うなら、尋ねてみる価値があります。
| ステータス | 不正な入力に対する意味 |
|---|---|
| 400 Bad Request | リクエストの形式が不正か無効。クライアントが直す必要がある。 |
| 415 Unsupported Media Type | Content-Type がエンドポイントの受け付けるものではない。 |
| 422 Unprocessable Content | ボディは解析できたが、値がルールに反している。 |
| 409 Conflict | 単独では有効だが、重複など現在の状態とぶつかる。(登録エンドポイントは使用済みのユーザー名に 422 を使う。) |
| 500 Internal Server Error | 不正な入力に対しては決して正しくない。拒否すべきものでサーバーが落ちた。 |
最後の行だけは例外のないルールです。送った入力が何であれ 500 になったなら、どれほど奇妙な入力でもバグを見つけたことに なります。サーバーは拒否するはずだったのに、倒れてしまったのです。きちんとした形の 500 がどう見えるかは /api/validation/problem/internalサインインして表示 で確かめられ、ほかの problem ドキュメントの例はここに並んでいます。
/api/validation/problem演習
POST /api/validation/annotationsサインインして表示 をテストしてください。有効な例はリファレンスページにあります。手書きのルールではなく、フレームワークに組み込まれた 検証を使っています。
username、age、tagsの境界を見つける。- 無効な電話番号と Web サイトを 3 つ、有効なものを 2 つ試す。
- 無効なボディを
/annotationsと/registerの両方に送り、エラーレスポンスを比べる。それぞれどのステータスを使っていますか。errorsの中のフィールド名は、送ったフィールド名と比べてどう綴られていますか。 ageが数値を期待するところに文字列を送る。メッセージから何か漏れていますか。
2 つのエンドポイントの違いを、開発者に報告するつもりでまとめてください。何を送り、何が返り、何を期待していたかです。