Python Monthly Topics

Web APIのテストデータを自動生成してくれるツール「Schemathesis」紹介

筒井@ryu22eです。今月の「Python Monthly Topics」は、Web APIのテストデータを自動生成してくれるツールSchemathesis(スキーマセシス)を紹介します。

Schemathesisとは何か

Schemathesisは、OpenAPIまたはGraphQLで書かれたAPI仕様を元にテストデータを自動生成し、実際にそれらのテストデータを使ってAPIを呼び出すことで、仕様通りの挙動になっているか検証できるツールです。

Schemathesisを使う利点は以下のとおりです。

  • 人間が書いたテストコードでは気付けないエッジケースを見つけられる
  • 仕様書と実装の乖離に気づくことができる

Schemathesisの使い方

では、実際にSchemathesisを使ってみましょう。Schemathesisの使い方には、主に以下の2つがあります。

  • pytestと組み合わせる方法
  • コマンドラインインターフェースから利用する方法

この方法について、以下で順番に説明します。

本記事で使用するPython、Schemathesis、FastAPI、pytestのバージョンは以下のとおりです。

  • Python 3.13
  • Schemathesis 4.0.2
  • fastapi[standard] 0.115.13
  • pytest 8.4.1

なお、Schemathesisの現在の最新バージョンである4系は、3系とは仕様が大幅に変わりました。詳細な変更点については以下のドキュメントを参照してください。

本記事ではバージョン3系については説明しません。

pytestと組み合わせる方法

Schemathesisは、pytestと組み合わせることで、Schemathesisが生成したテストデータをpytestのテストコードに渡すことができます。

ここでは、FastAPIを使って作成したAPIをテスト対象とする例をお見せします。FastAPIはソースコードを元にOpenAPIスキーマを生成できるので、OpenAPIスキーマの作成は省略します。

アプリケーションの作成とテストの実行

まずは、以下の手順で必要なライブラリをインストールします。

$ python3.13 -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install "schemathesis==4.0.2" "fastapi[standard]==0.115.13" "pytest==8.4.1"

次に、以下の内容のmain.pyを作成します。整数abを入力値として渡し、a / bの結果を返すシンプルなAPIです。

main.py
import fastapi
from pydantic import BaseModel, StrictInt

app = fastapi.FastAPI()

class Values(BaseModel):
    a: StrictInt
    b: StrictInt

@app.post("/div")
async def div(values: Values):
    """2つの整数を受け取り、その商を返すAPIエンドポイント"""
    return {"result": values.a / values.b}

fastapi dev main.py でアプリケーションを立ち上げてから、以下のコマンドで動作確認してみます。

$ curl -X 'POST' \
  'http://127.0.0.1:8000/div' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "a": 10,
  "b": 2
}'
{"result":5.0}

なお、このAPIはゼロ除算のケースを考慮していないため、bに0を渡すと500エラーになります。これは、⁠プログラマがうっかりバグを混入させてしまった」という体で読んでください。

また、OpenAPIスキーマの内容はhttp://127.0.0.1:8000/docsで確認できます。以下のように、abは整数型を受け付けるように定義されています。

OpenAPIスキーマの内容
OpenAPIスキーマの内容

次に、テストコードを書きます。main.pyと同じディレクトリに以下の内容のtest_main.pyを作成します。

test_main.py
import schemathesis

# (1)OpenAPI仕様書のURLを指定してスキーマを生成
schema = schemathesis.openapi.from_url("http://127.0.0.1:8000/openapi.json")

@schema.parametrize()  # (2)スキーマと戦略の定義に基づいてテストケースを生成
def test_api(case):
    """pytestでAPIのテストを実行する関数"""
    # (3)Schemathesisが生成したテストケースを使用してAPIを呼び出し、検証を行う
    case.call_and_validate()

SchemathesisがOpenAPIのドキュメントを読み取り(コードの(1)⁠、仕様を元に生成したテストデータをテスト関数に渡して(コードの(2)テストを実行する(コードの(3)コードです。

test_api()関数の引数caseに入っているのは、SchemathesisのCaseというデータクラスです。ここには、Schemathesisが生成したテストデータが格納されています。

fastapi dev main.pyでアプリケーションを立ち上げた状態で、pytestを実行してみましょう。パラメータbに0を渡してゼロ除算になり、仕様に反するステータス500を返したため、テストが失敗しています。また、curlコマンドのスニペットが出力され、エラーになったテスト結果をシェルから再現できるようになっています。

(.venv) $ pytest test_main.py -v
============================= test session starts ==============================
(省略)
test_main.py::test_api[POST /div] FAILED                                 [100%]

=================================== FAILURES ===================================
_____________________________ test_api[POST /div] ______________________________
+ Exception Group Traceback (most recent call last):
(省略)
  | - Server error
  | 
  | - Undocumented HTTP status code
  | 
  |     Received: 500
  |     Documented: 200, 422
  | 
  | [500] Internal Server Error:
  | 
  |     `Internal Server Error`
  | 
  | Reproduce with: 
  | 
  |     curl -X POST -H 'Content-Type: application/json' -d '{"a": 0, "b": 0}' http://127.0.0.1:8000/div
  | 
  |  (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | schemathesis.core.failures.ServerError: Server error
    +---------------- 2 ----------------
    | schemathesis.openapi.checks.UndefinedStatusCode: Undocumented HTTP status code
    | 
    | Received: 500
    | Documented: 200, 422
    +------------------------------------
=========================== short test summary info ============================
FAILED test_main.py::test_api[POST /div]
============================== 1 failed in 0.38s ===============================

アプリケーションの修正とテストの再実行

それでは、テストが失敗しないようにアプリケーションを修正します。入力値bに0を渡せないように、バリデーションルールを追加してみましょう。main.pyを以下のように変更してください(コメント(1)(2)の部分⁠⁠。

修正版のmain.py
import fastapi
from pydantic import BaseModel, Field, StrictInt  # (1)Fieldをインポート

app = fastapi.FastAPI()

class Values(BaseModel):
    a: StrictInt
    # (2)Fieldを使用してOpenAPIの拡張を追加
    b: StrictInt = Field(
        ...,
        description="0以外の整数",
        json_schema_extra={
            "not": {"const": 0}  # OpenAPI拡張:b != 0 の意味
        }
    )

@app.post("/div")
async def div(values: Values):
    """2つの整数を受け取り、その商を返すAPIエンドポイント"""
    return {"result": values.a / values.b}

OpenAPIスキーマでは、bが0以外の整数であることを保証する旨が追記されます。

変更後のOpenAPIスキーマの内容
変更後のOpenAPIスキーマの内容

修正後に再度pytestを実行すると、Schemathesisは修正後のOpenAPIスキーマを読み取り、bに0を渡すとバリデーションエラーとしてステータスコード422を返すことを期待するテストケースを作成します。アプリケーションは仕様どおりに実装されているので、テストが通ります。

(.venv) $ pytest test_main.py -v
============================= test session starts ==============================
(省略)

test_main.py::test_api[POST /div] PASSED                                 [100%]

============================== 1 passed in 0.85s ===============================

テストデータのカスタマイズ方法

テストデータのカスタマイズ方法をいくつか紹介します。

settings()デコレータのmax_examples引数に整数値を渡すことで、生成するテストデータ数を調整することがきます。デフォルトのテストデータ数は100です。

以下の例では、生成するテストデータ数を500に変更しています。

生成するテストデータ数を500に変更
import schemathesis
from schemathesis import settings  # (1) settingsをインポート

schema = schemathesis.openapi.from_url("http://127.0.0.1:8000/openapi.json")

# (2)max_examplesを500に設定
@settings(max_examples=500)
@schema.parametrize()
def test_api(case):
    """pytestでAPIのテストを実行する関数"""
    case.call_and_validate()

また、given()デコレータを使うことでテストの「戦略」を定義して、どんなテストデータを生成させるかカスタマイズすることができます。

以下の例では、aに100000以上の整数を渡すように指定し(コードの(1)(2)⁠、case引数に格納されたaのテストデータを上書きしています(コードの(3)⁠。

aに100000以上の整数を渡すように指定
import schemathesis
# (1) Hypothesis(Schemathesisが依存するテストフレームワーク)をインポート
from hypothesis import strategies as st

schema = schemathesis.openapi.from_url("http://127.0.0.1:8000/openapi.json")

# (2) aは100000以上の整数のみを使用する戦略に変更
@schema.given(a=st.integers(min_value=100000))
@schema.parametrize()
def test_api(case, a):
    """pytestでAPIのテストを実行する関数"""
    # (3)aに渡す値を上書き
    if "a" in case.path_parameters:
        case.path_parameters["a"] = a
    case.call_and_validate()

テストコードのもう1つの書き方

テストコードのもう1つの書き方についても紹介します。

これは、テスト対象がPythonで書かれたWSGI(またはASGIアプリケーションである場合に使える方法です。前述の例ではpytestからHTTPでアプリケーションと通信する前提で、schemaオブジェクトの生成にfrom_url()関数を使っていました。from_url()関数の代わりにfrom_wsgi()関数、またはfrom_asgi()関数を使うと、WSGIまたはASGIで通信するテストになります。

例をお見せします。前述のtest_main.pyを以下のように書き換えます(コメント(1)(2)の部分⁠⁠。

ASGIで通信させるように変更したtest_main.py
import schemathesis

from main import app  # (1)FastAPIアプリケーションをインポート

# (2)from_asgi関数にOpenAPI仕様書のパスとアプリケーションを渡してスキーマを生成
schema = schemathesis.openapi.from_asgi("/openapi.json", app)

@schema.parametrize()
def test_api(case):
    """pytestでAPIのテストを実行する関数"""
    case.call_and_validate()

上記のテストコードでは、テスト対象のアプリケーションをASGIで直接呼び出すため、ネットワークを介したHTTPによるテストより高速です。また、あらかじめアプリケーションを立ち上げなくても、pytestのコマンド実行時にアプリケーションが立ち上がってくれます。なお、WSGIまたはASGIで通信する場合は、テスト失敗時にcurlコマンドのスニペットは表示されません。

コマンドラインインターフェースから利用する方法

続いて、Schemathesisをコマンドラインインターフェースから利用する方法を紹介します。

Schemathesisにはschemathesisというコマンドが提供されています(stというエイリアスコマンドもあります⁠⁠。このschemathesisコマンドにOpenAPIスキーマのURLを渡すことで、コマンド経由でテストを実行することができます。

schemathesisコマンドでテストを実行するには、runというサブコマンドにOpenAPIスキーマのURLを渡します。

使用例をお見せします。前述の「pytestと組み合わせた方法」で作ったアプリケーションをfastapi dev main.pyで立ち上げてから、schemathesis run http://127.0.0.1:8000/openapi.jsonを実行します。pytestから実行した際と同様に、OpenAPIスキーマを元にテストデータを作成し、テストを実行します。

以下は、テストに成功した場合のメッセージです(⁠⁠アプリケーションの修正とテストの再実行」の項で紹介した修正版のmain.pyを使っている前提です⁠⁠。

$ schemathesis run http://127.0.0.1:8000/openapi.json
Schemathesis dev
━━━━━━━━━━━━━━━━

 ✅  Loaded specification from http://127.0.0.1:8000/openapi.json (in 0.10s)

     Base URL:         http://127.0.0.1:8000/
     Specification:    Open API 3.1.0
     Operations:       1 selected / 1 total

 ✅  API capabilities:

     Supports NULL byte in headers:    ✘

 ⏭   Examples (in 0.11s)

     ⏭  1 skipped

 ✅  Coverage (in 0.40s)

     ✅ 1 passed

 ✅  Fuzzing (in 0.68s)

     ✅ 1 passed

===================================================================== SUMMARY ======================================================================

API Operations:
  Selected: 1/1
  Tested: 1

Test Phases:
  ✅ API probing
  ⏭  Examples
  ✅ Coverage
  ✅ Fuzzing
  ⏭  Stateful (not applicable)

Test cases:
  128 generated, 128 passed

Seed: 25425465587409846911775882801013537899

============================================================= No issues found in 1.20s =============================================================

テストに失敗した場合は以下のようなメッセージが出力されます(⁠⁠アプリケーションの作成とテストの実行」のところで作った修正前のmain.pyを使っている前提です⁠⁠。⁠アプリケーションの作成とテストの実行」でpytestを実行した際と同様、curlコマンドのスニペットが出力されます。

$ schemathesis run http://127.0.0.1:8000/openapi.json
Schemathesis dev
━━━━━━━━━━━━━━━━

 ✅  Loaded specification from http://127.0.0.1:8000/openapi.json (in 1.48s)

     Base URL:         http://127.0.0.1:8000/
     Specification:    Open API 3.1.0
     Operations:       1 selected / 1 total

 ✅  API capabilities:

     Supports NULL byte in headers:    ✘

 ⏭   Examples (in 0.11s)

     ⏭  1 skipped

 ❌  Coverage (in 0.36s)

     ❌ 1 failed

 ❌  Fuzzing (in 0.14s)

     ❌ 1 failed

===================================================================== FAILURES =====================================================================
____________________________________________________________________ POST /div _____________________________________________________________________
1. Test Case ID: cgqbU7

- Server error

- Undocumented HTTP status code

    Received: 500
    Documented: 200, 422

[500] Internal Server Error:

    `Internal Server Error`

Reproduce with:

    curl -X POST -H 'Content-Type: application/json' -d '{"a": 0, "b": 0}' http://127.0.0.1:8000/div

===================================================================== SUMMARY ======================================================================

API Operations:
  Selected: 1/1
  Tested: 1

Test Phases:
  ✅ API probing
  ⏭  Examples
  ❌ Coverage
  ❌ Fuzzing
  ⏭  Stateful (not applicable)

Failures:
  ❌ Server error: 1
  ❌ Undocumented HTTP status code: 1

Test cases:
  27 generated, 1 found 2 unique failures

Seed: 107349865083912536094560386602272888193

=============================================================== 2 failures in 0.63s ================================================================

なお、コマンドラインインターフェースでは「テストデータのカスタマイズ方法」の項で紹介したgiven()デコレータでテストの「戦略」を定義する機能がありません。⁠戦略」の定義が必要な場合はpytestを使ってください。

schemathesis.tomlでテスト実行時の設定をカスタマイズする

以下のいずれかの場所にschemathesis.tomlファイルを置くことで、pytestまたはschemathesisコマンド実行時に渡すオプションの値を省略できます。

  • カレントディレクトリ
  • プロジェクトルート
  • --config-file引数で渡したパス

以下がschemathesis.tomlの記述例です。

schemathesis.tomlの記述例
# --headersに渡す値
# API_TOKENは環境変数として設定しておく
headers = { Authorization = "Bearer ${API_TOKEN}" }
# --continue-on-failureを有効にする
continue-on-failure = true
# --max-examplesに渡す値
generation.max-examples = 500

なお、schemathesis.tomlで利用可能なオプションの内容については、以下の公式ドキュメントを参照してください。

最後に

最後に、筆者がSchemathesisを使ってみた実感について書きます。

この記事を読んで、⁠このツールを使えば人間が書くテストコードは省略できるのでは?」と思われる方がいるかもしれませんが、筆者が使ってみた実感としては、人間が書くテストコードは減らさないほうがいいという感想です。

なぜなら、Schemathesisがやってくれるテストの内容は予測できません。私はテストコードを「自分が意図したとおりに動くか検証する」という意味合いで捉えているので、何をテストするのかコントロールできないSchemathesisは、テストコードを完全に代替するものではないような気がしました。

また、テストコードはテスト対象の仕様を説明するドキュメントとしての側面もあると思います。ところが、Schemathesisのテストコードは「pytestと組み合わせる方法」のtest_main.pyのような内容で、このコードだけでは仕様を読み取れません。人間が書くテストコードが減ってしまうと、プログラマーが読める情報が減ってしまうというデメリットもあると感じました。Schemathesisを導入する際は、従来通りのテストコードは書いた上で、冒頭で説明した以下の利点を活かすツールと考えたほうがいいでしょう。

  • 人間が書いたテストコードでは気付けないエッジケースを見つけられる
  • 仕様書と実装の乖離に気づくことができる

SchemathesisはPythonで書かれていないAPIに対しても使える、汎用性の高いツールです。OpenAPIやGraphQLを採用しているプロジェクトであれば導入しやすいのも嬉しいですね。興味を持った方はぜひ試してみてください!

おすすめ記事

記事・ニュース一覧