この記事の対象読者
- OpenAPI 定義を先に書いて、その定義どおりに API を実装しているエンジニア
- FastAPI や Express などで REST API を書いた経験があり、pytest 等でユニットテストを書いている中級者
想定しているのは「スキーマ駆動でやっているつもりだが、定義と実装が本当に一致しているか確かめる手段を持っていない」という状態の方です。OpenAPI そのものの入門記事ではありません。
この記事で得られること
- ユニットテストのカバレッジ100%が、API の契約遵守を一切保証しないことの実測データ
- OpenAPI 定義と実装のズレを機械的に検出する手順と、そのままコピーできるコマンド
- ズレを分類して、実装を直すべきか定義を直すべきかを判断する基準
- ステータスコードの不一致が、その裏側の違反を丸ごと隠してしまう仕組みと、その実測
この記事で扱わないこと
- OpenAPI 3.0 の書き方そのもの
- 認証つき API への適用
- 負荷試験、性能測定
1. 発端: テストは全部グリーンだった
在庫管理 API を題材にします。設計を先に決め、openapi.yaml をフロントエンドと合意してから、サーバ側を実装するという、よくある進め方です。
実装が終わり、ユニットテストを 8 本書きました。結果はこうです。
$ python -m coverage run --branch -m pytest -q test_app_v1.py
........ [100%]
8 passed in 0.65s
$ python -m coverage report -m --include="app_v1.py"
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------
app_v1.py 46 0 8 0 100%
-------------------------------------------------------
TOTAL 46 0 8 0 100%
8 本全部グリーン。行カバレッジ 100%。分岐カバレッジも 100%。
この状態でマージしました。そして、この API は openapi.yaml に対して 21 件の契約違反 を抱えていました。しかも後で分かったのですが、21 件というのは全体像ではありませんでした。
設計図どおりに建てたつもりの建物が、完了検査で 21 箇所の是正指示を受け、さらに検査対象にすらなっていなかった箇所が別にあった、という状態です。
本記事は、その全過程の記録です。使ったコードとレポートは全て実際に手元で実行したもので、末尾に再現手順を置いています。
題材のコードは意図的に壊してあるわけではありません。OpenAPI 定義を渡してサーバ実装を書くときに実際によく出るズレを、筆者が過去に踏んだものから 9 種類選んで再構成しました。どれも「動くコード」であり、単体では誰も間違いに気づけません。
2. 図面と現物: スキーマ駆動開発で何がズレるのか
この記事では最後まで、建築の比喩を使います。
| 開発の要素 | 建築でいうと |
|---|---|
| OpenAPI 定義 | 確認申請に出した設計図 |
| 実装 | 実際に建った建物 |
| ユニットテスト | 施工者が自分で行う内覧 |
| schemathesis | 設計図を持って現場に来る完了検査 |
| 契約違反 | 図面と現物の不一致に対する是正指示 |
このセクションで押さえてほしいのは、ユニットテストは内覧であって完了検査ではない、という一点です。
ユニットテストは実装を見ながら書きます。「この関数はこう返すから、こう assert する」という順番です。つまりテストは実装の写像であって、設計図の写像ではありません。施工者が自分で建てた建物を自分で見て回っても、図面と照らし合わせなければズレは見つかりません。
点線が問題です。設計図と現物を直接突き合わせる線が、どこにもありません。この線を引くのが完了検査、すなわちコントラクトテストです。
3. 完了検査を呼ぶ: schemathesis とは何者か
schemathesis は、OpenAPI または GraphQL のスキーマから自動的にテストケースを生成し、実際に動いている API に投げて応答を検証するツールです。2019 年から開発が続いており、ICSE 2022 で査読論文も出ています。
論文の abstract から、該当箇所を引用します。
We construct the most comprehensive evaluation of web API fuzzers to date, running eight fuzzers against sixteen real-world open source web services. OpenAPI schemas found in the wild have a long tail of rare features and complex structures. Of the tools we evaluated, Schemathesis was the only one to handle more than two-thirds of our target services without a fatal internal error. Schemathesis finds 1.4× to 4.5× more unique defects than the respectively second-best fuzzer for each target, and is the only fuzzer to find defects in four targets.
和訳:
我々は現時点で最も包括的な Web API ファザーの評価を構築し、8 つのファザーを 16 の実在するオープンソース Web サービスに対して実行した。実世界の OpenAPI スキーマには、稀な機能と複雑な構造のロングテールが存在する。評価したツールのうち、対象サービスの 3 分の 2 を超える数を致命的な内部エラーなしに処理できたのは Schemathesis だけであった。Schemathesis は各対象について、2 番目に優秀なファザーと比べて 1.4 倍から 4.5 倍の固有欠陥を発見し、4 つの対象では欠陥を発見した唯一のファザーであった。
出典: Hatfield-Dodds, Z. and Dygalo, D., "Deriving Semantics-Aware Fuzzers from Web API Schemas", arXiv:2112.10328, 2021年12月投稿 / ICSE 2022 掲載。
検査員には、何をチェックするかの項目表があります。schemathesis の組み込みチェックのうち、この記事で登場するものを公式ドキュメントの原文とともに挙げます。
| チェック名 | 公式の説明(原文) | 和訳 |
|---|---|---|
not_a_server_error |
Catches server-side errors (5xx status codes) and GraphQL errors arrays. |
サーバ側エラー、すなわち 5xx ステータスコードと GraphQL の errors 配列を捕捉する |
status_code_conformance |
Verifies the response status code is documented in the schema for the operation (or a default response is defined). |
応答のステータスコードが、そのオペレーションのスキーマに記載されているか、あるいは default 応答が定義されているかを検証する |
response_schema_conformance |
Validates the response body against its JSON Schema definition. Catches format mismatches, missing required properties, type errors, and more. | 応答ボディをその JSON Schema 定義に照らして検証する。形式の不一致、必須プロパティの欠落、型エラーなどを捕捉する |
negative_data_rejection |
Verifies the API properly rejects invalid input data. | API が不正な入力データを適切に拒否することを検証する |
出典: Schemathesis 公式ドキュメント Checks リファレンス。各チェックが何を見ているかが一覧で定義されています。
つまり検査員は、図面に書いてある部屋数、寸法、仕様を、現物と 1 つずつ照合していきます。内覧では絶対に見つからない種類のズレが、ここで出ます。
4. 1 回目の完了検査
サーバを起動して、検査員を呼びます。
python -m uvicorn app_v1:app --host 127.0.0.1 --port 8030 &
schemathesis run openapi_v1.yaml \
--url http://127.0.0.1:8030 \
--checks all \
--continue-on-failure \
--phases examples,coverage,fuzzing \
--seed 42
--seed 42 を付けているのは再現性のためです。生成されるテストケースが毎回同じになるので、修正の前後を同じ条件で比較できます。この記事の数値はすべて、この seed を固定した状態で取っています。
結果です。
Failures:
❌ Server error: 2
❌ Response violates schema: 8
❌ API accepted schema-violating request: 2
❌ Undocumented HTTP status code: 9
Test cases:
401 generated, 12 found 21 unique failures
Seed: 42
============================= 21 failures in 3.87s =============================
4 秒で 21 件。設計図を持った検査員は、施工者が 1 時間かけて見て回っても気づかなかったものを、この速度で出してきます。
401 generated と 12 found と 21 unique failures は、それぞれ別の数です。401 は生成したテストケースの総数、12 はそのうち違反を踏んだケース数、21 は違反の種類の数です。1 つのケースが複数種類の違反を同時に踏むことがあるので、21 の方が 12 より大きくなります。
5. ここまでのまとめ
ここまでで確認できたことを整理します。
- ユニットテスト 8 本は全部グリーンで、行・分岐カバレッジともに 100% だった
- 同じコードに対して、OpenAPI 定義を基準にした検査をかけると 21 件の違反が出た
- カバレッジが測っているのは「コードのどこを通ったか」であり、「通った結果が契約どおりか」ではない
内覧の歩数記録が満点でも、図面との照合は 1 回もしていない、ということです。次のセクションから、21 件の中身に入ります。
6. 是正指示を分類する
21 件をそのまま眺めても手が動きません。どちらを直すのか で分けます。分けた結果は 3 つの山になりました。
| 分類 | 件数 | 直す対象 |
|---|---|---|
| A. 現物が図面に違反している | 14 | 実装だけ |
| B. 図面に部屋を描き忘れていた | 5 | 定義だけ |
| C. 図面が実態に追いついていなかった | 2 | 定義、または定義と実装の両方 |
6-1. A: 現物が図面に違反している(14 件 / 症状としては 9 種類)
| # | エンドポイント | 症状 | 検出したチェック | 件数 |
|---|---|---|---|---|
| 1 | GET /items/{id} |
存在しない ID で KeyError が出て 500 になる。図面には 404 と書いてある |
not_a_server_error と status_code_conformance
|
2 |
| 2 | DELETE /items/{id} |
同上。存在しない ID で 500 | 同上 | 2 |
| 3 | DELETE /items/{id} |
成功時に 200 とボディを返す。図面は 204 No Content | status_code_conformance |
1 |
| 4 | POST /items |
成功時に 200 を返す。図面は 201 Created | status_code_conformance |
1 |
| 5 | GET /items |
limit=101 を受理する。図面の maximum は 100 |
negative_data_rejection |
1 |
| 6 | GET /items |
返却件数が limit より 1 件多く、maxItems: 100 を超える |
response_schema_conformance |
1 |
| 7 |
GET /items と GET /items/{id}
|
price が割引適用時に 3582.0 になる。図面は type: integer
|
response_schema_conformance |
2 |
| 8 | POST /items |
定義に無いプロパティを含むリクエストを受理する | negative_data_rejection |
1 |
| 9 | GET /items |
POST /items の入力検証漏れで保存された不正データが一覧に漏れ出す。name が空文字 / 61 文字、price が -1
|
response_schema_conformance |
3 |
7 番の price が個人的には一番痛い種類です。実装はこう書いてありました。
LOW_STOCK_DISCOUNT = 0.9
price = row["price"] * LOW_STOCK_DISCOUNT if status == "low_stock" else row["price"]
3980 * 0.9 は Python では 3582.0 です。float です。JSON にすると 3582.0 と出力され、type: integer に違反します。ユニットテストは pytest.approx(3582.0) で assert していたので、当然通ります。人間の目でレスポンスを見ても、3582.0 の末尾の .0 に気づける人は多くありません。
検査員のレポートはこう出ます。
- Response violates schema
3582.0 is not of type "integer"
Value:
3582.0
Reproduce with:
curl -X GET http://127.0.0.1:8030/items/2
再現用の curl まで付いてくるのが実務ではありがたいところです。
9 番の出どころにも注目してください。POST /items のリクエストモデルには min_length も max_length も ge も書いていませんでした。定義側には全部書いてあるのにです。その結果、空文字や 61 文字の name、-1 の price がそのまま保存され、後から GET /items のレスポンス違反 3 件として出てきました。
入口の検証漏れは、必ず別の場所で出口の違反に化けます。 受理した瞬間は 200 が返るだけで、何も起きないのが厄介なところです。
6-2. B: 図面に部屋を描き忘れていた(5 件)
5 件は全て Undocumented HTTP status code で、内訳は 422 が 4 件、400 が 1 件です。
FastAPI はリクエストのバリデーションに失敗すると 422 Unprocessable Content を返しますが、openapi.yaml にはその応答を一切書いていませんでした。ボディが JSON として壊れている場合の 400 も同様です。
OpenAPI 仕様は、この点についてこう書いています。
The documentation is not necessarily expected to cover all possible HTTP response codes because they may not be known in advance. However, documentation is expected to cover a successful operation response and any known errors.
和訳:
ドキュメントは、事前に把握できない可能性があるため、必ずしも起こりうる全ての HTTP 応答コードを網羅することを期待されていない。しかしながら、成功時の応答と、既知のエラーについては網羅していることが期待される。
出典: OpenAPI Specification 3.0.3, Responses Object。応答オブジェクトが何を記載すべきかを定めた箇所です。
422 は「既知のエラー」です。書いていなかったこちらの落ち度でした。図面に部屋を描き忘れていたのです。
6-3. C: 図面が実態に追いついていなかった(2 件)
残る 2 件は、どちらとも言い切れないものでした。
| 症状 | 検討 | 直したもの |
|---|---|---|
status に discontinued が出る。図面の enum に無い |
廃番という状態は業務上実在する。図面に書き忘れていた | 図面だけ。enum に discontinued を追加し、実装は 1 文字も変えていない |
未更新の商品で updated_at キーが消える。図面では required
|
未更新という状態は実在するが、キーごと消すのは契約違反 | 両方。図面を nullable: true にし、実装は必ずキーを出すよう直した |
この 2 件は「実装が悪い」と決めつけて直すと、業務上必要な状態を潰してしまいます。検査で落ちたときに、まず現物が悪いのか図面が古いのかを判断する工程が要る、という実例です。
7. 図面だけ直しても、現物は直らない
分類が正しいかどうかを、実験で確かめました。openapi.yaml に 400 と 422 の応答だけを追記し、既存のスキーマ定義には一切触れず、実装も一切変えずに、もう一度検査を通します。追加したのは応答の記述と、そこから参照する ValidationError スキーマだけです。
schemathesis run openapi_v1_docs.yaml \
--url http://127.0.0.1:8010 \
--checks all --continue-on-failure \
--phases examples,coverage,fuzzing --seed 42
結果を、修正前と並べます。
| チェック | 修正前 | 400 と 422 を追記後 |
|---|---|---|
| Server error | 2 | 2 |
| Response violates schema | 8 | 8 |
| API accepted schema-violating request | 2 | 2 |
| Undocumented HTTP status code | 9 | 4 |
| 合計 | 21 | 16 |
減ったのは Undocumented HTTP status code の 5 件だけです。他の 3 種類は 1 件も動いていません。§6-2 で「図面が悪い」と分類した 5 件と完全に一致しました。分類の裏取りができたことになります。
そして残る 16 件は、書類をいくら整えても 1 件も減りません。
このパターンは実務でも起きます。検査で落ちたときに、実装を直さずに定義側を実装に合わせて書き換えると、レポートは緑になります。ただしそれは、図面を現物に合わせて描き直したのと同じです。フロントエンドと合意した契約が、黙って変わります。§6-3 の 2 件のように、図面を直すのが正解の場合もあります。両者を区別できる根拠を持っているかどうかが分かれ目です。
8. 図面に載っていない部屋は、検査されない
ここが今回いちばん重要な発見です。
POST /items の実装は、レスポンスに quantity という余計なフィールドを混ぜ、price を float で返し、名前の長さも価格の下限も検証していませんでした。Item スキーマには additionalProperties: false と各種制約を書いてあるので、明確な違反です。
JSON Schema の定義を確認します。
Setting the
additionalPropertiesschema tofalsemeans no additional properties will be allowed.
和訳:
additionalPropertiesスキーマをfalseに設定することは、追加のプロパティが一切許可されないことを意味する。
出典: JSON Schema 公式ガイド Object セクション。additionalProperties が何を制御するかを説明した箇所です。
ところが、1 回目の検査で POST /items のレスポンスに対する違反は 1 件も報告されていませんでした。
理由は、同じエンドポイントに別の違反があったからです。図面には 201 Created と書いてあるのに、実装は 200 を返していました。status_code_conformance は 200 を「図面に載っていないステータスコード」として弾きます。そして 載っていないステータスコードには、照合すべきレスポンススキーマが存在しません。 検査員は「この部屋は図面にありません」と書いて、部屋の中には入らないのです。
これを実験で確かめました。実装は一切変えず、openapi.yaml の POST /items の応答を 201 から 200 に書き換えるだけで、もう一度検査します。
schemathesis run openapi_v1_post200.yaml \
--url http://127.0.0.1:8011 \
--checks all --continue-on-failure \
--phases examples,coverage,fuzzing --seed 42
結果が変わりました。
| チェック | 修正前 | POST を 200 と書き換え後 |
|---|---|---|
| Server error | 2 | 2 |
| Response violates schema | 8 | 13 |
| API accepted schema-violating request | 2 | 2 |
| Undocumented HTTP status code | 9 | 8 |
| 合計 | 21 | 25 |
Undocumented HTTP status code が 1 件減り、代わりに Response violates schema が 5 件増えました。増えた 5 件はすべて POST /items のレスポンスに対するもので、内訳はこうです。
Additional properties are not allowed ('quantity' was unexpected)
0.0 is not of type "integer"
-1 is less than the minimum of 0
"0000000000000000000000000000000000000000000000000000000000000" is longer than 60 characters
"" is shorter than 1 character
つまり、たった 1 つのステータスコードの食い違いの裏に、5 件の違反が完全に隠れていました。 1 回目に見えていた 21 件は全体像ではありません。
ここから導ける、もう少し一般的な話があります。レポートの件数は違反の総数ではなく、下限です。 schemathesis は同じオペレーションの同じチェックについて、失敗を 1 件に集約して報告します。手前の 1 件を潰すまで、その後ろに並んでいる違反は表に出てきません。§10-2 で、これを実際に踏みます。
実務上の教訓は 1 つです。ステータスコードの不一致は、必ず最優先で潰す。 それを直すまで、その応答のボディは検査対象にすらなっていません。是正指示の件数が減ったことと、建物が図面どおりになったことは別の話です。
9. 実装を直す
現物を図面に合わせます。ここからは実際のコードです。先ほどまでの比喩でいえば、是正指示書を持って現場をやり直す工程にあたります。
クリックで修正後の実装を展開(STORE の定義など初期化部分は省略した抜粋です)
"""Inventory API - implementation v2"""
from fastapi import FastAPI, HTTPException, Query, Path
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
LOW_STOCK_THRESHOLD = 5
def resolve_status(row: dict) -> str:
if row["quantity"] == 0:
return "out_of_stock" if row["restocking"] else "discontinued"
if row["quantity"] < LOW_STOCK_THRESHOLD:
return "low_stock"
return "in_stock"
def discounted(price: int) -> int:
# FIX 1: 割引後も integer を保つ。四捨五入を整数演算で行う
return (price * 9 + 5) // 10
def to_item(item_id: int, row: dict) -> dict:
status = resolve_status(row)
price = discounted(row["price"]) if status == "low_stock" else row["price"]
# FIX 2: updated_at は未設定でも必ずキーを出す
return {"id": item_id, "name": row["name"], "price": price,
"status": status, "updated_at": row["updated_at"]}
class ItemCreate(BaseModel):
# FIX 8: additionalProperties: false を extra="forbid" で表現する
model_config = ConfigDict(extra="forbid")
# FIX 3 と FIX 9: 定義の制約を実装に写し、型変換を厳格にする
name: StrictStr = Field(min_length=1, max_length=60)
price: StrictInt = Field(ge=0, le=1_000_000)
quantity: StrictInt = Field(ge=0, le=10_000)
@app.get("/items")
def list_items(limit: int = Query(20, ge=1, le=100)):
# FIX 4: off-by-one を除去し、limit の上限を実装でも効かせる
ids = sorted(STORE)[:limit]
return {"items": [to_item(i, STORE[i]) for i in ids], "total": len(STORE)}
@app.post("/items", status_code=201)
def create_item(body: ItemCreate):
# FIX 5: 201 を返す。レスポンスに quantity を混ぜない
global _next_id
item_id = _next_id
_next_id += 1
STORE[item_id] = {"name": body.name, "price": body.price,
"quantity": body.quantity, "restocking": True,
"updated_at": "2026-08-19T00:00:00Z"}
return JSONResponse(to_item(item_id, STORE[item_id]), status_code=201)
@app.get("/items/{item_id}")
def get_item(item_id: int = Path(ge=1, le=1000)):
# FIX 6: 未知の ID は 404
row = STORE.get(item_id)
if row is None:
raise HTTPException(status_code=404, detail="item not found")
return to_item(item_id, row)
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int = Path(ge=1, le=1000)):
# FIX 7: 204 No Content。未知の ID は 404
if item_id not in STORE:
raise HTTPException(status_code=404, detail="item not found")
del STORE[item_id]
return Response(status_code=204)
図面側も §6-3 のとおり 2 箇所直しました。status の enum に discontinued を追加し、updated_at を nullable: true にしたうえで required には残しています。
OpenAPI 3.0 の nullable は、JSON Schema の type: ["string", "null"] とは書き方が違うので注意が必要です。仕様の該当箇所を引用します。
nullable |
boolean| Atruevalue adds"null"to the allowed type specified by thetypekeyword, only iftypeis explicitly defined within the same Schema Object.
和訳:
nullable | 真偽値 |
trueの値は、同じ Schema Object 内でtypeが明示的に定義されている場合に限り、typeキーワードで指定された許容型に"null"を追加する。
出典: OpenAPI Specification 3.0.3 の Schema Object 固定フィールド。nullable の効果と適用条件を定めた行です。
ここで重要なのは、required と nullable は別の話だということです。required はキーが存在することを要求し、nullable は値が null でよいことを許します。今回の違反は「キーごと消えていた」ものなので、nullable にするだけでは直りません。実装側で必ずキーを出すようにするのが FIX 2 です。
10. Pydantic の既定値が、図面と逆を向いていた 2 箇所
FIX 8 と FIX 9 は、FastAPI と Pydantic を使っている人にだけ効く話です。ただし実務では確実に踏みます。どちらも「図面にはっきり書いてあるのに、実装側の既定値がその逆になっている」という形をしています。
10-1. additionalProperties: false は Pydantic に伝わらない
§6-1 の表の 8 番として最初から出ていた違反です。
- API accepted schema-violating request
Invalid data should have been rejected
Expected: 400, 401, 403, 404, 405, 406, 409, 422, 428, 5xx
Invalid component: in body - object with unexpected properties
Reproduce with:
curl -X POST -H 'Content-Type: application/json' \
-d '{"name": "0", "price": 0, "quantity": 0, "x-schemathesis-unknown-property": 42}' \
http://127.0.0.1:8030/items
openapi.yaml の ItemCreate には additionalProperties: false を書いてあります。しかし Pydantic の既定動作は、知らないフィールドを黙って無視することです。図面には「これ以外の部屋を作ってはならない」と書いてあるのに、現場は余計な部屋を作られても何も言わなかったわけです。
class ItemCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
これで 422 を返すようになりました。
10-2. lax モードは false を 0 として受理する
こちらは、FIX 1 から FIX 8 までを全部入れてから検査を通して、初めて表に出てきた 1 件です。
- API accepted schema-violating request
Invalid data should have been rejected
Expected: 400, 401, 403, 404, 405, 406, 409, 422, 428, 5xx
Invalid component: in body - quantity: Incorrect type
Reproduce with:
curl -X POST -H 'Content-Type: application/json' \
-d '{"name": "0", "price": 0, "quantity": false}' \
http://127.0.0.1:8021/items
quantity: false が通っていました。JSON Schema では真偽値は整数ではありませんが、Pydantic の既定である lax モードは変換します。公式ドキュメントの原文です。
By default, Pydantic will attempt to coerce values to the desired type when possible. For example, you can pass the string
'123'as the input for the int number type, and it will be converted to the value123.
和訳:
既定では、Pydantic は可能な場合に値を目的の型へ強制変換しようと試みる。たとえば int 型の入力として文字列
'123'を渡すことができ、それは値123に変換される。
When strict mode is enabled, Pydantic will be much less lenient when coercing data, and will instead error if the data is not of the correct type.
和訳:
strict モードが有効な場合、Pydantic はデータの強制変換においてはるかに寛容でなくなり、データが正しい型でなければ代わりにエラーを発生させる。
出典: Pydantic 公式ドキュメント Strict Mode。既定の lax モードと strict モードの挙動差を定義した箇所です。
Python では bool が int のサブクラスなので、False はそのまま 0 として通ります。API の契約としては型違反です。StrictInt と StrictStr に変えて解決しました。
なぜ最初から出てこなかったのかというと、quantity: false のリクエスト自体は 1 回目の検査でも送られており、実装はそれを 201 で受理していたからです。ただし API accepted schema-violating request の失敗は、同じオペレーションについて 1 件に集約されて報告されます。10-1 の「余計なプロパティを受理する」がその枠を占めていたため、10-2 は表に出ていませんでした。
実際、FIX 1 から 7 までを入れた状態で検査すると残り 1 件は 10-1、FIX 8 まで入れると残り 1 件は 10-2 に入れ替わります。緑になるまで回すという運用が要る理由がこれです。1 回で全部出てくるとは限りません。
11. 最終検査
図面と現物を両方直して、もう一度検査を通しました。
Test Phases:
⏭ Examples
✅ Coverage
✅ Fuzzing
⏭ Stateful (disabled)
Test cases:
400 generated, 400 passed
Seed: 42
=========================== No issues found in 3.63s ===========================
400 ケース全通過、3.63 秒。完了検査済みの建物になりました。
検査 1 回の流れを 1 枚にまとめます。
12. カバレッジは、結局何を測っていたのか
最後に 1 つだけ、居心地の悪い実験をしました。
修正後の実装に対して、修正前に書いたユニットテスト 8 本をそのままかけました。
FAILED test_ai_tests_on_v2.py::test_create_item - assert 201 == 200
FAILED test_ai_tests_on_v2.py::test_delete_item - assert 204 == 200
2 failed, 6 passed in 0.46s
2 本落ちました。落ちた理由は、テストがこう書いてあったからです。
def test_create_item():
res = client.post("/items", json={"name": "新商品", "price": 500, "quantity": 10})
assert res.status_code == 200 # 図面には 201 と書いてある
契約どおりに直したら、テストの方が落ちたわけです。このテストは仕様を検証していたのではなく、実装の現状を写経していただけでした。
行カバレッジ 100%、分岐カバレッジ 100% というのは、内覧で全ての部屋を歩いたという歩数記録にすぎません。歩いた部屋が図面どおりの寸法だったかは、一言も保証していないのです。
- カバレッジが測るもの: 実装のどの行を通ったか
- コントラクトテストが測るもの: 通った結果が契約どおりだったか
この 2 つは直交しています。片方が 100% でも、もう片方は 0% でありえます。今回がまさにそれでした。
13. CI に組み込む
完了検査は、竣工時に 1 回やって終わりではありません。CI に置きます。
name: contract-test
on: [push, pull_request]
jobs:
schemathesis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt schemathesis
- name: start API
run: |
python -m uvicorn app:app --host 127.0.0.1 --port 8000 &
for i in $(seq 1 30); do
curl -sf "http://127.0.0.1:8000/items?limit=1" > /dev/null && break
sleep 1
done
- name: contract test
run: |
schemathesis run openapi.yaml \
--url http://127.0.0.1:8000 \
--checks all \
--continue-on-failure \
--seed 42
疎通確認は、実際に定義してあるエンドポイントを叩いてください。ヘルスチェック用のパスを openapi.yaml に書いていない場合、そこを叩くと schemathesis 側では検査対象にならないのに、CI だけがそのパスに依存することになります。
導入時の実務的な注意点を 3 つ挙げます。
| 注意点 | 理由 | 対処 |
|---|---|---|
--seed を固定する |
固定しないと、生成データが変わって CI が不安定になる | リリースごとに seed を見直す運用にする |
最初は --checks を絞る |
既存 API にいきなり all をかけると数百件出て手が止まる |
not_a_server_error と status_code_conformance から始める |
| 状態を変える操作に注意 |
DELETE が実際に実行されるので、テスト用インスタンスを使う |
ジョブごとにサーバを起動し直す |
2 番目に順序の理由があります。§8 のとおり、ステータスコードの不一致を先に潰さないと、その裏のレスポンス違反は観測すらできません。段階導入するなら次の順です。
--checks not_a_server_error,status_code_conformance-
response_schema_conformanceを追加 -
negative_data_rejectionを追加 -
--checks allを CI に固定
今回は 4 エンドポイントの小さな API で、1 回目の報告が 21 件、隠れていた分がさらに 5 件でした。100 エンドポイントの既存 API に --checks all をかけたら何が起きるかは、想像がつくと思います。図面と現物のズレは、放置した年数の分だけ溜まっています。
再現手順
クリックで再現手順を展開
環境は Python 3.11.15 / FastAPI 0.141.1 / schemathesis 4.24.3 / pytest 9.1.1 / coverage 7.15.4 / hypothesis 6.165.10 です。
pip install fastapi "uvicorn[standard]" schemathesis pytest httpx coverage
# 1. ユニットテスト(全部グリーン・カバレッジ100%)
python -m coverage run --branch -m pytest -q test_app_v1.py
python -m coverage report -m --include="app_v1.py"
# 2. 1回目の完了検査(21件)
python -m uvicorn app_v1:app --host 127.0.0.1 --port 8030 &
schemathesis run openapi_v1.yaml --url http://127.0.0.1:8030 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 3. 図面に 400 と 422 だけ追記した状態(16件)
python -m uvicorn app_v1:app --host 127.0.0.1 --port 8010 &
schemathesis run openapi_v1_docs.yaml --url http://127.0.0.1:8010 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 4. POST の応答を 201 から 200 に書き換えて、隠れていた違反を出す(25件)
python -m uvicorn app_v1:app --host 127.0.0.1 --port 8011 &
schemathesis run openapi_v1_post200.yaml --url http://127.0.0.1:8011 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 5. FIX 1 から 7 までを入れた状態(残り1件は10-1の違反)
python -m uvicorn app_v2_step1:app --host 127.0.0.1 --port 8020 &
schemathesis run openapi_v2.yaml --url http://127.0.0.1:8020 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 6. FIX 8 まで入れた状態(残り1件が10-2の違反に入れ替わる)
python -m uvicorn app_v2_step2:app --host 127.0.0.1 --port 8021 &
schemathesis run openapi_v2.yaml --url http://127.0.0.1:8021 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 7. 最終検査(0件)
python -m uvicorn app_v2:app --host 127.0.0.1 --port 8003 &
schemathesis run openapi_v2.yaml --url http://127.0.0.1:8003 \
--checks all --continue-on-failure --phases examples,coverage,fuzzing --seed 42
# 8. 修正後の実装に、修正前のユニットテストをかける(2本落ちる)
sed 's/app_v1/app_v2/g' test_app_v1.py > test_ai_tests_on_v2.py
python -m pytest -q test_ai_tests_on_v2.py
app_v2_step1.py は FIX 1 から 7 まで、app_v2_step2.py は FIX 8 までを入れた途中状態です。
ポート番号を分けているのは、DELETE で在庫が減った状態を次の実行に持ち越さないためです。同じポートを使い回す場合は、実行ごとにサーバを起動し直してください。
用語集
| 用語 | 意味 |
|---|---|
| コントラクトテスト | API の定義(契約)と実装が一致していることを検証するテスト。この記事では OpenAPI 定義が契約にあたる |
| スキーマ駆動開発 | 先に API 定義を決め、その定義からサーバとクライアントを実装する進め方。設計図を先に描く方式 |
| ファジング | 入力を自動生成して異常を探す手法。schemathesis はスキーマの制約を使って、境界値や不正値を体系的に生成する |
additionalProperties |
JSON Schema のキーワード。false にすると、定義されていないプロパティを一切許可しない |
nullable |
OpenAPI 3.0 のキーワード。type が明示されている場合に限り、その型に null を追加で許容する |
| lax モード / strict モード | Pydantic の型変換方針。lax は '123' を 123 に変換し、strict は型が違えばエラーにする |
| 204 No Content | 処理は成功したが返すボディが無いことを示す HTTP ステータスコード |
まとめ
- ユニットテストのカバレッジ 100% と、API 契約の遵守は独立している。今回は前者が満点で後者が 21 件の違反だった
- 違反は「実装だけ直す」「定義だけ直す」「両方直す」の 3 つに分けて処理する。定義に 400 と 422 を追記しただけでは、残る 16 件は 1 件も減らなかった
- ステータスコードの不一致は最優先で潰す。不一致のまま放置すると、その応答のボディはスキーマ検証されない。今回は 1 つの不一致の裏に 5 件が隠れていた。レポートの件数は違反の総数ではなく下限だと考えるべき
- FastAPI と Pydantic では、
additionalProperties: falseと JSON Schema の型厳密性が既定では実装に伝わらない。extra="forbid"とStrictIntが必要 - 実装を契約どおりに直すと、実装を写経したユニットテストは落ちる。落ちたテストの本数は、そのテストが仕様を見ていなかった量そのもの
設計図を持った検査員を、4 秒で呼べます。呼ばない理由はもう無いと思っています。
参考文献
- Deriving Semantics-Aware Fuzzers from Web API Schemas (arXiv:2112.10328) — schemathesis の設計思想と、8 つのファザーを 16 の実サービスで比較した評価結果が書かれている論文。ICSE 2022 掲載
- Schemathesis 公式ドキュメント Checks リファレンス — 組み込みチェックの一覧と、各チェックが何を検証するかの定義が載っている
-
Schemathesis 公式ドキュメント CLI リファレンス —
--phasesや--checksなどのオプション一覧が載っている - OpenAPI Specification 3.0.3 — Responses Object の項に、成功応答と既知のエラーを記載すべきという要求が書かれている
-
OpenAPI Specification 3.0.3 (Markdown 版) — Schema Object の固定フィールド表に
nullableとadditionalPropertiesの定義が載っている -
JSON Schema 公式ガイド Object セクション —
additionalPropertiesとrequiredの意味が説明されている - Pydantic 公式ドキュメント Strict Mode — 既定の lax モードが行う型変換と、strict モードとの違いが説明されている
関連記事
より実装寄りの手順と、導入時に踏んだエラーの記録は Zenn 側に書きました。