REST APIテストの設計方法|pytest×requestsで正常系・異常系を自動化する

テスト自動化

REST APIテストでは、「どこまでテストすべきか」が最も難しいポイントです。

特にQAエンジニア初心者は、

  • 正常系だけで十分なのか
  • 認証・異常系はどこまで必要か
  • APIテストケースをどう設計すべきか

で悩みがちです。

本記事では、REST APIテストの設計4軸(正常系・異常系・認証・境界値)を、pytest × requests の実装コード付きで体系的に解説します。

この記事でわかること

  • ✅ REST APIテストの設計4軸(正常系・異常系・認証・境界値)
  • ✅ pytest × requests の実装パターン(コード付き)
  • ✅ APIテストケースの作り方とテンプレート
  • ✅ 認証・境界値・異常系テストの考え方
  • ✅ 現場でよくある失敗パターンと対策

⚠️ コードサンプルについて

この記事のサンプルURLには jsonplaceholder.typicode.com(学習用フェイクAPI)を使用しています。実際には認証・バリデーション・DELETEの永続削除などは実装されていません。記事全体を「実務APIを想定したテスト設計例」としてお読みください。

APIテストは一般的にE2Eテストより高速・安定しており、テストピラミッドの中核を担います。設計を体系化することで、バグの早期発見とテストの保守性が大幅に向上します。

📌 この記事はこんな方におすすめ

  • REST APIテストを始めたいが設計方法がわからない方
  • pytest × requestsでのAPIテスト実装を学びたい方
  • 正常系だけでなく異常系・認証・エラーハンドリングも網羅したい方
  • テスト自動化ロードマップのAPIテスト(STEP 4)を学習中の方

✅ この記事を読むと得られること

  • REST APIテストの設計4軸(正常系・異常系・認証・エラー)がわかる
  • pytest × requestsを使った実装パターンがコード付きでわかる
  • 実務で使えるテストケース設計の考え方が身につく

👤 この記事を書いた人

QAエンジニア・テスト自動化エンジニアとして15年以上の実務経験を持つ Yoshi が執筆。REST APIテストは実務プロジェクトで毎日実施しており、この記事で紹介するパターンはチームの標準として使っています。実装コードはGitHubで公開中:github.com/YOSHITSUGU728/automated-testing-portfolio

REST APIテストとは?E2Eテストとの違い

REST APIテストとは、アプリケーションのAPIエンドポイントに対してHTTPリクエストを送り、レスポンスが期待通りかを検証するテストです。ブラウザを使わずHTTPリクエストを直接送るため、E2Eテストより格段に高速です。

比較項目APIテストE2E(Selenium)テスト
実行速度⚡ 数秒🐢 数分
安定性✅ 高い(UI変更の影響なし)⚠️ UI変更で壊れやすい
環境ブラウザ不要ブラウザ必須
テスト範囲ビジネスロジック・データ整合性ユーザー操作フロー全体
CI/CDとの親和性✅ 非常に高い⚠️ ヘッドレス設定が必要
💡 テストピラミッドの考え方:理想的なテスト構成は「単体テスト(多)→ APIテスト(中)→ E2Eテスト(少)」のピラミッド型です。APIテストを厚くすることで、高速かつ安定したテストスイートを実現できます。

REST APIテストの設計4軸とは?

REST APIテストは以下の4軸でテストケースを設計します。この4軸を押さえることで、テストの抜け漏れを防ぎ、品質を網羅的に担保できます。

🎯 REST APIテスト設計の4軸

正常系テスト正しいリクエストで期待通りのレスポンスが返るか
異常系テスト不正なリクエストで適切なエラーが返るか
認証・認可テスト認証が必要なエンドポイントに正しくアクセス制御されているか
境界値・エッジケース極端な値・空値・型違いで正しく処理されるか

STEP 1:環境構築——pytest × requestsのセットアップ

まず必要なライブラリをインストールします。

pip install requests pytest pytest-html
💡 pytest-html とは:テスト結果をHTML形式のレポートとして出力できるプラグインです。CI/CDで視覚的なレポートを生成したい場合に便利です。
実行例:pytest --html=report.html → report.html が生成されます。

プロジェクト構成:

api-test-project/
├── tests/
│   ├── conftest.py         # base_url・認証token・共通fixture
│   ├── test_users.py       # ユーザーAPIのテスト
│   ├── test_posts.py       # 投稿APIのテスト
│   └── test_auth.py        # 認証APIのテスト
├── requirements.txt
└── pytest.ini

conftest.py の作成

# tests/conftest.py
import pytest
import requests

@pytest.fixture(scope="session")
def base_url():
    """ベースURLをfixture化(環境ごとに切り替えやすい)"""
    # ※ サンプルURL。実際のAPIに置き換えてください。
    return "https://jsonplaceholder.typicode.com"

@pytest.fixture(scope="session")
def auth_headers():
    """認証ヘッダーをfixture化"""
    return {
        "Authorization": "Bearer YOUR_TOKEN_HERE",
        "Content-Type": "application/json"
    }

class TimeoutSession(requests.Session):
    """デフォルトtimeoutを設定したSession。
    requestsはSession単体でtimeoutを設定できないため、wrapperで実現します。"""
    def request(self, *args, **kwargs):
        # connect timeout=3秒 / read timeout=10秒(タプルで個別設定可能)
        # timeout=None を渡すと個別テストで無効化できます
        kwargs.setdefault("timeout", (3, 10))
        return super().request(*args, **kwargs)

@pytest.fixture(scope="session")
def api_session():
    """requestsのSessionをfixture化(接続再利用+デフォルトtimeout設定)

    requests.get() を直接使うこともできますが、
    多くのプロジェクトではSessionで接続を再利用するケースが一般的です。
    """
    session = TimeoutSession()
    session.headers.update({"Content-Type": "application/json"})
    yield session
    session.close()
💡 Session Tiprequests.Session() を使うとHTTPコネクションを再利用できます。APIテストが100件以上になると速度差が顕著になるため、多くのプロジェクトで Session を標準にしています。

💡 safe_json helper と raise_for_status() の使い分け

実務では safe_jsontests/utils.py に分離すると再利用しやすくなります。

# tests/utils.py(共通ヘルパー)
import pytest

def safe_json(response):
    """JSONパースを安全に実行する共通ヘルパー"""
    try:
        return response.json()
    except ValueError:
        # JSONDecodeError は ValueError のサブクラスのため、ValueError で捕捉できます
        pytest.fail("レスポンスがJSON形式ではありません")

# 各テストファイルでのimport
from tests.utils import safe_json

# raise_for_status() はステータスコードが4xx/5xxのとき例外を発生させます
# ただし「異常系テスト」ではステータスコード自体を検証したいため使わないことに注意

# 使う場合(正常系の前提確認)
response = api_session.get(f"{base_url}/users/1")
response.raise_for_status()  # 4xx/5xx なら例外発生
data = safe_json(response)

# 使わない場合(異常系テスト)
response = api_session.get(f"{base_url}/users/99999")
assert response.status_code == 404  # ← raise_for_status() を使うと例外になってしまう

STEP 2:正常系テスト設計——HTTPメソッドごとの検証ポイント

正常系テストでは「期待通りのステータスコード」と「レスポンスの内容」の両方を検証します。

GET リクエストのテスト

# tests/test_users.py
# ※ 以降のコードでも同じ import を使用します
import pytest
import requests
from tests.utils import safe_json  # JSONパース共通ヘルパー

class TestGetUser:
    """GETリクエストの正常系テスト"""

    def test_get_user_returns_200(self, base_url, api_session):
        """正常系:ユーザー取得でステータス200が返る"""
        # TimeoutSession により全リクエストに timeout=10 が自動適用されます
        response = api_session.get(f"{base_url}/users/1")

        assert response.status_code == 200

    def test_get_user_returns_expected_fields(self, base_url, api_session):
        """正常系:レスポンスに必須フィールドが含まれる"""
        response = api_session.get(f"{base_url}/users/1")
        data = safe_json(response)  # JSONパースを安全に実行

        assert "id" in data
        assert "name" in data
        assert "email" in data

    def test_get_user_id_matches_request(self, base_url, api_session):
        """正常系:取得したユーザーIDがリクエストと一致する"""
        user_id = 1
        response = api_session.get(f"{base_url}/users/{user_id}")
        data = safe_json(response)

        assert data["id"] == user_id

    def test_get_users_list_returns_array(self, base_url, api_session):
        """正常系:ユーザー一覧がリスト形式で返る"""
        response = api_session.get(f"{base_url}/users")
        data = safe_json(response)

        assert isinstance(data, list)
        assert len(data) > 0

    @pytest.mark.performance  # CIでは分離実行を推奨
    def test_response_time_is_acceptable(self, base_url, api_session):
        """正常系:レスポンスタイムが2秒以内
        ※ pytest.ini に markers = performance: ... を追加してからご使用ください
        """
        response = api_session.get(f"{base_url}/users/1")

        # ※ response.elapsed はHTTP通信全体の時間を含みます
        # (DNS解決・TCP接続・TLSハンドシェイク・サーバー応答など)
        # アプリケーションの処理時間そのものではない点に注意してください
        # network jitter によりCIでflakyになりやすいため、
        # pytest.mark.performance で分離して「pytest -m "not performance"」で除外する運用も一般的です
        assert response.elapsed.total_seconds() < 2.0

    def test_response_is_valid_json(self, base_url, api_session):
        """正常系:レスポンスがJSON形式で返る"""
        response = api_session.get(f"{base_url}/users/1")

        # HTMLエラーページが返っている場合、response.json() は JSONDecodeError になります
        # Content-Type を先に確認することで安全に処理できます
        # ※ RFC 7807 対応APIでは application/problem+json を返す場合もあります
        content_type = response.headers.get("Content-Type", "")
        assert (
            "application/json" in content_type
            or "application/problem+json" in content_type
        )

        data = safe_json(response)  # JSONパースは safe_json() が内部でエラー処理済み

        assert "id" in data

POST リクエストのテスト

class TestCreatePost:
    """POSTリクエストの正常系テスト"""

    def test_create_post_returns_201(self, base_url, api_session):
        """正常系:投稿作成でステータス201が返る"""
        payload = {
            "title": "テスト投稿",
            "body": "テスト本文です",
            "userId": 1
        }
        response = api_session.post(f"{base_url}/posts", json=payload)

        assert response.status_code == 201

    def test_create_post_returns_created_data(self, base_url, api_session):
        """正常系:作成した投稿データがレスポンスに含まれる"""
        payload = {"title": "タイトル", "body": "本文", "userId": 1}
        response = api_session.post(f"{base_url}/posts", json=payload)
        data = safe_json(response)

        assert data["title"] == payload["title"]
        assert data["body"] == payload["body"]
        assert "id" in data  # 新しいIDが払い出される
💡 検証すべき正常系ポイント

  • ステータスコード(GET=200、POST=201 が一般的。PUT/PATCHは200または204、DELETEは204を返すAPIが多いが実装によって異なる)
  • レスポンスボディの必須フィールド
  • データの一致(リクエストで送った値がレスポンスに反映されているか)
  • レスポンスタイム(SLAが定義されている場合は必ずテスト)

STEP 3:異常系テスト設計——エラーパターンの網羅

異常系テストは「どんな不正なリクエストを送っても適切なエラーが返ること」を確認します。parametrize を使うとパターンを効率よく網羅できます。

ステータスコード別の異常系テスト

class TestErrorHandling:
    """異常系テスト"""

    def test_get_nonexistent_user_returns_404(self, base_url, api_session):
        """異常系:存在しないユーザーIDで404が返る"""
        response = api_session.get(f"{base_url}/users/99999")

        assert response.status_code == 404

    @pytest.mark.parametrize("invalid_id", [
        "abc",      # 文字列
        "!@#",      # 記号
        "-1",       # 負の値
        "0",        # ゼロ
        "9" * 20,   # 極端に長い数値
    ])
    def test_get_user_with_invalid_id(self, base_url, api_session, invalid_id):
        """異常系:無効なIDで400または404が返る"""
        response = api_session.get(f"{base_url}/users/{invalid_id}")

        # API仕様によって 400 / 404 のどちらを返すかは異なります
        # OpenAPI / API仕様書を基準に期待値を定義しましょう
        # ※ 理想は仕様書に沿って1つに固定することです。複数許容は暫定対応として扱ってください
        assert response.status_code in [400, 404]

    @pytest.mark.parametrize("missing_field, payload", [
        ("title", {"body": "本文のみ", "userId": 1}),
        ("body",  {"title": "タイトルのみ", "userId": 1}),
        ("userId", {"title": "タイトル", "body": "本文"}),
    ])
    def test_create_post_with_missing_required_field(
        self, base_url, api_session, missing_field, payload
    ):
        """異常系:必須フィールドが欠けている場合に400が返る"""
        response = api_session.post(f"{base_url}/posts", json=payload)

        # 400 Bad Request または 422 Unprocessable Entity
        assert response.status_code in [400, 422]

STEP 4:認証・認可テスト設計

認証テストでは「正しいトークンでアクセスできること」と「トークンがない・無効な場合に拒否されること」の両方を確認します。

# tests/test_auth.py
import pytest
import requests

class TestAuthentication:
    """認証・認可テスト
    ※ /protected/users などのエンドポイントは実務向けのサンプルAPI例です。
    jsonplaceholder.typicode.com には存在しないため、実際の認証APIに置き換えて実行してください。
    """

    def test_protected_endpoint_without_token_returns_401(self, base_url, api_session):
        """認証なし:401 Unauthorized が返る"""
        response = api_session.get(f"{base_url}/protected/users")

        assert response.status_code == 401

    def test_protected_endpoint_with_invalid_token_returns_401(self, base_url, api_session):
        """無効トークン:401 Unauthorized が返る"""
        headers = {"Authorization": "Bearer INVALID_TOKEN"}
        response = api_session.get(f"{base_url}/protected/users", headers=headers)

        assert response.status_code == 401

    def test_protected_endpoint_with_valid_token_returns_200(
        self, base_url, api_session, auth_headers
    ):
        """有効トークン:200 OK が返る"""
        response = api_session.get(f"{base_url}/protected/users", headers=auth_headers)

        assert response.status_code == 200

    def test_user_cannot_access_other_users_data(self, base_url, api_session, auth_headers):
        """認可:他ユーザーのデータに403 Forbiddenが返る"""
        # 自分のIDではないユーザーデータへアクセス
        response = api_session.get(
            f"{base_url}/users/999/private",
            headers=auth_headers
        )

        assert response.status_code == 403

    @pytest.mark.parametrize("expired_token", [
        "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired",
        "Bearer ",          # 空のトークン
        "InvalidFormat",    # Bearer プレフィックスなし
    ])
    def test_invalid_token_formats_return_401(self, base_url, api_session, expired_token):
        """各種の無効トークン形式で401が返る"""
        headers = {"Authorization": expired_token}
        response = api_session.get(f"{base_url}/protected/users", headers=headers)

        assert response.status_code == 401

STEP 5:境界値・エッジケースのテスト設計

境界値テストはAPIの安定性を担保するために重要です。「極端な値」「空値」「型が違う値」のパターンを網羅します。

# tests/test_boundary.py
import pytest

# 境界値テストデータ
BOUNDARY_TITLES = [
    ("empty_string", "", [400, 422]),                  # 空文字
    ("single_char", "A", [200, 201]),                  # 最小有効値
    ("max_length", "A" * 255, [200, 201]),             # 最大有効値
    ("over_max_length", "A" * 256, [400, 422]),        # 最大値超過
    ("special_chars", "!@#$%^&*()", [200, 400]),          # 特殊文字
    ("emoji", "📝 テスト投稿 🚀", [200, 201]),           # 絵文字
    # SQLインジェクションテストで重要なのは:
    # ✅ 500エラーにならないこと(DBエラーがそのまま返らない)
    # ✅ DBエラーの詳細を返さないこと(テーブル名・スキーマ漏洩防止)
    # ✅ 認証・認可を突破できないこと
    # ⚠️ 「SQL文字列を拒否すること」だけが目的ではありません
    # 💡 サニタイズ済みAPIでは、SQL文字列が通常の文字列として扱われ200になるケースもあります。
    #    重要なのは「500エラーにならないこと」です。
    # ⚠️ 本番環境や第三者APIへの送信は厳禁。許可された検証環境のみで実施。参考:OWASP Testing Guide
    ("sql_injection", "' OR '1'='1", [200, 400, 422]),
    # XSSスクリプトが安全に処理されるかも確認(500にならないことが目的)
    ("xss_script", "<script>alert('xss')</script>", [200, 400, 422]),
    # ※ SQLインジェクション文字列の期待結果はAPI仕様によって異なります
    # 重要なのは「500 サーバーエラー」にならず安全に処理されることです
]

class TestBoundaryValues:
    """境界値・エッジケーステスト"""

    @pytest.mark.parametrize("case_name, title, expected_statuses", BOUNDARY_TITLES)
    def test_post_title_boundary(
        self, base_url, api_session, case_name, title, expected_statuses
    ):
        """タイトルの境界値テスト"""
        payload = {"title": title, "body": "本文", "userId": 1}
        response = api_session.post(f"{base_url}/posts", json=payload)

        assert response.status_code in expected_statuses, \
            f"Case: {case_name}, got {response.status_code}"

    def test_pagination_first_page(self, base_url, api_session):
        """ページネーション:最初のページ"""
        response = api_session.get(f"{base_url}/posts?_page=1&_limit=10")

        assert response.status_code == 200
        assert len(safe_json(response)) <= 10

    def test_pagination_beyond_last_page_returns_empty(self, base_url, api_session):
        """ページネーション:存在しないページは空リストが返る"""
        response = api_session.get(f"{base_url}/posts?_page=99999&_limit=10")

        assert response.status_code == 200
        assert safe_json(response) == []  # 空リストが返る(404ではない)

STEP 6:スキーマ検証——jsonschema でレスポンスの型・フォーマットを検証する

スキーマ検証を追加することで「フィールドの型が正しいか」「必須フィールドが存在するか」を一括で検証できます。

# pip install jsonschema
from jsonschema import validate, ValidationError, FormatChecker
import pytest

# 💡 補足:jsonschema の format(email・uri等)は、
# FormatChecker() を指定しないと厳密チェックされません。
# 例:email フィールドの形式検証はFormatCheckerなしでは無視されます。

# レスポンスのスキーマ定義
USER_SCHEMA = {
    "type": "object",
    "required": ["id", "name", "email", "username"],
    "additionalProperties": False,  # 未定義フィールドを拒否(厳密検証したい場合のみ使用)
    # ⚠️ APIに新フィールドが追加されると(後方互換性のある変更でも)テストが壊れます。
    # 「APIの進化に合わせてテストも更新する」運用が必要になります。
    # 初心者は省略可。Consumer-Driven Contract Testing(Pact等)でも重要な観点です。
    "properties": {
        "id":       {"type": "integer"},
        "name":     {"type": "string", "minLength": 1},
        "email":    {"type": "string", "format": "email"},
        "username": {"type": "string", "minLength": 1},
    }
}

POST_SCHEMA = {
    "type": "object",
    "required": ["id", "title", "body", "userId"],
    "properties": {
        "id":     {"type": "integer"},
        "title":  {"type": "string"},
        "body":   {"type": "string"},
        "userId": {"type": "integer"},
    }
}

class TestResponseSchema:
    """スキーマ検証テスト"""

    def test_user_response_schema(self, base_url, api_session):
        """ユーザーレスポンスのスキーマが正しい"""
        response = api_session.get(f"{base_url}/users/1")
        data = safe_json(response)

        try:
            validate(
                instance=data,
                schema=USER_SCHEMA,
                format_checker=FormatChecker()  # email形式などのformat検証を有効化
            )
        except ValidationError as e:
            pytest.fail(f"スキーマ検証失敗: {e.message}")

    def test_post_list_all_items_match_schema(self, base_url, api_session):
        """投稿一覧の全アイテムがスキーマに準拠している"""
        response = api_session.get(f"{base_url}/posts")
        posts = safe_json(response)

        for post in posts[:5]:  # 全件は時間がかかるので最初の5件をサンプル検証
            try:
                validate(
                    instance=post,
                    schema=POST_SCHEMA,
                    format_checker=FormatChecker()
                )
            except ValidationError as e:
                pytest.fail(f"Post ID {post.get('id')}: スキーマ検証失敗 - {e.message}")

STEP 7:PUT/PATCHリクエストのテスト設計

更新系のAPIテストでは「更新後のデータが正しく反映されていること」を必ず確認します。REST原則上はPATCHが部分更新・PUTが全体置き換えですが、多くのプロジェクトではPUTでも部分更新を実装するAPIが存在します。実際のAPI仕様書に従って設計しましょう。

💡 PUTとPATCHの違い(REST原則):REST原則ではPUTは冪等性を持つ更新として扱われます。一方PATCHはRFC上「冪等である必要はない」HTTPメソッドですが、実際のAPIでは冪等として実装されるケースもあります。API仕様書を基準に期待値を設計しましょう。
class TestUpdatePost:
    """PUT/PATCHリクエストのテスト"""

    def test_patch_post_returns_200(self, base_url, api_session):
        """正常系:部分更新でステータス200が返る"""
        payload = {"title": "更新後タイトル"}

        response = api_session.patch(
            f"{base_url}/posts/1",
            json=payload
        )
        data = safe_json(response)

        assert response.status_code == 200
        assert data["title"] == "更新後タイトル"

    def test_put_post_returns_200(self, base_url, api_session):
        """正常系:全体更新でステータス200が返る"""
        payload = {
            "id": 1,
            "title": "全体更新タイトル",
            "body":  "全体更新本文",
            "userId": 1
        }

        response = api_session.put(
            f"{base_url}/posts/1",
            json=payload
        )
        data = safe_json(response)

        assert response.status_code == 200
        assert data["title"] == payload["title"]
        assert data["body"]  == payload["body"]

    def test_patch_nonexistent_post_returns_404(self, base_url, api_session):
        """異常系:存在しない投稿の更新で404が返る"""
        response = api_session.patch(
            f"{base_url}/posts/99999",
            json={"title": "更新"}
        )

        assert response.status_code == 404

STEP 8:モックテストで外部API依存をなくす

外部APIに依存するテストは、ネットワーク障害やAPI変更で不安定になります。responses ライブラリを使うと、実際のAPIを呼ばずにレスポンスをモック化できます。

# pip install responses
import responses
import requests
import pytest

@responses.activate
def test_external_api_mock():
    """外部APIをモック化してテスト(ネットワーク不要)"""
    # モックレスポンスを定義
    responses.add(
        responses.GET,
        "https://api.example.com/users/1",
        json={"id": 1, "name": "Test User"},
        status=200
    )

    # 実際のAPIを呼ばずにモックが返る
    response = requests.get("https://api.example.com/users/1")

    assert response.status_code == 200
    assert safe_json(response)["name"] == "Test User"
    assert len(responses.calls) == 1  # 1回だけ呼ばれたことを確認

@responses.activate
def test_api_error_handling_with_mock():
    """モックでエラーレスポンスをシミュレート"""
    responses.add(
        responses.GET,
        "https://api.example.com/users/999",
        json={"message": "Not Found"},
        status=404
    )

    response = requests.get("https://api.example.com/users/999")

    assert response.status_code == 404
💡 モックテストを使う場面:外部決済API・SMS認証・メール送信など、テスト環境から直接叩けない外部サービスに依存するテストで特に有効です。CIでも安定して動作します。

📋 ここまでのまとめ

  • 正常系:ステータスコード・必須フィールド・データ一致・レスポンスタイムを検証
  • 異常系:400・401・403・404・422の各エラーパターンをparametrizeで網羅
  • 認証:トークンなし・無効トークン・有効トークン・認可(403)の4パターン
  • 境界値:空文字・最大長・型違い・特殊文字・SQLインジェクションを確認
  • スキーマ検証:jsonschemaでフィールドの型・必須項目を体系的に検証

STEP 9:DELETEリクエストのテスト設計

DELETEリクエストのテストでは「削除成功」と「削除後に再取得すると404になること」の2点が重要です。また、テスト用データはfixtureで作成・クリーンアップする設計が推奨されます。

💡 fixture によるテストデータのクリーンアップ例

@pytest.fixture
def test_post(base_url, api_session):
    """テスト用投稿を作成し、テスト後に削除するfixture"""
    payload = {"title": "テスト投稿", "body": "fixtureで作成", "userId": 1}
    response = api_session.post(f"{base_url}/posts", json=payload)
    post_id = safe_json(response)["id"]

    yield post_id  # テスト実行

    # teardown:テスト後にクリーンアップ
    # try/except で保護することでteardownの失敗がテスト結果に影響しないようにします
    try:
        api_session.delete(f"{base_url}/posts/{post_id}")
    except Exception as e:
        print(f"Cleanup failed for post_id={post_id}: {e}")
class TestDeletePost:
    """DELETEリクエストのテスト"""

    def test_delete_post_returns_204(self, base_url, api_session):
        """正常系:投稿削除でステータス204が返る"""
        response = api_session.delete(f"{base_url}/posts/1")

        assert response.status_code == 204

    def test_delete_post_response_body_is_empty(self, base_url, api_session):
        """正常系:削除成功のレスポンスボディは空"""
        response = api_session.delete(f"{base_url}/posts/1")

        # RFC上 204 はレスポンスボディを持たない想定ですが、
        # 一部API実装では空JSON・空配列・whitespaceを返すケースもあります。
        # 実際のAPI仕様に従って検証してください。
        assert response.status_code == 204
        assert not response.content  # 空ボディであることを確認
        # ※ APIによっては以下が安全な場合があります:
        # assert response.content in [b"", b"null", b"{}"]

    def test_delete_nonexistent_post_returns_404(self, base_url, api_session):
        """異常系:存在しない投稿の削除で404が返る"""
        response = api_session.delete(f"{base_url}/posts/99999")

        assert response.status_code == 404

    def test_delete_without_auth_returns_401(self, base_url, api_session):
        """認証なし:401 Unauthorized が返る"""
        # 認証なしのsessionで削除を試みる
        response = api_session.delete(f"{base_url}/protected/posts/1")

        assert response.status_code == 401

    def test_deleted_post_cannot_be_fetched(self, base_url, api_session, test_post):
        """削除後に再取得すると404になる(fixture でデータを管理)"""
        # NOTE: jsonplaceholder.typicode.com では永続削除されません。
        # 実務APIを想定したサンプルです。
        delete_response = api_session.delete(f"{base_url}/posts/{test_post}")
        assert delete_response.status_code == 204

        # 削除後に再取得すると404が返るはず
        get_response = api_session.get(f"{base_url}/posts/{test_post}")
        assert get_response.status_code == 404
💡 DELETEテストの注意点:テスト環境のデータを削除すると他のテストに影響します。削除テスト用のデータはfixtureで作成し、テスト終了後にクリーンアップする設計を徹底しましょう。

APIテストでよく使うHTTPステータスコード一覧とは?

APIテストを設計するうえで、ステータスコードの意味を正しく理解することは必須です。テスト設計時の参照として活用してください。

コード意味主なユースケーステスト観点
200 OK成功GET・PUT・PATCH の成功正常系
201 Created作成成功POST でリソース作成正常系
204 No Content削除成功DELETE でリソース削除正常系
400 Bad Request不正なリクエストパラメーター形式エラー異常系・境界値
401 Unauthorized認証エラートークンなし・無効なトークン認証テスト
403 Forbidden権限なしアクセス権のないリソースへのアクセス認可テスト
404 Not Foundリソース未存在存在しないIDへのアクセス異常系
422 Unprocessable Entityバリデーションエラー必須フィールド欠け・型エラー異常系・境界値
500 Internal Server Errorサーバーエラー予期しないエラーテストで500が返る=バグ
⚠️ 重要:テストで 500 Internal Server Error が返った場合は即バグ報告が必要です。不正な入力でも500を返すAPIは安全ではありません。SQLインジェクションや境界値テストで500が返らないことを必ず確認しましょう。

STEP 10:ネットワーク例外・タイムアウトのテスト設計

実務のAPIテストでは「正常なレスポンス」だけでなく「通信エラー時の例外処理」もテストすることが重要です。

import pytest
import requests
from unittest.mock import patch

class TestNetworkErrorHandling:
    """ネットワーク例外テスト"""

    @patch.object(requests.Session, "get")
    def test_timeout_exception(self, mock_get):
        """Timeout例外をSessionベースのモックで再現(CI環境でも安定して動作)"""
        mock_get.side_effect = requests.exceptions.Timeout

        session = requests.Session()
        with pytest.raises(requests.exceptions.Timeout):
            session.get("https://example.com")

    @patch.object(requests.Session, "get")
    def test_connection_error(self, mock_get):
        """ConnectionErrorをSessionベースのモックで再現(DNS依存を排除してCI安定化)"""
        mock_get.side_effect = requests.exceptions.ConnectionError

        session = requests.Session()
        with pytest.raises(requests.exceptions.ConnectionError):
            session.get("https://example.com")
⚠️ timeout は必ず設定しましょうtimeout を指定しないとAPIが応答しない場合にテストが永久にハングします。現場では timeout=10 程度を Session のデフォルトとして設定するのが安全です。

⚠️ よくあるはまりポイント5選

① ステータスコードだけしかテストしない

ステータスコード200が返っても、レスポンスボディが空や間違ったデータの場合があります。必ずボディの内容も検証しましょう。assert response.json()["id"] == expected_idのような具体的な検証が重要です。

② 正常系しかテストしない

多くのバグは異常系(無効な入力・認証エラー・存在しないリソース)で発見されます。parametrize を使って異常系のパターンを最低5〜10ケースは用意しましょう。

③ テスト間でデータが依存している

「テストAで作成したデータをテストBで使う」という構成は、テストの順序依存バグを生みます。各テストは独立して実行できるように設計し、テスト用データはfixtureで管理しましょう。

④ Content-Type ヘッダーを忘れる

requests.post(url, json=data) は自動で Content-Type: application/json を設定しますが、requests.post(url, data=data) は設定しません。APIが 415 Unsupported Media Type を返す場合はこれが原因であることが多いです。

⑤ 本番環境を直接テストしてしまう

APIテストは実際のデータを作成・変更・削除します。誤って本番環境に向けて実行するとデータ破損のリスクがあります。base_url fixture で環境を管理し、CI/CDでは環境変数で切り替えることを徹底しましょう。

📖 Selenium × pytest の fixture・parametrize の使い方はこちらSelenium × pytest 実践ガイド|fixture・parametrize・conftest.py・mark

FAQ

Q. APIテストと単体テストの違いは何ですか?

単体テストは個々の関数やクラスをテストします。APIテストはHTTPエンドポイント全体(ルーティング・バリデーション・DB操作・レスポンス)を外部から検証します。単体テストより結合度が高く、バックエンドの統合品質を確認できる点が特徴です。テストピラミッドでは「単体テスト(多)→APIテスト(中)→E2Eテスト(少)」の構成が推奨されています。

Q. PostmanだけではなくPythonでAPIテストを書く必要はありますか?

Postmanは手動確認・仕様共有には優れていますが、CI/CDへの組み込み・パラメータ化・フィクスチャ管理の面ではPython(pytest + requests)の方が優位です。Postmanで動作確認し、pytest + requestsで自動化するという使い分けがよく見られます。特にテストケースが10件以上になったらコードベースでの管理を検討しましょう。

Q. APIテストはどのレイヤーまで自動化すべきですか?

最低限「正常系・認証・主要な異常系(400・401・404)」を自動化することを推奨します。全パターンを自動化しようとすると保守コストが増大するため、リスクが高い箇所(認証・決済・個人情報)を優先しましょう。E2Eテストで確認できない「バリデーション・認可・データ整合性」の観点がAPIテストに向いています。

Q. requestsとPlaywright API Testingはどちらを使うべきですか?

まず requests から始めることを推奨します。シンプルで学習コストが低く、REST APIテストの大部分はrequestsで対応できます。すでにPlaywrightでE2Eテストを書いている場合は、同一テストスイート内でAPIテストも書けるPlaywright API Testingが便利です。

Q. テストデータの作成・削除はどう管理すればいいですか?

テスト用データはfixtureで管理し、yield の後にクリーンアップするのがベストプラクティスです。例えば「テスト用ユーザーを作成 → テスト実行 → ユーザー削除」という流れをfixture内に書きます。テスト環境にはテスト専用のデータベースを用意するのが理想です。

Q. 外部APIに依存するテストはどうすればいいですか?

unittest.mockresponses(pip install responses)ライブラリを使ってAPIレスポンスをモック化します。外部APIへの依存をなくすことで、ネットワーク障害やAPIの変更に影響されない安定したテストが実現できます。

Q. ステータスコードの使い分けがわかりません

よく使うコードの目安:200(GETの成功)・201(POST=リソース作成成功)・204(DELETE=削除成功・ボディなし)・400(不正なリクエスト)・401(認証エラー)・403(認可エラー・アクセス権なし)・404(リソースが存在しない)・422(バリデーションエラー)・500(サーバーエラー)です。

Q. APIテストをCI/CDに組み込む方法は?

GitHub Actionsで pytest tests/ を実行するだけでAPIテストもE2Eテストも同時に実行できます。APIテストはブラウザ不要のためheadlessモードの設定も不要で、CI/CDへの組み込みが非常に簡単です。環境変数で BASE_URL を切り替えることでステージング・本番環境への対応も容易です。


Q. APIテストでモック(Mock)は必要ですか?

外部APIや決済サービスなど「テスト環境から直接叩けない」依存がある場合に有効です。responses ライブラリや unittest.mock を使えば、実際のAPIを呼ばずにレスポンスをシミュレートできます。CI/CDの安定性向上にも効果的です。逆に自分たちが管理するAPIのテストでは、モックを使いすぎると実際の動作との乖離が起きやすいため注意が必要です。

Q. APIテストとContract Testingの違いは何ですか?

APIテストは「自分たちのAPIが正しく動くか」を検証します。Contract Testing(Pact等)は「消費者(Consumer)と提供者(Provider)の間の契約を検証する」アプローチで、マイクロサービス間の互換性保証に使われます。APIテストは単体での品質検証、Contract Testingはサービス間の統合品質保証と役割が異なります。

Q. Postmanだけでは足りませんか?

Postmanは仕様確認・手動テスト・チーム共有には優れています。ただし「大量のテストケースをCI/CDで自動実行する」「fixtureでテストデータを管理する」「parametrizeで異常系パターンを網羅する」用途では pytest + requests の方が有利です。Postmanで動作確認し、pytest + requestsで継続的自動化という使い分けが実際の現場では多く見られます。

📋 APIテストケース設計テンプレート

テスト設計時に以下の4軸からケースを作成することで、抜け漏れを防げます。

観点テストケース例期待ステータス
正常系有効なパラメーターでリクエスト200 / 201 / 204
異常系必須フィールド欠け・型不正・存在しないID400 / 404 / 422
認証トークンなし・無効・有効・他ユーザーアクセス401 / 403
境界値最大文字数・空文字・特殊文字・数値の上限200 / 400 / 422
スキーマ必須フィールドの存在・型・フォーマット確認200
エラー安全性SQLインジェクション・XSS文字列(検証環境のみ)500 が返ったらバグ

📋 APIテスト観点チェックリスト

テスト設計の抜け漏れ防止にご活用ください。

✅ ステータスコードの検証(200・201・204・400・401・403・404・422・500)
✅ レスポンスボディ・必須フィールドの存在確認
✅ スキーマ検証(フィールドの型・フォーマット)
✅ 認証テスト(トークンなし・無効・有効の3パターン)
✅ 認可テスト(他ユーザーのデータに403が返るか)
✅ レスポンスタイム(SLA定義がある場合)
✅ 境界値・特殊文字・SQLインジェクションで500にならないか
✅ timeout設定による例外ハンドリング

実務では、この記事で紹介した4軸(正常系・異常系・認証・境界値)をチェックリストとして使い、テスト設計レビューを行っています。特に認証テストの抜け漏れは本番障害に直結しやすいため、必ず全パターンを確認するようにしています。

📋 この記事のまとめ

  • REST APIテストは 正常系・異常系・認証・境界値の4軸で設計する
  • pytest × requestsを使えばシンプルかつ保守しやすいAPIテストが実装できる
  • parametrize で異常系パターンを効率よく網羅できる
  • スキーマ検証(jsonschema)でフィールドの型・必須項目を体系的にテストできる
  • テスト間のデータ依存・本番環境への誤実行・Content-Type忘れに注意する

まずは正常系のテストを1つ動かしてみてください。GETリクエストを送ってステータスコードとフィールドを確認するだけで、APIテストの基本的な流れが身につきます。そこから異常系・認証・境界値へとテストを広げていくのが効率的です。

タイトルとURLをコピーしました