APIの​テストデータを​自動生成できる​Schemathesisの​紹介

PyCon mini 東海 2025資料

Creative Commons License This work is licensed under a Creative Commons Attribution 4.0 International License .

はじめに

自己紹介

  • Ryuji Tsutsui@ryu22e

  • さくらインターネット株式会社所属

  • Python歴は​14年くらい​(主に​Django)

  • Python Boot Camp、​Shonan.py、​GCPUG Shonanなど​コミュニティ活動も​しています

  • 著書​(共著)​:『Python実践レシピ

今​日話すこと

  • Schemathesisの​概要

  • Schemathesisの​使い方

  • Django + Schemathesisの​例

今​日話さない​こと

  • Pythonの​文法

  • pytestを​使った​ユニットテストの​書き方

この​​トークの​​対象者

  • Pythonの​ユニットテストを​書いた​ことがある​人

この​​トークで​​得られる​​こと

  • Schemathesisの​概要、​使い方

  • Hypothesisの​概要

  • 「プロパティベーステスト」の​概要

  • Schemathesisが​どのような​場面で​役立つのか

トークの​構成

  • Schemathesisの​概要

  • Hypothesisの​概要

  • コード例紹介

Schemathesisの​概要

Schemathesisとは​何か

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

どんな​とき役立つか

  • 人間が​書いた​テストコードでは​気付けない​エッジケースを​見つけられる

  • 仕様書と​実装の​乖離に​気づく​ことができる

Schemathesisの​簡単な​使い方

使い方は​2通り:

  1. pytest経由で​使う

  2. コマンドラインインターフェースで​使う

テスト対象の​アプリケーション

以下の​FastAPIアプリケーションを​用意する。

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エンドポイント"""
    # 0で除算するケースを考慮していないのは「バグ」。
    return {"result": values.a / values.b}

テストコード

import schemathesis

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

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

テスト実行結果

$ 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 ===============================

なぜエラーに​なるのか

OpenAPIスキーマの内容

a, bは​整数なら​何でも​受け付ける​仕様に​なっている

アプリケーションを​修正

bが​0の​ときバリデーションエラーに​なるよう修正。

import fastapi
from pydantic import BaseModel, Field, StrictInt, field_validator  # (1)Field, field_validatorをインポート

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 の意味
        }
    )

    # (3)このメソッドを追加
    @field_validator("b")
    @classmethod
    def b_must_not_be_zero(cls, v: int) -> int:
        if v == 0:
            # ここで ValueError を出しておくと FastAPI が 422 にしてくれる
            raise ValueError("b must not be 0")

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

修正後の​OpenAPIスキーマの​内容

OpenAPIスキーマの内容

bは​0を​受け付けない​仕様に​なった

修正後の​テスト実行結果

$ pytest test_main.py -v
================================================================== test session starts ==================================================================
platform darwin -- Python 3.13.9, pytest-8.4.2, pluggy-1.6.0 -- /Users/ryu22e/development/temp/schemathesis-example/.direnv/python-venv-3.13.9/bin/python3.13
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /Users/ryu22e/development/temp/schemathesis-example
plugins: schemathesis-4.4.3, hypothesis-6.142.1, anyio-4.11.0, subtests-0.14.2
collected 1 item

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

=================================================================== 1 passed in 1.02s ===================================================================

コマンドラインインターフェースを​使う方​法

schemathesisまたは​stコマンドを​使う。

$ 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 =============================================================

Schemathesisは​Pythonで​書かれていない​アプリケーションにも​使える

  • 結局、​スキーマ定義を​元に​HTTPで​通信しているだけ

  • テスト対象が​どんな​言語で​書かれていても​関係ない

  • ただし、​WSGIまたは​ASGIで​通信する​方​法も​あって、​こちらの​ほうが​高速

WSGIまたは​​ASGIで​​通信する​​場合の​書き方

import schemathesis

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

# (2)from_asgi関数にOpenAPI仕様書のパスとアプリケーションを渡す
# またはschemathesis.openapi.from_wsgi()を使う
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
...  # 省略

別の​バグを​仕込んで​みる

...  # 省略
@app.post("/div")
async def div(values: Values):
    """2つの整数を受け取り、その商を返すAPIエンドポイント"""
    assert values.a < 1000000  # 大きな値を受け取ると発生するバグ
    return {"result": values.a / values.b}

テスト完了まで​かかる​時間

$ time pytest test_main.py -v
(省略)
================================================================ short test summary info ================================================================
FAILED test_main.py::test_api[POST /div]
=================================================================== 1 failed in 1.45s ===================================================================
pytest test_main.py -v  1.45s user 0.15s system 81% cpu 1.975 total

テストを​再実行してみる

前回より​終了時間が​短い。

$ time pytest test_main.py -v
(省略)
================================================================ short test summary info ================================================================
FAILED test_main.py::test_api[POST /div]
=================================================================== 1 failed in 0.34s ===================================================================
pytest test_main.py -v  0.56s user 0.10s system 78% cpu 0.843 total

なぜ2回目は​早く​終わるのか?

  • Schemathesisは​前回エラーに​なった​テストデータを​キャッシュしている

  • 2回目以降は​キャッシュを​元に、​エラーに​なりそうな​データを​優先的に​実行する

キャッシュの​場所

$ ls -1A                                [~/development/temp/schemathesis-example]
__pycache__
.hypothesis  # これがキャッシュ
.pytest_cache
main.py
test_main.py

なぜ​「.hypothesis」?

なぜ​「.schemathesis」じゃないの?

→答えは​次の​セクションで

Hypothesisの​概要

Hypothesisとは​何か

  • Hypothesisの​読み=ハイポセシス

  • Schemathesisが​内部で​利用している​テストフレームワーク

  • 「プロパティベーステスト」と​いう​テスト手法で​テストできる

「プロパティベーステスト」が​どのような​考え方の​テスト手法なのか

  • 従来の​テストコード:

    • 人間が​「入力値」​「期待する​返却値」を​考える

  • プロパティベーステスト:

    • 人間が​「入力に​対する​コードの​振る​舞い」​(プロパティ)を​考え、​テストデータの​生成は​ツールに​任せる

プロパティベーステストの​例

以下の​関数の​テストを​やってみる。

def div(a, b):
    return a / b

従来の​テストコードを​書く​場合

人間が​「入力値」​「期待する​返却値」を​考える。

import pytest

@pytest.mark.parametrize(
    "a, b, expected",
    [
        (10, 2, 5),
        (7, 2, 3.5),
        (6, -3, -2),
        (123.456, 1, 123.456),
    ],
)
def test_div(a, b, expected):
    """通常の除算が期待通りに動作するかを確認"""
    assert div(a, b) == expected

プロパティベーステストの​場合

import pytest
from hypothesis import given, strategies as st

@given(a=st.floats(allow_infinity=False, allow_nan=False),
       b=st.floats(allow_infinity=False, allow_nan=False))
def test_div_basic_property(a, b):
    result = div(a, b)
    # 浮動小数誤差を考慮して、a ≈ result * b が成り立つ
    assert pytest.approx(a, rel=1e-9, abs=1e-9) == result * b

「プロパティベーステスト」に​ついての​参考図書

実践プロパティベーステスト ― PropErと​Erlang/Elixirで​はじめよう

「プロパティベーステスト」は​難しい、が……

Schemathesisは​OpenAPIや​GraphQLの​スキーマを​読ませるだけで​使えるので​簡単。

コード例紹介

Django(+ Django REST framework)​製の​APIと​Schemathesisを​組み合わせてみる。

Djangoアプリケーションを​用意する

以下の​ライブラリを​使用。

  • Django REST framework

  • drf-spectacular​(OpenAPIスキーマを​生成する)

  • pytest

  • pytest-django

  • schemathesis

WSGIまたは​ASGIで​通信する​場合

import pytest

# または from example.asgi import application as app
from example.wsgi import application as app

@pytest.fixture
def web_app(db):
    """DjangoアプリケーションのWebアプリケーションを返す"""
    # ASGIオブジェクトならfrom_asgi()関数を使う
    return schemathesis.openapi.from_wsgi("/api/schema.json", app)

# from_pytest_fixture()関数に上記の関数名を指定
schema = schemathesis.pytest.from_fixture("web_app")

pytestを​実行してみると……

$ pytest
(省略)
  | - Undocumented HTTP status code
  |
  |     Received: 400
  |     Documented: 200
  |
  | [401] Unauthorized:
  |
  |     `{"detail":"Invalid username/password."}`
  |
  | Reproduce with:
  |
  |     curl -X GET -H 'Content-Type: application/json' -d 0 --insecure http://localhost/books/
  |
  |  (1 sub-exception)
  +-+---------------- 1 ----------------
    | schemathesis.openapi.checks.UndefinedStatusCode: Undocumented HTTP status code
    |
    | Received: 401
    | Documented: 200
    +------------------------------------

なぜテストが​通らないのか?

drf-spectacularが​生成した​OpenAPIスキーマの​せい。

drf-spectacularが生成したOpenAPIスキーマ

スキーマ上は​どんな​リクエストも​200を​返す仕様に​なっている

drf-spectacularの​何が​問題か

  • drf-spectacularは​異常終了を​考慮しない

  • Schemathesisは​OpenAPIスキーマが​絶対正しいと​いう​前提

  • 「そうか、​どんな​入力値でも​絶対に​200 OKなんだな!」と​考える

  • でも、​実際には​そうではないので​コケる

どう​すれば​いいか

drf-standardized-errorsを​導入する。

「それで、​結局​Schemathesisが​あれば​人間は​テストコードを​書かなくても​よくなるの?」

Schemathesisは​テストコードを​自動生成してくれる。​ つまり、​人間が​テストコードを​書かなくても​テストが​機能するのでは?

結論から​言うと

そんな​ことはない​😇

Schemathesisの​テストの​問題点

  • どんな​テストが​実行されるのか​コントロールできない

  • テストコードから​アプリケーションの​仕様を​読み取れなくなる

Schemathesisの​使い所

テストコードは​従来通り​書きつつ、​最初に​挙げた​以下の​2点の​利点を​活かすのが​良い。

  • 人間が​書いた​テストコードでは​気付けない​エッジケースを​見つけられる

  • 仕様書と​実装の​乖離に​気づく​ことができる

最後に

まとめ

  • schemathesisは​OpenAPIまたは​GraphQLスキーマを​元に​テストを​行ってくれる

  • 人間が​気づかない​エッジケースを​見つけてくれる

  • ただし、​従来の​テストコードは​書いて​補助的に​使うのが​よさそう

ご清聴​ありがとう​ございました

schemathesisで高品質のAPIを開発する有能エンジニアたち

schemathesisで​高品質の​APIを​開発する​有能エンジニアたち