pytestのプラグインTavernでAPIテストをYAMLで宣言的に記述してみる

pytestのプラグインTavernでAPIテストをYAMLで宣言的に記述してみる

2026.03.16

はじめに

データ事業本部のkobayashiです。

FastAPIなどでAPIを開発している際にpytestでテストコードを書くことはよくあるかと思います。しかしテストケースが増えてくるとPythonのテストコードの量も膨大になり、リクエストの組み立てやレスポンスの検証のコードが似たようなパターンの繰り返しになりがちです。

今回は、APIテストをYAMLで宣言的に記述できるpytestプラグイン「Tavern」を試してみたのでその内容をまとめます。

taverntesting/tavern: A command-line tool and Python library and Pytest plugin for automated testing of RESTful APIs

Tavernとは

TavernはAPIの自動テストに特化したPython製のテストフレームワークで、pytestプラグインとして動作します。テストケースをYAML形式で宣言的に記述できることが最大の特徴です。

主な特徴としては以下になります。

  • テストをYAMLで宣言的に記述するため可読性が高く、非プログラマーでも理解しやすい
  • pytestプラグインとして動作するためpytestのエコシステム(レポート、CI/CD統合、マーカー等)をそのまま利用可能
  • 前のリクエストのレスポンスを変数に保存して後続のリクエストで使用するマルチステージテストに対応
  • 複雑な検証が必要な場合はPython関数で拡張可能
  • REST API、MQTT、gRPC、GraphQLのテストに対応

では早速試してみます。

Tavernを使ってみる

環境

今回使用した環境は以下の通りです。

Python 3.13.2
FastAPI 0.115.8
uvicorn 0.34.0
tavern 3.2.0

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

まずテスト対象となるFastAPIアプリケーションを作成します。シンプルなアイテム管理APIで、ヘルスチェック、アイテムのCRUD、トークンベースの認証を持つ構成です。

main.py
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel

app = FastAPI()

# インメモリストア
items_db: dict[int, dict] = {}
item_id_counter = 0

class ItemCreate(BaseModel):
    name: str
    price: float
    description: str | None = None

class LoginRequest(BaseModel):
    username: str
    password: str

VALID_TOKEN = "secret-token-12345"

def verify_token(authorization: str = Header()):
    if authorization != f"Bearer {VALID_TOKEN}":
        raise HTTPException(status_code=401, detail="Unauthorized")

@app.get("/health")
def health_check():
    return {"status": "ok"}

@app.post("/items", status_code=201)
def create_item(item: ItemCreate):
    global item_id_counter
    item_id_counter += 1
    item_data = {"id": item_id_counter, **item.model_dump()}
    items_db[item_id_counter] = item_data
    return item_data

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return items_db[item_id]

@app.post("/auth/token")
def login(req: LoginRequest):
    if req.username == "testuser" and req.password == "testpass":
        return {"access_token": VALID_TOKEN, "token_type": "bearer"}
    raise HTTPException(status_code=401, detail="Invalid credentials")

@app.delete("/items/{item_id}", dependencies=[Depends(verify_token)])
def delete_item(item_id: int):
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    deleted = items_db.pop(item_id)
    return {"message": "Item deleted", "item": deleted}

このFastAPIアプリケーションをuvicornで起動しておきます。TavernはHTTPリクエストを実際に送信するため、テスト実行時にはサーバーが起動している必要があります。

$ uvicorn main:app --reload
INFO:     Will watch for changes in these directories: ['/Users/kobayashi.masahiro/CMProject/developers.io/developers.io_22/tavern']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [71642] using WatchFiles
INFO:     Started server process [71644]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

それではこのAPIエンドポイントに対してtavernでAPIテストを記述します。

インストール

uvでライブラリをインストールします。

$ uv add "tavern[pytest]"

基本的なGETリクエストのテスト

Tavernのテストファイルはtest_*.tavern.yamlという命名規則で作成します。pytestがこの形式のファイルを自動的に検出して実行してくれます。

まずはヘルスチェックエンドポイントに対するシンプルなGETリクエストのテストを書いてみます。

test_health.tavern.yaml
test_name: ヘルスチェックエンドポイントが正常に応答すること

stages:
  - name: GETリクエストでステータスを確認
    request:
      url: http://localhost:8000/health
      method: GET
    response:
      status_code: 200
      json:
        status: ok

YAMLの構造はシンプルで、test_nameでテスト名を定義し、stagesの中にリクエストと期待するレスポンスを記述します。これだけでAPIテストが完成します。

実行してみます。

$ pytest test_health.tavern.yaml -v
...
collected 1 item                                                                                                                                                                                                                        

 tavern/test_health.tavern.yaml::test_health.tavern.yaml::ヘルスチェックエンドポイントが正常に応答すること                                                            100% ██████████

Results (0.13s):
       1 passed

Pythonコードを一切書かずにAPIテストが実行できました。

POSTリクエストとレスポンス検証のテスト

次にPOSTリクエストでアイテムを作成し、レスポンスのJSONを検証するテストを書いてみます。

test_items.tavern.yaml
test_name: アイテムを作成してレスポンスを検証する

strict:
  - headers:off
  - json:off

stages:
  - name: 新しいアイテムを作成する
    request:
      url: http://localhost:8000/items
      method: POST
      json:
        name: "テストアイテム"
        price: 1500
        description: "テスト用のアイテムです"
    response:
      status_code: 201
      json:
        name: "テストアイテム"
        price: 1500
        description: "テスト用のアイテムです"

requestjsonにリクエストボディを記述し、responsejsonに期待するレスポンスボディを記述します。Pythonのコードでassert response.json() == {...}と書いていた部分がYAMLの宣言で済むようになります。

なお、Tavernはデフォルトでレスポンスのキーを厳密にチェック(strictモード)します。上記のテストではstrict設定でjson:offを指定することで、レスポンスに期待値以外のキー(この場合id)が含まれていてもテストが通るようにしています。strictを指定しない場合、レスポンスに余分なキーがあると「Extra keys in response」エラーになるため注意が必要です。

マルチステージテスト(アイテムの作成→取得)

Tavernの強力な機能の一つが、前のステージのレスポンスを変数に保存して後続のステージで使用できるマルチステージテストです。

以下はアイテムを作成し、そのレスポンスからidを保存して、次のステージで保存したidを使ってアイテムを取得するテストです。

test_items_multistage.tavern.yaml
test_name: アイテムを作成して取得する

strict:
  - headers:off
  - json:off

stages:
  - name: アイテムを作成してIDを保存する
    request:
      url: http://localhost:8000/items
      method: POST
      json:
        name: "マルチステージテスト"
        price: 2000
    response:
      status_code: 201
      save:
        json:
          item_id: id

  - name: 保存したIDでアイテムを取得する
    request:
      url: "http://localhost:8000/items/{item_id}"
      method: GET
    response:
      status_code: 200
      json:
        name: "マルチステージテスト"
        price: 2000

ポイントは1つ目のステージのresponse.save.jsonでレスポンスJSONのidフィールドを変数item_idとして保存し、2つ目のステージのrequest.url{item_id}として参照している点です。

同じテストをpytestの通常のPythonコードで書くと以下のようになります。

test_items_python.py
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)

def test_create_and_get_item():
    # アイテムを作成
    response = client.post(
        "/items",
        json={"name": "マルチステージテスト", "price": 2000},
    )
    assert response.status_code == 201
    item_id = response.json()["id"]

    # 作成したアイテムを取得
    response = client.get(f"/items/{item_id}")
    assert response.status_code == 200
    assert response.json()["name"] == "マルチステージテスト"
    assert response.json()["price"] == 2000

Pythonコードでは変数の取り出しやアサーションを手続き的に書く必要がありますが、TavernではYAMLのsave{変数名}の参照だけで同じことが実現できます。テストケースが増えるほどこの差が効いてきます。

認証フローのテスト

実際のAPIでは認証が必要なエンドポイントがあるかと思います。Tavernのマルチステージテストを使えば、ログイン → トークン保存 → 認証付きリクエストという一連のフローを1つのテストとして記述できます。

test_auth.tavern.yaml
test_name: 認証フロー(ログイン→アイテム作成→認証付き削除)

strict:
  - headers:off
  - json:off

stages:
  - name: ログインしてアクセストークンを取得する
    request:
      url: http://localhost:8000/auth/token
      method: POST
      json:
        username: testuser
        password: testpass
    response:
      status_code: 200
      json:
        token_type: bearer
      save:
        json:
          access_token: access_token

  - name: 削除対象のアイテムを作成する
    request:
      url: http://localhost:8000/items
      method: POST
      json:
        name: "削除テスト用アイテム"
        price: 500
    response:
      status_code: 201
      save:
        json:
          item_id: id

  - name: 認証付きでアイテムを削除する
    request:
      url: "http://localhost:8000/items/{item_id}"
      method: DELETE
      headers:
        Authorization: "Bearer {access_token}"
    response:
      status_code: 200
      json:
        message: "Item deleted"

3つのステージで構成されています。1つ目でログインしてトークンを保存し、2つ目でアイテムを作成してIDを保存し、3つ目で保存したトークンとIDを使って認証付きのDELETEリクエストを送信しています。

共通設定の外出し(includes)

テストファイルが増えてくるとURLのベース部分など共通の設定を毎回書くのは冗長です。Tavernでは!includeタグを使って共通設定を外部ファイルに切り出すことができます。

まず共通設定ファイルを作成します。

common.yaml
name: 共通設定
variables:
  base_url: "http://localhost:8000"

テストファイルではincludesで共通設定を読み込み、{base_url}で参照します。

test_health_with_include.tavern.yaml
test_name: ヘルスチェック(共通設定使用)

includes:
  - !include common.yaml

stages:
  - name: ヘルスチェック
    request:
      url: "{base_url}/health"
      method: GET
    response:
      status_code: 200
      json:
        status: ok

これにより環境ごとにURLを切り替えたい場合でもcommon.yamlを変更するだけで全テストに反映されます。

外部Python関数によるカスタム検証

YAMLの宣言的な検証だけでは対応できない複雑なバリデーションが必要な場合は、verify_response_withを使って外部のPython関数に検証を委譲できます。

helpers.py
def validate_item_response(response):
    """アイテムレスポンスのカスタム検証"""
    data = response.json()
    assert "id" in data, "レスポンスにidフィールドがありません"
    assert isinstance(data["id"], int), "idが整数ではありません"
    assert data["price"] > 0, f"priceが0以下です: {data['price']}"
test_items_custom_validation.tavern.yaml
test_name: カスタム検証でアイテム作成をテストする

stages:
  - name: アイテムを作成してカスタム検証を行う
    request:
      url: http://localhost:8000/items
      method: POST
      json:
        name: "カスタム検証テスト"
        price: 3000
    response:
      status_code: 201
      verify_response_with:
        function: helpers:validate_item_response

YAMLで表現しやすい部分はYAMLで、複雑なロジックはPython関数でと使い分けることで保守性を高めることができます。

Claude Codeを使ってTavernテストを自動生成する

TavernのテストはYAMLの宣言的なフォーマットで記述するため、AIによる自動生成と非常に相性が良いです。Claude Codeを使えば、FastAPIのソースコードを読み取って対応するTavernテストを自動生成できます。

実際にClaude Codeに以下のようなプロンプトを送ってみます。

main.pyのFastAPIエンドポイントに対するTavernテストをYAMLで生成して。
認証フローを含むマルチステージテストも作って。

Claude Codeはmain.pyを読み取り、エンドポイントの定義(パス、HTTPメソッド、リクエストボディのスキーマ、レスポンスのステータスコード)を解析して、以下のようにTavernテストファイルを生成してくれます。

⏺ 2つのテストファイルを作成しました。

  test_error_cases.tavern.yml — エラーケーステスト(4テスト)

  ┌────────────────────────┬─────────────────────────────────────────────┐
  │         テスト         │                  検証内容                   │
  ├────────────────────────┼─────────────────────────────────────────────┤
  │ 存在しないアイテム取得 │ GET /items/99999 → 404                      │
  ├────────────────────────┼─────────────────────────────────────────────┤
  │ 認証なし削除           │ DELETE without Authorization → 422          │
  ├────────────────────────┼─────────────────────────────────────────────┤
  │ 不正トークン削除       │ Bearer invalid-token → 401                  │
  ├────────────────────────┼─────────────────────────────────────────────┤
  │ 不正認証情報ログイン   │ 間違ったパスワード/存在しないユーザー → 401 │
  └────────────────────────┴─────────────────────────────────────────────┘

  test_full_lifecycle.tavern.yml — 完全ライフサイクルテスト(2テスト)

  ┌────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
  │         テスト         │                                    検証内容                                    │
  ├────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
  │ CRUD完全ライフサイクル │ ログイン → アイテム作成 → 取得確認 → 認証付き削除 → 削除後404確認(5ステージ) │
  ├────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
  │ オプショナルフィールド │ description省略時にnullが返ることを確認                                        │
  └────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘

  既存の test_auth.tavern.yml との違いとして、削除後の404確認や!int型マッチャーの使用、エラーパス網羅を追加しています。

特にTavernのテストは宣言的なYAMLなので、Pythonのテストコードと比較してClaude Codeが正確なテストを生成しやすく、生成されたテストの可読性も高いのがメリットです。

従来のPythonコードでAPIテストを書く場合は、TestClientの使い方やアサーションの書き方などPython固有の知識が必要でしたが、Tavernであれば「どのURLに何を送って何が返ってくるか」というAPIの仕様そのものをYAMLで表現するだけなので、Claude Codeへの指示も簡潔になります。

新しいエンドポイントを追加した際に「このエンドポイントのTavernテストを追加して」と依頼するだけでテストが生成されるので、テスト作成のコストを大幅に削減できます。

まとめ

APIテストをYAMLで宣言的に記述できるTavernを使ってFastAPIアプリケーションのテストを行ってみました。

Pythonのテストコードと比較してTavernは以下のようなメリットがあります。

  • テストの可読性が高く、APIの仕様がそのままテスト定義になる
  • マルチステージテストで認証フローなどの複雑なシナリオも簡潔に記述できる
  • pytestプラグインとして動作するため既存のpytest環境にそのまま導入できる
  • YAMLの宣言的なフォーマットはClaude Codeによる自動生成とも相性が良い

一方で、Tavernは実際にHTTPリクエストを送信するためテスト対象のサーバーを起動しておく必要がある点と、非常に複雑なアサーションロジックが多い場合はPythonコードのほうが柔軟な点には注意が必要です。

導入もuv add "tavern[pytest]"だけで完了し、既存のpytest環境にすぐに追加できるため、APIのインテグレーションテストを検討されている方はぜひ試してみてください。

最後まで読んで頂いてありがとうございました。

この記事をシェアする

FacebookHatena blogX

関連記事