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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?