- API
- 検証・テスト
APIテストの観点チェックリスト|正常系・異常系・認可・境界値
API テストの観点は「正しい入力で正しく返るか」だけでなく、「おかしな入力・他人の権限・二重送信・同時更新でも決めたとおりに返るか」まで並べると、抜けが減ります。 なかでも見落とされやすいのが、ログインはしているが、他人のデータの ID を指定するテスト(BOLA)です。
| 観点 | 一言でいうと | 例(この記事の API) |
|---|---|---|
| ステータスコード | 結果に合ったコードか | 作成は 201、削除は 204、他人のデータは 404 |
| レスポンスの形 | 項目・型・ヘッダーが決めたとおりか | id は数値、Location ヘッダーがある |
| 入力チェック | おかしな入力をはじくか | 空文字・型違い・余計な項目・壊れた JSON |
| 境界値 | 上限・下限ちょうどと、その前後 | 名前 50文字は OK、51文字は 422 |
| 認証 | トークンが無い・違うとき | 401 と WWW-Authenticate |
| 認可(BOLA) | 他人のデータの ID を指定したとき | bob が alice の商品を見る・変える・消す → 404 |
| 認可(機能) | 権限の無い機能を呼んだとき | 一般ユーザーが管理者 API → 403 |
| 冪等性 | 同じ要求を2回送ったとき | 同じ冪等キーの POST は1件だけ作る |
| ページング | ページをまたいで抜け・重複が無いか | limit・offset の範囲外は 422 |
| 並び順 | 決めた順で、同じ値のときも安定するか | sort=price で価格順・同額は id 順 |
| 文字コード | 日本語・絵文字・異体字が化けないか | 髙橋🍎カナ を登録して読み直す |
| タイムアウト | 遅いときにクライアントが待ち続けないか | 0.5秒で打ち切る |
| 同時更新 | 2人が同時に更新したとき | 片方は 200、もう片方は 412 |
この表の観点を、FastAPI で作った小さな API に対して、curl と pytest+httpx で実際に確かめました。 テストは 28件すべて成功し、わざと BOLA を入れた版では、BOLA のテスト 3件だけが失敗しました。
確認環境:Python 3.11、FastAPI 0.143.0、Starlette 1.7.0、Pydantic 2.14.1、uvicorn 0.54.0、httpx 0.28.1、pytest 9.1.1、curl(Ubuntu 24.04)で動かしました。OWASP API Security Top 10 2023 の項目は OWASP の原文で、ステータスコードの意味は RFC で確かめています(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「APIテストの観点チェックリスト|正常系・異常系・認可・境界値」
観点チェックリスト(詳細版)
1つの API(エンドポイント)ごとに、この表を上から見ていきます。 「期待する結果」の列は、この記事の API での例です。チームの設計に合わせて書き換えて使えます。
正常系
| No | 観点 | 確かめること | 期待する結果の例 |
|---|---|---|---|
| 1 | ステータスコード | 作成・取得・更新・削除ごとのコード | POST 201、GET 200、PATCH 200、DELETE 204 |
| 2 | レスポンスの項目 | 項目が過不足なくあるか、型は合っているか | id・owner・name・price・version の5つ |
| 3 | ヘッダー | Content-Type・Location など |
application/json、Location: /items/1 |
| 4 | 登録した値が読めるか | POST した値を GET で読み直す | 同じ値が返る |
| 5 | 文字コード | 日本語・絵文字・半角カナ・全角空白・異体字 | 登録した文字のまま返る |
| 6 | 空の一覧 | データが0件のとき | 200 で items: [](404 にしない) |
異常系(入力チェック・境界値)
| No | 観点 | 送るもの | 期待する結果の例 |
|---|---|---|---|
| 7 | 必須の項目が無い | {"price":1} |
422 |
| 8 | 型が違う | "price":"abc" |
422 |
| 9 | null | "price":null |
422 |
| 10 | 境界値 | 下限ちょうど・下限−1・上限ちょうど・上限+1 | OK・422・OK・422 |
| 11 | 余計な項目 | "owner":"bob" |
422(無視する設計なら、owner が変わらないこと) |
| 12 | 壊れた JSON | {"name":"x", |
FastAPI は 422(400 にする API も多い) |
| 13 | Content-Type が違う | text/plain で JSON を送る |
422 か 415 |
| 14 | UTF-8 でない本文 | Shift_JIS のバイト列 | 400 |
| 15 | パスの ID の型 | /items/abc |
422 か 404 |
| 16 | 存在しない ID | /items/99999 |
404 |
| 17 | 許していないメソッド | PUT /items/1 |
405 |
| 18 | 大きすぎる本文 | 数 MB の JSON | 413(上限を決めておく) |
認証・認可
| No | 観点 | 送るもの | 期待する結果の例 |
|---|---|---|---|
| 19 | トークンが無い | Authorization なし |
401、WWW-Authenticate: Bearer |
| 20 | トークンが違う・期限切れ | でたらめなトークン | 401 |
| 21 | 形式が違う | Bearer が無い |
401 |
| 22 | 他人のデータ(BOLA) | bob のトークンで alice の ID を GET・PATCH・DELETE | 404(か 403)、alice のデータは変わらない |
| 23 | 他人のデータの項目 | 自分のデータに owner を書き込む |
422 か無視(API3) |
| 24 | 権限の無い機能 | 一般ユーザーで管理者 API | 403 |
| 25 | 一覧に他人のデータが混ざらない | 一覧を取得 | 自分のデータだけ |
冪等性・ページング・並び順
| No | 観点 | 送るもの | 期待する結果の例 |
|---|---|---|---|
| 26 | 同じ冪等キーで2回 POST | Idempotency-Key: k-001 を2回 |
2回とも同じ id、作られるのは1件 |
| 27 | 同じ冪等キーで中身が違う | 価格だけ変えて再送 | 422 |
| 28 | DELETE を2回 | 同じ ID | 1回目 204、2回目 404 |
| 29 | ページの範囲外 | limit=0・limit=101・offset=-1 |
422(か、上限に丸める) |
| 30 | ページをまたいだ抜け・重複 | 全ページを取得してつなげる | 件数が total と同じ、id が重複しない |
| 31 | 並び順 | sort=price、同じ価格のデータ |
価格順、同額は id 順で毎回同じ |
| 32 | 決めていない並び順 | sort=name |
422 |
同時更新・タイムアウト・量
| No | 観点 | 送るもの | 期待する結果の例 |
|---|---|---|---|
| 33 | 同時更新 | 同じ版(If-Match: "1")で2人が同時に PATCH |
片方 200、もう片方 412 |
| 34 | 版を付けない更新 | If-Match なし |
428 |
| 35 | タイムアウト | 遅い処理をクライアント側で打ち切る | クライアントがタイムアウトの例外を出す |
| 36 | 呼び出しの回数 | 短時間に大量に呼ぶ | 429(回数の上限を決めた場合) |
同値分割・境界値分析の考え方は、関連記事「同値分割・境界値分析|テストケース表とpytestコード例で解説」で、テスト技法の全体像は「テスト技法一覧|ブラックボックス・ホワイトボックスの種類と選び方早見表」で扱っています。
OWASP API Security Top 10 2023 との対応
OWASP API Security Top 10 2023 は、API に特有のセキュリティの弱点を10個にまとめた一覧です。 チェックリストの番号と対応させると、セキュリティの観点の抜けを確かめられます。
| ID | 項目(原文) | 中身(原文の要約) | チェックリスト |
|---|---|---|---|
| API1:2023 | Broken Object Level Authorization | 利用者が指定した ID で、他人のオブジェクトを扱えてしまう | No.22・25 |
| API2:2023 | Broken Authentication | 認証の不備で、トークンを盗まれたり、なりすまされたりする | No.19〜21 |
| API3:2023 | Broken Object Property Level Authorization | 項目単位の認可の不備(見せてはいけない項目を返す・変えてはいけない項目を変えられる) | No.11・23 |
| API4:2023 | Unrestricted Resource Consumption | 帯域・CPU・保存領域・SMS などの従量課金を使い尽くされる | No.18・29・36 |
| API5:2023 | Broken Function Level Authorization | 管理者の機能を一般ユーザーが呼べる | No.24 |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | 購入・投稿などの業務の流れを自動化で悪用される | No.36(業務ごとに回数を決める) |
| API7:2023 | Server Side Request Forgery | 利用者が指定した URL を、サーバーが確かめずに取りに行く | URL を受け取る API があれば追加 |
| API8:2023 | Security Misconfiguration | 設定の不備(エラーの詳細の出しすぎ、CORS など) | No.12〜17 のエラーの中身 |
| API9:2023 | Improper Inventory Management | 使わなくなった版の API・デバッグ用の API が残っている | API の一覧と、/v1 などの版ごとのテスト |
| API10:2023 | Unsafe Consumption of APIs | 外部 API から受け取ったデータを信じすぎる | 外部 API の応答の異常系(モック) |
Web アプリ全体の OWASP Top 10 は、関連記事「OWASP Top 10 2025|全10項目を脆弱コードと修正例で解説」で扱っています。
テスト対象の API(FastAPI)
確かめるために作った API です。商品(items)を、持ち主(owner)ごとに管理します。トークンは固定の3つ(alice・bob・admin)です。環境変数 VULN=1 で起動すると、持ち主の確認を外した(BOLA のある)版になります。
import os, time, threading
from typing import Literal
from fastapi import FastAPI, Header, HTTPException, Depends, Query, Response
from pydantic import BaseModel, Field, ConfigDict
app = FastAPI()
VULN = os.getenv("VULN") == "1" # 1 にすると、他人のデータを見られる(BOLA)版になる
TOKENS = {"token-alice": "alice", "token-bob": "bob", "token-admin": "admin"}
DB: dict[int, dict] = {}
IDEM: dict[tuple[str, str], tuple[dict, dict]] = {}
LOCK = threading.Lock()
NEXT = {"id": 1}
def current_user(authorization: str | None = Header(None)) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing token", headers={"WWW-Authenticate": "Bearer"})
user = TOKENS.get(authorization.removeprefix("Bearer "))
if not user:
raise HTTPException(401, "invalid token", headers={"WWW-Authenticate": "Bearer"})
return user
class ItemIn(BaseModel):
model_config = ConfigDict(extra="forbid") # owner などの余計な項目は 422
name: str = Field(min_length=1, max_length=50)
price: int = Field(ge=0, le=1_000_000)
class ItemPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(None, min_length=1, max_length=50)
price: int | None = Field(None, ge=0, le=1_000_000)
def get_owned(item_id: int, user: str) -> dict:
item = DB.get(item_id)
if item is None or (not VULN and item["owner"] != user):
raise HTTPException(404, "not found") # 他人のデータは「無い」と返す
return item
@app.post("/items", status_code=201)
def create(body: ItemIn, response: Response, user: str = Depends(current_user),
idempotency_key: str | None = Header(None)):
with LOCK:
if idempotency_key:
saved = IDEM.get((user, idempotency_key))
if saved:
if saved[0] != body.model_dump():
raise HTTPException(422, "idempotency key reused with different body")
response.headers["Idempotent-Replayed"] = "true"
return saved[1]
item = {"id": NEXT["id"], "owner": user, **body.model_dump(), "version": 1}
DB[item["id"]] = item
NEXT["id"] += 1
if idempotency_key:
IDEM[(user, idempotency_key)] = (body.model_dump(), item)
response.headers["Location"] = f"/items/{item['id']}"
return item
@app.get("/items")
def list_items(user: str = Depends(current_user),
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
sort: Literal["id", "-id", "price", "-price"] = "id"):
rows = [i for i in DB.values() if i["owner"] == user]
key = sort.lstrip("-")
rows.sort(key=lambda i: (i[key], i["id"]), reverse=sort.startswith("-"))
return {"items": rows[offset:offset + limit], "total": len(rows), "limit": limit, "offset": offset}
@app.get("/items/{item_id}")
def read(item_id: int, user: str = Depends(current_user)):
return get_owned(item_id, user)
@app.patch("/items/{item_id}")
def update(item_id: int, body: ItemPatch, user: str = Depends(current_user),
if_match: str | None = Header(None)):
with LOCK:
item = get_owned(item_id, user)
if if_match is None:
raise HTTPException(428, "If-Match required")
if if_match.strip('"') != str(item["version"]):
raise HTTPException(412, "version mismatch")
item.update(body.model_dump(exclude_none=True))
item["version"] += 1
return item
@app.delete("/items/{item_id}", status_code=204)
def delete(item_id: int, user: str = Depends(current_user)):
get_owned(item_id, user)
del DB[item_id]
@app.get("/admin/stats")
def stats(user: str = Depends(current_user)):
if user != "admin":
raise HTTPException(403, "forbidden")
return {"items": len(DB)}
@app.get("/slow")
def slow(sec: float = Query(1.0, ge=0, le=10)):
time.sleep(sec)
return {"slept": sec}
pip install fastapi uvicorn httpx pytest
uvicorn app:app --port 8061
curl での確かめ方
まず curl で1件ずつ叩いて、ステータスコードと本文を目で確かめます。 -i でヘッダーまで、-w '%{http_code}' でステータスコードだけを出せます。
正常系:作成
curl -i -X POST http://127.0.0.1:8061/items \
-H 'Authorization: Bearer token-alice' -H 'Content-Type: application/json' \
-d '{"name":"りんご🍎","price":120}'
HTTP/1.1 201 Created
content-type: application/json
location: /items/1
{"id":1,"owner":"alice","name":"りんご🍎","price":120,"version":1}
content-type は application/json で、charset は付きませんでした。 JSON は UTF-8 で送ることが RFC 8259 で決まっていて、application/json には charset の指定がありません。絵文字もそのまま返りました。
認証・認可
curl -i http://127.0.0.1:8061/items/1 # トークンなし
curl -w ' %{http_code}\n' http://127.0.0.1:8061/items/1 -H 'Authorization: Bearer token-bob' # 他人のデータ
curl -w ' %{http_code}\n' http://127.0.0.1:8061/admin/stats -H 'Authorization: Bearer token-bob'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
{"detail":"missing token"}
{"detail":"not found"} 404
{"detail":"forbidden"} 403
他人のデータに 404 を返すか 403 を返すかは、設計で決めます。 403 は「そのデータがある」ことを相手に教えるので、この API では 404 にしています。どちらにするかを API 設計書に書いておき、テストもそれに合わせます。
異常系:FastAPI が返したもの
| 送ったもの | ステータス | detail の type |
|---|---|---|
{"name":"","price":-1} |
422 | string_too_short・greater_than_equal(2件まとめて返る) |
{"name":"x","price":1,"owner":"bob"} |
422 | extra_forbidden |
{"name":"x",(壊れた JSON) |
422 | json_invalid |
{"name":"x","price":"abc"} |
422 | int_parsing |
Content-Type: text/plain で JSON |
422 | model_attributes_type |
| Shift_JIS のバイト列 | 400 | There was an error parsing the body |
PUT /items/1 |
405 | Method Not Allowed |
GET /items/abc |
422 | ― |
limit=0・limit=101・sort=name |
422 | greater_than_equal・less_than_equal・literal_error |
FastAPI は、壊れた JSON に 400 ではなく 422 を返しました。 フレームワークによって返すコードが違うので、「壊れた JSON は 400」と決めているなら、エラー処理を書き換えてからテストします。エラーの形の決め方は、関連記事「APIエラーレスポンス設計|RFC 9457準拠のJSON例と実装コード」で扱っています。
冪等性と同時更新
# 同じ冪等キーで2回送る
curl -i -X POST http://127.0.0.1:8061/items -H 'Authorization: Bearer token-alice' \
-H 'Idempotency-Key: k-001' -H 'Content-Type: application/json' -d '{"name":"みかん","price":80}'
# 版 1 を指定して更新(2回目は版が合わない)
curl -X PATCH http://127.0.0.1:8061/items/1 -H 'Authorization: Bearer token-alice' \
-H 'If-Match: "1"' -H 'Content-Type: application/json' -d '{"price":130}'
| 送ったもの | 結果 |
|---|---|
冪等キー k-001 で1回目 |
201、id: 2 |
| 同じキー・同じ中身で2回目 | 201、同じ id: 2、idempotent-replayed: true |
| 同じキー・価格だけ変えて3回目 | 422 |
If-Match なしで PATCH |
428 |
If-Match: "1" で PATCH |
200、version: 2 |
もう一度 If-Match: "1" で PATCH |
412(版が合わない) |
| 同じ ID を2回 DELETE | 204 → 404 |
冪等キー(Idempotency-Key)は、IETF の HTTPAPI ワーキンググループの草案(draft-ietf-httpapi-idempotency-key-header、2026年10月時点で RFC にはなっていない)で決められているヘッダーです。草案では、同じキーで中身が違うときは 422、同じキーの要求をまだ処理中のときは 409 を返すことを勧めています。冪等キーの作り方は、関連記事「冪等性とは|APIの冪等キー実装と二重登録を防ぐリトライ設計の一覧」で扱っています。
412(Precondition Failed) は RFC 9110 で、条件付きの要求(If-Match など)の条件が合わなかったときのコードです。428(Precondition Required) は RFC 6585 で、「サーバーが条件付きの要求を求めている」ことを表します。
タイムアウト
$ curl -m 1 'http://127.0.0.1:8061/slow?sec=3'
curl: (28) Operation timed out after 1000 milliseconds with 0 bytes received
-m(--max-time)で、全体の待ち時間の上限を秒で決められます。curl の使い方は、関連記事「curlコマンドの使い方|よく使うオプションとAPIの確認方法」で扱っています。
自動テストのコード(pytest+httpx)
curl で確かめた観点を、pytest のテストにしておくと、変更のたびに流せます。 httpx で本物の HTTP を送るので、FastAPI 以外(Spring Boot・Express など)で作った API にも、BASE_URL を変えればそのまま使えます。
import os, uuid
from concurrent.futures import ThreadPoolExecutor
import httpx
import pytest
BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8061")
ALICE = {"Authorization": "Bearer token-alice"}
BOB = {"Authorization": "Bearer token-bob"}
@pytest.fixture
def api():
with httpx.Client(base_url=BASE_URL, timeout=5.0) as c:
yield c
@pytest.fixture
def alice_item(api):
r = api.post("/items", headers=ALICE, json={"name": "テスト用", "price": 100})
assert r.status_code == 201
return r.json()
# ---------- 正常系:ステータス・形・ヘッダー ----------
def test_create_returns_201_with_location(api):
r = api.post("/items", headers=ALICE, json={"name": "りんご", "price": 120})
assert r.status_code == 201
assert r.headers["content-type"].startswith("application/json")
body = r.json()
assert set(body) == {"id", "owner", "name", "price", "version"}
assert isinstance(body["id"], int)
assert r.headers["location"] == f"/items/{body['id']}"
def test_unicode_round_trip(api):
name = "髙橋🍎カナ " # 異体字・絵文字・半角カナ・全角空白
r = api.post("/items", headers=ALICE, json={"name": name, "price": 1})
got = api.get(f"/items/{r.json()['id']}", headers=ALICE).json()
assert got["name"] == name
# ---------- 入力チェック・境界値 ----------
@pytest.mark.parametrize("name, price, expected", [
("a", 0, 201), # 下限ちょうど
("a" * 50, 1_000_000, 201), # 上限ちょうど
("", 0, 422), # 名前の下限 - 1
("a" * 51, 0, 422), # 名前の上限 + 1
("a", -1, 422), # 価格の下限 - 1
("a", 1_000_001, 422), # 価格の上限 + 1
("a", "abc", 422), # 型が違う
("a", None, 422), # null
])
def test_boundaries(api, name, price, expected):
r = api.post("/items", headers=ALICE, json={"name": name, "price": price})
assert r.status_code == expected
def test_unknown_field_is_rejected(api):
r = api.post("/items", headers=ALICE, json={"name": "x", "price": 1, "owner": "bob"})
assert r.status_code == 422
# ---------- 認証・認可 ----------
@pytest.mark.parametrize("headers", [{}, {"Authorization": "Bearer wrong"}, {"Authorization": "token-alice"}])
def test_requires_valid_token(api, headers):
r = api.get("/items", headers=headers)
assert r.status_code == 401
assert "bearer" in r.headers.get("www-authenticate", "").lower()
@pytest.mark.parametrize("method, extra", [
("GET", {}),
("PATCH", {"json": {"price": 1}, "headers": {"If-Match": '"1"'}}),
("DELETE", {}),
])
def test_bola_other_users_item(api, alice_item, method, extra):
headers = {**BOB, **extra.get("headers", {})}
r = api.request(method, f"/items/{alice_item['id']}", headers=headers, json=extra.get("json"))
assert r.status_code == 404 # 403 を返す設計もある(チームで統一する)
assert api.get(f"/items/{alice_item['id']}", headers=ALICE).json()["price"] == 100
def test_admin_only_endpoint(api):
assert api.get("/admin/stats", headers=BOB).status_code == 403
assert api.get("/admin/stats", headers={"Authorization": "Bearer token-admin"}).status_code == 200
# ---------- 冪等性 ----------
def test_idempotency_key_prevents_duplicates(api):
key = str(uuid.uuid4())
h = {**ALICE, "Idempotency-Key": key}
r1 = api.post("/items", headers=h, json={"name": "二重送信", "price": 10})
r2 = api.post("/items", headers=h, json={"name": "二重送信", "price": 10})
assert r1.status_code == r2.status_code == 201
assert r1.json()["id"] == r2.json()["id"]
assert r2.headers.get("idempotent-replayed") == "true"
r3 = api.post("/items", headers=h, json={"name": "二重送信", "price": 11})
assert r3.status_code == 422
def test_delete_twice(api, alice_item):
assert api.delete(f"/items/{alice_item['id']}", headers=ALICE).status_code == 204
assert api.delete(f"/items/{alice_item['id']}", headers=ALICE).status_code == 404
# ---------- ページング・並び順 ----------
def test_paging_covers_all_without_duplicates(api):
h = {"Authorization": "Bearer token-admin"}
for p in [300, 100, 200, 100, 500, 100, 400]:
api.post("/items", headers=h, json={"name": "p", "price": p})
total = api.get("/items", headers=h).json()["total"]
seen = []
for offset in range(0, total, 3):
page = api.get("/items", headers=h, params={"limit": 3, "offset": offset, "sort": "price"}).json()
seen += page["items"]
assert len(seen) == total
assert len({i["id"] for i in seen}) == total # 重複・抜けが無い
assert [i["price"] for i in seen] == sorted(i["price"] for i in seen)
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": 101}, {"offset": -1}, {"sort": "name"}])
def test_paging_params_out_of_range(api, params):
assert api.get("/items", headers=ALICE, params=params).status_code == 422
# ---------- 同時更新 ----------
def test_concurrent_update_only_one_wins(api, alice_item):
url = f"/items/{alice_item['id']}"
def patch(price):
with httpx.Client(base_url=BASE_URL) as c:
return c.patch(url, headers={**ALICE, "If-Match": '"1"'}, json={"price": price}).status_code
with ThreadPoolExecutor(2) as ex:
codes = sorted(ex.map(patch, [200, 300]))
assert codes == [200, 412]
assert api.get(url, headers=ALICE).json()["version"] == 2
def test_update_without_if_match(api, alice_item):
r = api.patch(f"/items/{alice_item['id']}", headers=ALICE, json={"price": 1})
assert r.status_code == 428
# ---------- タイムアウト ----------
def test_client_timeout(api):
with pytest.raises(httpx.ReadTimeout):
api.get("/slow", params={"sec": 2}, timeout=0.5)
uvicorn app:app --port 8061 &
pytest -q test_api.py
............................ [100%]
28 passed in 2.32s
同時更新のテストは、2つのスレッドから同じ版で PATCH を送り、ステータスコードが [200, 412] になることを確かめています。 3回続けて流して、3回とも成功しました。サーバーが版の確認と更新を1つの処理(この例ではロック、DB なら UPDATE ... WHERE version = ?)で行っていないと、両方が 200 になることがあります。
BOLA のある版で流すと、3件だけ落ちる
VULN=1 uvicorn app:app --port 8061 で、持ち主の確認を外した版を起動して、同じテストを流しました。
FAILED test_api.py::test_bola_other_users_item[GET-extra0] - assert 200 == 404
FAILED test_api.py::test_bola_other_users_item[PATCH-extra1] - assert 200 == 404
FAILED test_api.py::test_bola_other_users_item[DELETE-extra2] - assert 204 ==...
3 failed, 25 passed in 2.24s
ほかの 25件は、BOLA があっても全部成功しました。 一覧 API(自分のデータだけを返す)や、トークンなしのテストでは気づけません。「ログイン済みの別のユーザーで、他人の ID を指定する」テストを、ID を受け取るすべての API に用意するのが、BOLA を見つける近道です。
テストを書くときの工夫
| 工夫 | 理由 |
|---|---|
pytest.mark.parametrize で境界値を並べる |
「下限ちょうど・下限−1」を1行ずつ書け、抜けが表で見える |
| ユーザーを2人以上用意する | BOLA は1人のユーザーでは見つからない |
| 他人のデータに触ったあと、持ち主のデータが変わっていないかも見る | ステータスコードだけ見ると、「404 を返したが消えていた」を見落とす |
冪等キー・データの名前に uuid4() を使う |
何回流しても前のデータとぶつからない |
BASE_URL を環境変数にする |
開発・検証環境の両方に流せる |
pytest の fixture・パラメータ化は、関連記事「pytest 使い方|導入からfixture・mock・カバレッジまで実行結果つき」で扱っています。
よくある質問
Q. 422 と 400 はどう使い分けますか? A. RFC 9110 では、422 は「本文の形式(Content-Type)も構文も正しいが、中身を処理できない」ときのコードです。400 は、要求の形がおかしいなどで処理できないときに使います。FastAPI は入力チェックの失敗をまとめて 422 で返し、壊れた JSON も 422 でした。どちらにするかを API 設計書で決め、テストはそれに合わせます。ステータスコードの一覧は、関連記事「HTTPステータスコード 一覧|全コードの原因と対処・curl実例つき」で扱っています。
Q. 観点が多すぎて、全部の API に書ききれません。 A. 入力チェック・認証のように、全 API で同じ仕組みを使っているものは、代表の API で深く確かめ、ほかは代表的な1〜2件にします。BOLA(No.22)だけは、ID を受け取る API ごとに書きます。 持ち主の確認は API ごとに書かれていることが多く、1か所の抜けがそのまま穴になるからです。
Q. Postman や Playwright でも同じことができますか?
A. できます。Playwright には request という API テスト用の機能があり、画面のテストと同じ仕組みで書けます。この記事の観点表は、道具によらず使えます。
Q. FastAPI で API を作るところから知りたいです。 A. 関連記事「FastAPI入門|uvで作るCRUD API完成コードと実行結果」で扱っています。
まとめ
- API テストの観点は ステータス・形・入力チェック・境界値・認証・認可・冪等性・ページング・並び順・文字コード・タイムアウト・同時更新
- BOLA は「ログイン済みの別のユーザーで、他人の ID」 を指定して確かめる。この記事の再現では、ほかの 25件のテストは BOLA があっても成功した
- 他人のデータに触ったあとは、持ち主のデータが変わっていないかまで見る
- 壊れた JSON が 400 か 422 かはフレームワークで違う(FastAPI は 422)。設計で決めてテストする
- 冪等キーは同じキーで同じ結果、同時更新は
If-Matchで片方を 412 にする - OWASP API Security Top 10 2023 と対応させて、セキュリティの観点の抜けを確かめる
参考・出典
確認日はいずれも 2026年10月13日です。
- OWASP API Security Top 10 2023:https://owasp.org/API-Security/editions/2023/en/0x11-t10/
- RFC 9110 HTTP Semantics:https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 6585 Additional HTTP Status Codes(428・429):https://www.rfc-editor.org/rfc/rfc6585.html
- RFC 8259 The JavaScript Object Notation (JSON) Data Interchange Format:https://www.rfc-editor.org/rfc/rfc8259.html
- IETF draft-ietf-httpapi-idempotency-key-header-07:https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- MDN「422 Unprocessable Content」:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/422
- FastAPI ドキュメント「Handling Errors」:https://fastapi.tiangolo.com/tutorial/handling-errors/
- HTTPX ドキュメント「Timeouts」:https://www.python-httpx.org/advanced/timeouts/
- pytest ドキュメント「How to parametrize fixtures and test functions」:https://docs.pytest.org/en/stable/how-to/parametrize.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「APIテストの観点チェックリスト|正常系・異常系・認可・境界値」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。