SchemathesisでOpenAPIスキーマからプロパティベーステスト(PBT)を行ってみる
はじめに
データアナリティクス事業本部のkobayashiです。
Web APIの開発において、「正常系のテストは書いたけど、異常系は...」「境界値のテストが漏れていた」といったことはよくあるかと思います。手動でテストケースを考えると仕様を明確に理解していたとしても考慮漏れが発生してしまうことがあります。
今回紹介するSchemathesisは、APIの仕様書(OpenAPI/GraphQL)を読み込むだけで、大量のテストケースを自動生成し、APIの堅牢性を徹底的に検証してくれるプロパティベーステスト(PBT)ツールです。開発者が想定していなかったパターンのリクエストを送信して、APIがクラッシュする・想定していなかった挙動等をテストしてくれます。
環境
- Python 3.11.4
- FastAPI 0.115.6
- Schemathesis 4.4.4
- pytest 8.2.0
Schemathesisとは
Schemathesisは、Web APIのためのプロパティベーステストツールです。
「APIの仕様書(スキーマ)を読み込ませるだけで、その仕様に基づいた大量のテストケースを自動生成し、APIが予期せぬデータを受け取ってもクラッシュしたり、おかしな挙動をしたりしないかを検証してくれる」ライブラリです。
通常、APIのテストを書くときは、境界値分析、同値分割、ドメイン分析、デシジョンテーブルなどテスト技法を使って開発者が一つ一つ考えてテストケースを作成します。しかし、人間が考えつくパターンには限界があり、どうしても考慮漏れが発生しがちです。
Schemathesisは、この「テストケースを考える」部分を自動化し、開発者が思いもよらないような多様なリクエストを生成してAPIの堅牢性を徹底的にテストしてくれます。
Schemathesisの仕組み
Schemathesisは以下の流れでテストを実行します。
「個別の値をテストする」のではなく、「入力データが満たすべき性質(プロパティ)を定義し、その性質を破ろうとする値をランダムに生成してテストする」というプロパティベーステストを行うのが最大の特徴になります。
Schemathesisは何に使うのか
このSchemathesisは、APIの品質と信頼性を向上させるために使います。
特に以下の点が特徴です。
- セキュリティ
- 開発者が意図しない入力に対する脆弱性を早期に発見し、セキュアなAPIを構築できる
- パフォーマンス
- 生成される大量のリクエストは、APIサーバーに対する一種の負荷テストとしても機能するため、パフォーマンスのボトルネックを発見できる
- コードの保守性
- Schemathesisは、OpenAPIやGraphQLといったAPIスキーマ定義ファイル(仕様書)を正としてテストを行えるので仕様変更への追従が容易になり仕様と実装の乖離を防げる
Schemathesisを使ってみる
では実際にSchemathesisを使って、PythonのWebフレームワークであるFastAPIで構築したAPIをテストしてみます。
準備:テスト対象のAPIサーバーを立てる
まず、テスト対象となる簡単なAPIサーバーを用意します。
FastAPIは、コードを書くだけで自動的にOpenAPIスキーマ(/openapi.json)を生成してくれるので、これをSchemathesisで読むだけになります。
uvでライブラリをインストールします。
uv add fastapi "uvicorn[standard]" schemathesis pytest 次にapp.py という名前で以下のAPIサーバーのコードを作成します。
こちらはschemathesisのサンプルリポジトリにあるものを参考にしています。
schemathesis/examples/booking/app.py at master · schemathesis/schemathesis
import uuid from typing import Optional from fastapi import Depends, FastAPI, Header, HTTPException from pydantic import BaseModel, Field app = FastAPI(title="Booking API", version="1.0.0") BOOKINGS: dict[str, dict] = {} def verify_token(authorization: Optional[str] = Header(None)) -> bool: if not authorization or authorization != "Bearer secret-token": raise HTTPException(status_code=401, detail="Invalid or missing token") return True class BookingRequest(BaseModel): guest_name: str = Field(min_length=2, max_length=100) room_type: str nights: int = Field(gt=0, le=365) class BookingResponse(BaseModel): booking_id: str guest_name: str room_type: str nights: int status: str price_per_night: float total_price: float @app.post("/bookings", response_model=BookingResponse, responses={400: {"description": "Invalid booking"}}) # type: ignore[misc] def create_booking(booking: BookingRequest, _: bool = Depends(verify_token)) -> BookingResponse: # Calculate price based on room type room_prices = {"standard": 99.99, "deluxe": 149.99, "suite": 299.99} price_per_night = room_prices[booking.room_type] total_price = price_per_night * booking.nights booking_id = str(uuid.uuid4()) booking_data = { "booking_id": booking_id, "guest_name": booking.guest_name, "room_type": booking.room_type, "nights": booking.nights, "status": "confirmed", "price_per_night": price_per_night, "total_price": total_price, } BOOKINGS[booking_id] = booking_data return BookingResponse(**booking_data) @app.get( # type: ignore[misc] "/bookings/{booking_id}", response_model=BookingResponse, responses={404: {"description": "Booking not found"}} ) def get_booking(booking_id: str, _: bool = Depends(verify_token)) -> BookingResponse: if booking_id not in BOOKINGS: raise HTTPException(status_code=404, detail="Booking not found") return BookingResponse(**BOOKINGS[booking_id]) @app.get("/health") # type: ignore[misc] def health_check() -> dict: return {"status": "healthy"} これはゲスト名、部屋タイプ、泊数を受け取って、予約ID、ゲスト名、部屋タイプ、泊数、ステータス、一泊あたりの料金、合計料金を返すという単純なホテル予約用のAPIです。
ターミナルで以下のコマンドを実行します。
uvicorn app:app --reload INFO: Will watch for changes in these directories: ['/tmp/pytest-schemathesis'] INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Started reloader process [79391] using WatchFiles INFO: Started server process [79393] INFO: Waiting for application startup. INFO: Application startup complete. http://127.0.0.1:8000 でAPIサーバーが起動します。ブラウザで http://127.0.0.1:8000/openapi.json にアクセスすると、このAPIの仕様が書かれたJSONが表示されることを確認できます。
Schemathesisはこれを読み込んでテストを行います。
pytestと連携したPBTを行う
次にpytestでschemathesisを使うためにtest_api.py という名前で以下のファイルを作成します。
import schemathesis from hypothesis import settings # from_url を使って、OpenAPIスキーマの場所を指定する schema = schemathesis.openapi.from_url("http://127.0.0.1:8000/openapi.json") # @schema.parametrize() デコレータをテスト関数につけるだけでSchemathesisがスキーマ内の各エンドポイントに対してテストケースを自動生成する @schema.parametrize() @settings(max_examples=500) def test_api(case): """ APIがスキーマ定義に準拠しているかをテストする。 Args: case: Schemathesisが生成した個々のテストケース。 リクエストのメソッド、パス、ヘッダー、ボディなどが含まれる。 """ # case.call_and_validate() は、以下の処理をまとめて実行してくれる便利なメソッド case.call_and_validate(headers={"Authorization": "Bearer secret-token"}) 先に作成したAPIではBearer認証を行っているので
headers={"Authorization": "Bearer secret-token"} をcase.call_and_validateに与える必要があります。
また@settings(max_examples=500)デコレーターを付加することでテストケースの件数も制御できます。
それでは実行してみます。
$ pytest test_api.py -v Test session starts (platform: darwin, Python 3.11.4, pytest 8.4.2, pytest-sugar 1.1.1) ... ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― test_api[POST /bookings] ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― + Exception Group Traceback (most recent call last): | File "/tmp/venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py", line 226, in test_api | def test_wrapper(*args: Any, **kwargs: Any) -> Any: | ^^^ | File "/tmp/venv/lib/python3.11/site-packages/hypothesis/core.py", line 1988, in wrapped_test | _raise_to_user(errors, state.settings, [], " in explicit examples") | File "/tmp/venv/lib/python3.11/site-packages/hypothesis/core.py", line 1582, in _raise_to_user | raise the_error_hypothesis_found | File "/tmp/venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py", line 228, in test_wrapper | return test_function(*args, **kwargs) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | File "/tmp/pytest-schemathesis/test_api.py", line 20, in test_api | case.call_and_validate(headers={"Authorization": "Bearer secret-token"}) | File "/tmp/venv/lib/python3.11/site-packages/schemathesis/generation/case.py", line 476, in call_and_validate | self.validate_response( | File "/tmp/venv/lib/python3.11/site-packages/schemathesis/generation/case.py", line 450, in validate_response | raise FailureGroup(_failures, message) from None | schemathesis.core.failures.FailureGroup: Schemathesis found 2 distinct failures | | - Server error | | - Undocumented HTTP status code | | Received: 500 | Documented: 200, 400, 422 | | [500] Internal Server Error: | | `Internal Server Error` | | Reproduce with: | | curl -X POST -H 'Authorization: [Filtered]' -H 'Content-Type: application/json' -d '{"guest_name": "00", "nights": 1, "room_type": ""}' http://127.0.0.1:8000/bookings | | (2 sub-exceptions) +-+---------------- 1 ---------------- | schemathesis.core.failures.ServerError: Server error +---------------- 2 ---------------- | schemathesis.openapi.checks.UndefinedStatusCode: Undocumented HTTP status code | | Received: 500 | Documented: 200, 400, 422 +------------------------------------ ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[POST /bookings] ⨯ 33% ███▍ ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[GET /bookings/{booking_id}] ✓ 67% ██████▋ ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[GET /health] ✓ 100% ██████████ ===================================================================================================== short test summary info ===================================================================================================== FAILED test_api.py::test_api[POST /bookings] Results (12.78s): 2 passed 1 failed - ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py:225 test_api[POST /bookings] テストケースを自動作成してテストを実行していることがわかります。
この中で POST /bookings に対するテストが FAILED となっています。Schemathesisは、room_type が空文字の場合にサーバーがステータスコード500(Internal Server Error)を返したことをテスト失敗として出力しています。
pytest と連携することで、CI/CDパイプライン(継続的インテグレーション/継続的デリバリー)にSchemathesisのテストを簡単に組み込むことができ、コードが変更されるたびにAPIの堅牢性を自動でチェックする体制を構築できます。
FAILした時の対応方法
Schemathesisの使い所としては、開発者が想定していなかったパターンのリクエストを送信して、APIがクラッシュする・想定していなかった挙動等をテストすることです。そのため、テストがFAILした場合は、APIの仕様もしくは実装に問題がある可能性が高いので以下のフローで修正を行っていきます。
ステップ1: FAILの出力を分析する
SchemathesisのテストがFAILすると、非常に有益な情報が出力されるのでここから問題の原因を特定していきます。
前回の例のFAIL出力を見てみます。
―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― test_api[POST /bookings] ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― ... | - Server error | | - Undocumented HTTP status code | | Received: 500 | Documented: 200, 400, 422 | | [500] Internal Server Error: | | `Internal Server Error` | | Reproduce with: | | curl -X POST -H 'Authorization: [Filtered]' -H 'Content-Type: application/json' -d '{"guest_name": "00", "nights": 1, "room_type": ""}' http://127.0.0.1:8000/bookings ... ここから読み取れる重要な情報としては以下になります。
- どのエンドポイントで失敗したか?:
POST /bookings - どんなリクエストで失敗したか?:
room_typeが""のJSONをPOSTしたとき。 - どのように失敗したか?: API仕様としては
200,400,422を想定しているがサーバーが500 status code(Internal Server Error) を返した。
ステップ2: 失敗をローカルで再現する
次に、この問題が発生したリクエストを実行して問題を再現させます。
Reproduce with: に失敗するためのcurlコマンドが記載されているのでこれをそのまま実行します。
$ curl -X POST -H 'Authorization: [Filtered]' -H 'Content-Type: application/json' -d '{"guest_name": "00", "nights": 1, "room_type": ""}' http://127.0.0.1:8000/bookings {"detail":"Internal Server Error"}% このリクエストを送ったときに、サーバーのコンソールにエラーログやスタックトレースが表示されるのでそれを元に修正・対応を行います。
ステップ3: 原因を調査・特定し、修正する
問題が再現できたら次に原因調査を行いますがFAILの種類によって調査のポイントが異なります。
ケース1: 5xx Server Error の場合
- 原因: アプリケーションコード内の予期せぬエラー(バグ)である可能性が非常に高いです。
- 調査:
- APIサーバーのログを確認します。FastAPIなどのフレームワークは、500エラーが発生した際に、どのファイルの何行目でエラーが起きたかを示すスタックトレースを出力します。
- 今回の例では、
main.pyの以下の部分が原因だとすぐにわかります。
room_prices = {"standard": 99.99, "deluxe": 149.99, "suite": 299.99} price_per_night = room_prices[booking.room_type] - 修正: エラーになった箇所の実装が正しいかどうかはビジネスロジックに依存し、それが特定のビジネスロジックであるべきなら、500エラーではなくクライアントに理由を伝える 400 Bad Request や 409 Conflict などを返すようにコードを修正します。
ケース2: レスポンススキーマの不一致の場合
- 原因: APIが返したレスポンスの形式(JSONのキー、データ型など)が、OpenAPIスキーマに書かれている定義と異なっています。
- 調査:
- Schemathesisの出力で、レスポンスのどの部分がスキーマと違うかを特定します。
- 実装コードが、スキーマ通りのレスポンスを生成しているか確認します。
- 修正: 実装が間違っていればコードを修正します。もし実装が正しく、スキーマ定義が古い・間違っているのであれば、スキーマファイル(
openapi.jsonを生成する元のコードなど)を修正します。
ケース3: タイムアウトの場合
- 原因: 特定の入力(例: 非常に長い文字列、巨大な数値)に対して、APIの処理に時間がかかりすぎています。パフォーマンス上の問題や、DoS攻撃に対する脆弱性を示唆しています。
- 調査:
- プロファイリングツールを使って、コードのどこがボトルネックになっているかを特定します。
- 非効率なアルゴリズムや、データベースのN+1クエリ問題などがないか確認します。
- 修正: ボトルネックとなっているコードを最適化します。また、入力値の長さに上限を設けるなどのバリデーションをスキーマやコードに追加することも有効です。
ケース4: APIの仕様漏れの場合(UndefinedStatusCode / RejectedPositiveData)
- 原因: OpenAPIスキーマに定義されていないステータスコードやエラーレスポンスを返している状態。コード修正(500→400への変更など)後にスキーマ更新を忘れるケースが典型的です。
- エラー:
UndefinedStatusCode- スキーマに定義されていないステータスコードが返される - エラー:
RejectedPositiveData- スキーマ準拠のリクエストがビジネスロジックで拒否される
- エラー:
- 調査:
- Schemathesisの出力で
Undocumented HTTP status codeやRejectedPositiveDataエラーを確認します。 - 実装が返しているステータスコード(400, 422など)がOpenAPIスキーマの
responsesに定義されているか確認します。 - コードとSwagger UIを見比べ、エラーケースの定義漏れがないか確認します。
- Schemathesisの出力で
- 修正: スキーマにエラーレスポンスを追加して、実装とスキーマを一致させます。
ステップ4: 実際に修正を行う
今回のエラーはケース4に該当します。room_typeが空文字のときに500エラーが発生していたので、APIの仕様にroom_typeをstandard、deluxe、suiteのいずれかに限定するように明記することで解決します。
以下の修正をapp.pyに行います。
$ diff --git a/pytest-schemathesis/app.py b/pytest-schemathesis/app.py --- a/pytest-schemathesis/app.py(revision 12231fe1bfc5ed8caf1f5d6430210d8c50cdc0aa) +++ b/pytest-schemathesis/app.py(date 1762762281090) @@ -1,4 +1,5 @@ import uuid +from enum import Enum from typing import Optional from fastapi import Depends, FastAPI, Header, HTTPException @@ -15,9 +16,15 @@ return True +class RoomType(str, Enum): + standard = "standard" + deluxe = "deluxe" + suite = "suite" + + class BookingRequest(BaseModel): guest_name: str = Field(min_length=2, max_length=100) - room_type: str + room_type: RoomType nights: int = Field(gt=0, le=365) この状態で再度テストを実行してみます。
$ pytest test_api.py -v Test session starts (platform: darwin, Python 3.11.4, pytest 8.4.2, pytest-sugar 1.1.1) ... collected 3 items ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[POST /bookings] ✓ 33% ███▍ ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[GET /bookings/{booking_id}] ✓ 67% ██████▋ ../../venv/lib/python3.11/site-packages/schemathesis/generation/hypothesis/builder.py::test_api[GET /health] ✓ 100% ██████████ Results (20.08s): 3 passed すると更新したOpenAPIスキーマの仕様に従ってPBTが行われるのでテストが成功します。
このようにschemathesisを使うことで以下のようなメリットが生まれます。
-
実装とドキュメント(スキーマ)の同期: コードの挙動を変えたら、必ずAPIの仕様書も更新しなければテストが通らなくなります。これにより、「ドキュメントは古いけど、実際の挙動はこう」といった状態になることを防止することができ常に正確なAPI仕様書を維持できるようになります。
-
APIの利用者に対して正確なドキュメントの提供: スキーマにはAPIの仕様が明記されている状態になるため、APIの利用者はスキーマを見るだけでAPIの仕様を把握できます。そのためAPIを利用する開発者はエラーハンドリングを適切に実装しやすくなります。
まとめ
Schemathesisは、APIの仕様書を元にプロパティベーステストを実行でき、手動でテストケースを考えた場合に発生するようなテストケース漏れを防ぐことができます。
これにより、
- 手動テストの手間を削減し、開発者はより創造的な作業に集中できる。
- ファジングによりセキュリティ脆弱性を早期に発見できる
- スキーマとテストが連動するため、仕様変更に強く、メンテナンスが容易になる
OpenAPIやGraphQLスキーマをしっかり定義しているプロジェクトであれば、導入は非常に簡単で効果もすぐに出るためテストに含めることをおすすめします。







