Test Heuristics Cheat Sheet（WebAPIテスト）の話

# WebAPIテストは何をテストしたらいいの？
上記は、以前、チームの開発エンジニアから聞かれた言葉です。 
どういうテストを実施するか丁度良いサイトがあったので紹介しました。
 - [Test Heuristics Cheat Sheet](https://www.ministryoftesting.com/articles/test-heuristics-cheat-sheet) （2022年3月31日時点）

このサイトに書かれている「API Tests」の項目をお話しようと考えましたが、それぞれキーワードが英語で書かれていているため、わかりにくい。

そこで、それぞれに解説をつけてチーム内勉強会を開きました。
その時の資料を公開します。

## WebAPIテストのヒューリスティックス
ヒューリスティックス（Heuristics）は経験則や実践的なアプローチと訳されることが多く、「◯◯を発見する方法」と言われることもあります。
今回はAPIテストを経験則に基づいて評価する方法（アイデア）と考えてください。
サイトでは以下の3つを紹介していますが、それ以外にもHeuristicsはあるので、良ければ探してみてください。

### BINMEN
- Gwen Diagram ＆ Ash Winterが作ったHeuristic
- 以下の頭文字をとって、BINMEN（ビンメン）と呼ばれる
- 主にエラー処理の検証に重点を置いたもの

| 項目 　|　解説 |
|:------|:-----|
|Boundary<br>（境界）|APIが処理できる限界の値を使用して、APIをテストすること<br>可能な最大値、最小値およびそれらの僅かに上もしくは下の値を利用してテストする |
|Invalid entries<br>（無効エントリ）|制御された方法でエラーを返す、または無効なエントリを無視することでAPIが無効なエントリを適切に処理するかを確認する|
|Null<br>（ヌル）|NULL値がAPIに送信されて、その値で適切に処理できるかを確認する<br>NULL値の処理に問題があり、予期しないエラーが発生しないかを確認する|
|Method<br>（メソッド、方式）|APIの各メソッドが仕様に則り動作し、一貫性があるか、予測可能であるかを確認する<br>GET,POST,PUT,DELETEは正しく動作するか|
|Empty<br>（空）|空の値をAPIに送信して確認する<br>APIが空の値を受け取った場合に、発生する可能性がある問題を特定する|
|Negative<br>（否定、反対、負数）|有効ではない、またはエラーの原因となることがわかっているAPI入力を送信して確認する<br>APIがこれらのエラーを適切に処理し、失敗したり機能が停止することがないことを確認する|

### POISED
- Amber Raceが作ったHeuristic
- 以下の頭文字をとって、POISED（ポイズド）と呼ばれる
- 主にシステムの品質と堅牢性を確保するために考慮すべきもの

| 項目 　|　解説 |
|:------|:-----|
|Parameter<br>（パラメータ）|API操作のパラメータを評価する<br>データの受け渡しが正しいかどうか<br>データが空もしくはランダムなパラメータを受け渡した時にどうなるか<br>許可されたデータ型で変更されているか|
|Output<br>（出力）|サーバの応答が期待通りであることを確認する<br>要求を満たしているか<br>ビジネスルールに従って特定のメッセージが出力されているか<br>（例：法定年齢に達していないユーザーのユーザー登録があった場合。「未成年者の登録はできません」のメッセージが表示される|
|Interoperability<br>（相互運用、相互接続）|システムが他のシステムやコンポーネントと相互運用できているか<br>システムが他のシステム、サービス、デバイスと効果的に通信できているか|
|Security<br>（セキュリティ）|認証と承認に加えて、データ暗号化など他のセキュリティ面を評価し、潜在的な脆弱性を特定する|
|Errors<br>（エラー）|システムのエラー状況の処理を確認する<br>エラーが発生するテストケースに焦点を当てて、必須フィールドを送信しない、境界値を操作、仕様で許可されていないデータ型を利用するなど|
|Data<br>（データ）|データの整合性（データの保存、取得、処理方法）を確認する<br>POSTで保存されたデータがGETで正しく取得できるかどうか<br>データの削除が永久的（物理的）なのか、論理的な削除であるかどうか|

### VADER
- Stuart Ashmanが作ったHeuristic
- 以下の頭文字をとって、VADER（ベイダー）と呼ばれる
- 主にHTTP同士の正しい使用、エラー管理の効率、応答性までを評価するもの

| 項目 　|　解説 |
|:------|:-----|
|Verbs<br>（動詞）|HTTP動詞が適切に仕様されているかどうか<br>POST: 情報が正しく登録できるか<br>GET: 情報が正しく取得できるか<br>DELETE: レコード削除が論理的（非アクティブになる）なのか、物理的（完全に削除される）なのか<br>PUT: エンティティの全ての情報が正しく更新されているか<br>PATCH: エンティティの一部の情報が正しく更新されているか|
|Authorization／Authentication<br>（承認、認証）|承認および認証のメカニズムが正しく実装されているか<br>リクエストが正しく保護されているか<br>特定のリソースにアクセスするために有効な資格情報が必要であることが確認できるか<br>アクセストークンが正しいか<br>認証: ユーザーが誰であるかを区別し、システムが登録されているユーザーであると認識して認証しているか<br>承認: ユーザーがアクセスできるリソースが正しく決定されているか|
|Data<br>（データ）|APIによって管理されるデータの整合性と正確性を確認する<br>認可されるタイプまたはデータ構造、値、最大値と最小ちを確認し、情報が正しく保存、処理、送信されるか|
|Errors<br>（エラー）|エラー状況で返されるHTTPステータスコードとエラーメッセージの明確さ、有用性を確認する<br>エラーの原因となる不正なリクエストを送信（エラーになるデータセットを使用）して、そのエラーが適切に処理されるか<br>エラー状況に適したエラーコードとエラーメッセージが返されるか|
|Responsiveness<br>（応答性）|応答時間の観点からAPIの速度と効率を確認する<br>APIがシステムのパフォーマンス要件を満たしているか|

## 3つのHeuristicの特色
項目としては似ているところが多い印象だったので、簡単に並べると以下のようになりました。

![スクリーンショット 2025-04-08 17.19.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615344/744b647e-656a-443b-b8a2-b9fea0b780ba.png)

## BINMEN / POISED / VADARのいい塩梅
似ている項目を整理して、いい塩梅を探ってみました。

```
# WebAPI Testの観点
- 以下は、WebAPIテストで考慮すると望ましいテストの観点です。

## データ検証
 - APIが処理できる限界の値を使用して、正しく処理することを確認する
   - 最大値、最小値および、これらの値のわずかに上もしくは下の値を使用

## 無効なエントリ、エラー検証
 - 制御された方法でエラーを返す、または無効なエントリをしようしAPIが無効なエントリを適切に処理するかを確認する
  - 例: 必須フィールドに送信しない
  - 例: 仕様として許可されていないデータ型を使用する
  - 例: エラー状況で返される「HTTPステータスコード」と「エラーメッセージ」の明確さを評価する

## NULL検証
 - NULL値の処理に問題がないか、また処理に問題があって予期しないエラーが発生しないかを確認する

## 空の検証
 - APIに「空」の値を送信し、APIが値を受け取った場合に、発生する問題が何か特定する

## 否定、反対、負数の検証
 - 有効ではない、またはエラーの原因となるAPI入力を実施し、これらのエラーを適切に処理できるか、失敗して機能が停止しないことを確認する

## メソッドの検証
 - HTTPリクエストメソッドが適切に使用されているかを確認する
  - POST: 情報が正しく登録できるか
  - GET: 情報を正しく取得できるか
  - DELETE: レコード削除が論理的なのか（非アクティブになるだけなのか）、物理的なのか（完全に削除されるのか）
  - PUT: エンティティの全ての情報を正しく更新できているか
  - PATCH: エンティティの一部のみを正しく更新できているか

## 出力の検証
 - サーバの応答が期待通りか、要求定義により指定された要求を満たしているか、ビジネスルールに従って特定のメッセージを出力しているかを確認する
  - 例: ユーザー登録時に、法定年齢に達していない場合、未成年者データを送信すると「未成年者の登録はできません」というメッセージが表示される

## 相互運用、相互接続の検証
 - システムが他システムやコンポーネントと相互運用できるかを確認する
 - システムが他システム、サービス、デバイスと効率的に通信できるかを確認する

## セキュリティの検証
 - 承認および認証のメカニズムが正しく実装されているかを確認する
   - 承認: ユーザーがアクセスできるリソースが正しく決定されているか
   - 認証: ユーザーが誰であるかを区別し、システムが登録されているユーザーであると認識して認証しているか 
   - 「データ暗号化」が適切に行われ、データが保護されているか
   - 潜在的な脆弱性が存在しないか
   - 特定のリソースにアクセスするために有効な資格情報が必要であることが確認できるか
   - アクセストークンが正しいかどうか

## 応答性（Responsive）の検証
 - APIがシステムのパフォーマンス要件を満たしているかを確認する
   - システムによってはレイトリミットを設定している場合があるので、レイトリミットに対して問題ない応答速度なのかを確認する
```

## Copilotにテストケースを書いてもらうために
上記のテスト観点に具体例をつけて見ました。
業務で使用するにはより具体的な指示（パラメータなど）が必要になってきますが、抽象度の高いテスト観点としてCopilotにこのファイルを参考にしてテストケース作成を指示するには良いのでは？と思います。
実業務では「抽象的なテスト観点」と「（システムにあった）具体的な仕様やテスト観点」を掛け合わせてプロンプトとして指示すると良さそうです。

```
# Web API TEST観点

以下は、Web APIでの考慮が望ましいテストの観点です。
xxx/xxxに「xxx.spec」としてファイルを作成し、以下の観点ごとに示した具体例を参考にして、具体的なテストケースを実装してください。

## コンポーネントテスト
### データ検証
- **目的**: APIが処理できる限界値を正しく処理することを確認する
  - 最大値、最小値、および最大値＋1の値、最小値-1の値を使用
  - **期待する結果**: 正常な値は処理され、不正な値は適切なエラーが返される

——————————————————————————————————————
リクエスト（例）:
  - 正常値： {"age": 30}
  - 境界値（最小）: {"age": 0}
  - 境界値（最大）: {"age": 150}
  - 境界値未満（最小-1）: {"age": -1}
  - 境界値超過（最大値＋1）: {"age": 151}

期待される結果:
  - 正常値及び境界値の場合、ステータスコード200とレスポンスが返る。
  - 境界値未満及び境界値超過の場合、ステータスコード400とエラーメッセージが返る。
——————————————————————————————————————

### 無効なエントリ、エラー検証
- **目的**: 無効なエントリやエラーが適切に処理されることを確認する
  - 必須フィールドを送信しない場合
  - 許可されていないデータ型を使用した場合
  - **期待する結果**: 適切なHTTPステータスコード（例: 400）とエラーメッセージが返される

——————————————————————————————————————
シナリオ1: 正常系
リクエスト（例）: POST /api/v1/users {"name": "Alice", "age": 25}

期待される結果:
  - ステータスコード: 201
  - レスポンスボディ: {"id": 1, "name": "Alice", "age": 25}
——————————————————————————————————————
シナリオ2: 異常系（必須フィールド欠損）
リクエスト（例）: POST /api/v1/users {"age": 25}

期待される結果:
  - ステータスコード: 400
  - エラーメッセージ: "name is required"
——————————————————————————————————————

### NULL検証
- **目的**: NULL値の処理に問題がないことを確認する
  - **期待する結果**: NULL値が適切に処理され、予期しないエラーが発生しない

——————————————————————————————————————
シナリオ1: 必須フィールドがNULL
リクエスト（例）: POST /api/v1/users{"name": null,"age": 30}

期待される結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "The 'name' field is required and cannot be null"
——————————————————————————————————————
シナリオ2: オプションフィールドがNULL
リクエスト（例）: POST /api/v1/users{"name": "Jane Doe","age": null}

期待される結果:
  - ステータスコード: 201 Created
  - レスポンスボディ: {"id": 2, "name": "Jane Doe", "age": null}
——————————————————————————————————————
シナリオ3: リクエスト全体がNULL
リクエスト（例）: POST /api/v1/users null

期待される結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "Request body cannot be null"
——————————————————————————————————————
シナリオ4: NULL値による攻撃の防止
（NULL値がSQLインジェクションなどのセキュリティ上の脆弱性を引き起こさないことの確認）
リクエスト（例）: POST /api/v1/users {"name": "' OR '1'='1", "age": null}

期待される結果:
  - ステータスコード: 適切なエラーレスポンス（例：400 Bad Request）
  - エラーメッセージに内部データベース情報が含まれない。
——————————————————————————————————————

### 空の検証
- **目的**: 空の値を送信した場合の挙動を確認する
  - **期待する結果**: 空の値が適切に処理され、エラーが発生しない

——————————————————————————————————————
正常系シナリオ1: 空の文字列を許容する
リクエスト（例）: POST /api/v1/users
{
  "name": "Alice",
  "nickname": ""  // 空文字列
}

期待結果:
  - ステータスコード: 201 Created
  - レスポンスボディ: {"id": 1, "name": "Alice", "nickname": ""}
——————————————————————————————————————
正常系シナリオ2: 空配列を許容する
リクエスト（例）: POST /api/v1/products
{
  "tags": []  // 空配列
}

期待結果:
  - ステータスコード: 200 OK
  - レスポンスボディ:  {"tags": []}
——————————————————————————————————————
正常系シナリオ3: 空の文字列と空配列を区別して処理する
リクエスト（例）: PATCH /api/v1/users/1
{
  "bio": "",  // 空文字列
  "website": null  // 明示的なNULL
}

期待結果:
  - ステータスコード: 200 OK
  - レスポンスボディ: {"bio": "", "website": null}

——————————————————————————————————————
異常系シナリオ1: 必須フィールドが空の文字列
リクエスト（例）: POST /api/v1/users
{
  "name": "",  // 必須フィールド
  "age": 25
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "name must not be empty"
——————————————————————————————————————
異常系シナリオ2: 空オブジェクトの送信
リクエスト（例）: POST /api/v1/orders
{}

期待結果: 
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "At least one field is required"
——————————————————————————————————————
異常系シナリオ3: 空白文字を含む空の値
リクエスト（例）:POST /api/v1/comments
{
  "text": "   "  // 空白文字
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "text must contain non-whitespace characters"
——————————————————————————————————————

### 否定、反対、負数の検証
- **目的**: 有効ではない入力が適切に処理されることを確認する
  - **期待する結果**: エラーが適切に処理され、システムが停止しない
——————————————————————————————————————
異常系シナリオ1: 負の数値が許可されないフィールド
リクエスト（例）: POST /api/v1/transactions
{
  "amount": -1000  // 負数
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "amount must be greater than or equal to 0"
——————————————————————————————————————
異常系シナリオ2: 真偽値の逆転
リクエスト（例）: PATCH /api/v1/users/1
{
  "active": "false"  // 文字列形式の偽値
}

期待結果: 
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "active must be a boolean value"
——————————————————————————————————————
異常系シナリオ3: 数値フィールドへの文字列入力
リクエスト（例）: POST /api/v1/inventory
{
  "quantity": "ten"  // 数値フィールドに文字列
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "quantity must be a number"
——————————————————————————————————————
異常系シナリオ4: 日付フィールドへの無効フォーマット入力
リクエスト（例）: POST /api/v1/events
{
  "start_date": "2024/13/01"  // 無効な日付
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "start_date must be in YYYY-MM-DD format"
——————————————————————————————————————
異常系シナリオ5: 負数によるバッファオーバーフローの防止（不数がメモリ操作の脆弱性を引き起こさないことを確認）
リクエスト（例）: POST /api/v1/memory
{
  "size": -2147483649  // INT_MIN境界値
}

期待結果:
  - ステータスコード: 400 Bad Request
  - エラーメッセージ: "size must be a positive 32-bit integer"
——————————————————————————————————————
異常系シナリオ6: 負の配列インデックス（負のインデックス値が拒否されること）
リクエスト（例）: GET /api/v1/items/-1  // 負のID

期待結果:
  - ステータスコード: 404 Not Found
  - エラーメッセージ: "Invalid item ID"
——————————————————————————————————————

## コンポーネント統合テスト
### メソッドの検証
- **目的**: HTTPリクエストメソッドが正しく機能することを確認する
  - **POST**: 情報が正しく登録される
  - **GET**: 情報が正しく取得される
  - **DELETE**: レコード削除が論理的または物理的に正しく行われる
  - **PUT**: エンティティ全体が正しく更新される
  - **PATCH**: エンティティの一部が正しく更新される
  - **期待する結果**: 各メソッドが仕様通りに動作する

——————————————————————————————————————
POSTメソッドシナリオ1:新規リソースが正常に作成される
リクエスト（例）: POST /api/v1/users
リクエストパラメータ:
  - name (string, 必須)
  - age (integer, 必須)

期待されるレスポンス:
  - ステータスコード: 201
  - レスポンスボディ: {"id": 1, "name": "John Doe", "age": 30}
——————————————————————————————————————
POSTメソッドシナリオ2:必須フィールドの欠落
リクエスト（例）: POST /api/v1/users
{
  "age": 30,
  "email": "john.doe@example.com"
}

期待されるレスポンス:
  - ステータスコード: 400 Bad Request
  - レスポンスボディ: {"error": "Name is required"}
——————————————————————————————————————
GETメソッドシナリオ1:  リソースが正常に取得される
リクエスト（例）: GET /api/v1/users/123

期待されるレスポンス:
  - ステータスコード: 200 OK
  - レスポンスボディ: {"id": 123, "name": "John Doe", "age": 30, "email": "john.doe@example.com"}
——————————————————————————————————————
GETメソッドシナリオ2: 存在しないユーザーID
リクエスト（例）: GET /api/v1/users/999

期待されるレスポンス:
  - ステータスコード: 404 Not Found
  - レスポンスボディ: {"error": "User not found"}
——————————————————————————————————————
DELETEメソッドシナリオ1: リソースが正常に削除（または論理削除）される
リクエスト（例）: DELETE /api/v1/users/123

期待されるレスポンス: 
  - ステータスコード: 204 No Content
  - データベースの確認: ユーザーが削除されている（またはdeleted_atが設定されている）
——————————————————————————————————————
DELETEメソッドシナリオ2: 存在しないユーザーID
リクエスト（例）: DELETE /api/v1/users/999

期待されるレスポンス: 
  - ステータスコード: 404 Not Found
  - レスポンスボディ: {"error": "User not found"}
——————————————————————————————————————
PUTメソッドシナリオ1: リソース全体が正常に更新される
リクエスト（例）: PUT /api/v1/users/123
{
  "name": "Jane Smith",
  "age": 35,
  "email": "jane.smith@example.com"
}

期待されるレスポンス: 
  - ステータスコード: 200 OK
  - レスポンスボディ: {"id": 123, "name": "Jane Smith", "age": 35, "email": "jane.smith@example.com"}
——————————————————————————————————————
PUTメソッドシナリオ2: 必須フィールドを送信しない
リクエスト（例）: PUT /api/v1/users/123
{
  "email": "jane.smith@example.com"
}

期待されるレスポンス:
  - ステータスコード: 400 Bad Request
  - レスポンスボディ: {"error": "Name and age are required"}
——————————————————————————————————————
PATCHメソッドシナリオ1: リソースの一部が正常に更新される
リクエスト（例）: PATCH /api/v1/users/123
{
  "name": "Jane Smith"
}

期待されるレスポンス:
  - ステータスコード: 200 OK
  - レスポンスボディ: {"id": 123, "name": "Jane Smith", "age": 30, "email": "john.doe@example.com"}
——————————————————————————————————————
PATCHメソッドシナリオ2: 存在しないフィールドを更新
リクエスト（例）: PATCH /api/v1/users/123
{
  "invalid_field": "value"
}

期待されるレスポンス: 
  - ステータスコード: 400 Bad Request (または422 Unprocessable Entity)
  - レスポンスボディ: {"error": "Invalid field: invalid_field"}
——————————————————————————————————————

##＃ 出力の検証
- **目的**: サーバの応答が期待通りであることを確認する
  - 例: ユーザー登録時に未成年者データを送信すると「未成年者の登録はできません」というメッセージが表示される
  - **期待する結果**: 応答が要求定義やビジネスルールに従っている

——————————————————————————————————————
※割愛
——————————————————————————————————————

##＃ 相互運用、相互接続の検証
- **目的**: 他システムやコンポーネントとの相互運用性を確認する
  - **期待する結果**: 他システムやサービスと効率的に通信できる

——————————————————————————————————————
※割愛
——————————————————————————————————————

### セキュリティの検証
- **目的**: 承認、認証、データ保護が正しく実装されていることを確認する
  - 承認: ユーザーがアクセスできるリソースが正しく決定される
  - 認証: ユーザーが正しく認証される
  - データ暗号化が適切に行われる
  - **期待する結果**: 潜在的な脆弱性がなく、セキュリティ要件を満たしている
——————————————————————————————————————
# 認証テスト
- 対象API: GET /api/secure-data
- テスト観点: JWTトークンの検証プロセス
- 異常シナリオ:
  - トークン未付与
  - 改ざんされた署名
  - 有効期限切れトークン
  - 権限不足のロール付きトークン
- 期待動作:
  HTTP 401 Unauthorized
  {"code": "INVALID_TOKEN", "message": "認証情報が無効です"}
——————————————————————————————————————
# 認可テスト
Feature: ロールベースアクセス制御
  Scenario: 一般ユーザーが管理者APIにアクセス
    Given 一般ユーザーの有効なJWTトークンを取得
    When GET /api/admin/users を実行
    Then ステータスコード403を返す
    And エラーレスポンスに"FORBIDDEN"を含む

- テスト対象: DELETE /api/users/{id}
- テスト要件:
  - 通常ユーザーが他ユーザーのデータ削除を試行
  - 管理者権限を持つユーザーのみ許可
  - IDOR脆弱性の有無を確認
- 期待レスポンス:
  403 Forbidden
  {"code": "ACCESS_DENIED"}
——————————————————————————————————————
# 機密データ暗号化検証
  request:
    method: POST
    url: /api/payments
    body:
      card_number: "4111111111111111"
      cvv: "123"
  validate:
    - response.status == 201
    - response.body contains "tokenized_card"
    - response.headers["Strict-Transport-Security"] exists
    - response.body does not contain "4111"
——————————————————————————————————————
# インジェクション対策
- 対象パラメータ: GET /api/search?query={{payload}}
- テスト手法: パラメータファジング
- 使用ペイロード: SQLインジェクション/XSS/コマンドインジェクション
- 期待動作:
  - 400 Bad Request
  - エスケープ処理された出力
——————————————————————————————————————

### 応答性（Responsive）の検証
- **目的**: APIがパフォーマンス要件を満たしていることを確認する
  - レイトリミットに対して問題ない応答速度である
  - **期待する結果**: 応答時間が許容範囲内である
——————————————————————————————————————
# 通常負荷テスト
  request:
    method: GET
    url: /api/products/123
  validate:
    - response.status == 200
    - response.time < 300ms
    - response.headers["X-RateLimit-Remaining"] exists
——————————————————————————————————————
# 連続リクエストのレイト制限テスト
  config:
    phases:
      - duration: 60s
        arrivalRate: 2  # 1秒あたり2リクエスト
  scenario:
    - function: "単一リクエストの応答時間検証"
——————————————————————————————————————
// k6での高負荷テスト
import { check } from 'k6';
import http from 'k6/http';

export const options = {
  stages: [
    { duration: '30s', target: 100 },  // 段階的負荷増加
    { duration: '1m', target: 1000 },
    { duration: '30s', target: 0 },
  ],
};

export default function () {
  const res = http.get('https://api.example.com/products');
  check(res, {
    '応答時間500ms以内': (r) => r.timings.duration < 500,
    'レイト制限ヘッダ存在': (r) => r.headers['X-RateLimit-Remaining'] !== null,
  });
});
——————————————————————————————————————


```