← プログラミングページの先頭へ

pytest: Python 自動テスト

1. 基本的な考え方

pytest は、Python エコシステムで広く使用されている自動テスト フレームワークです。

主に検証に使用されます。

  • ビジネスロジックは正しいですか?
  • API が期待どおりに返されるかどうか。
  • 認証と権限が有効かどうか。
  • データベースの動作は正しいですか?
  • 例外や特殊なケースは正しく処理されますか。
  • コードを変更した後に既存の関数が破棄されるかどうか (Regression Testing)。

基本的なプロセス:

コードを書く
    ↓
テストを書く
    ↓
走る pytest
    ↓
テストケースを実行する
    ↓
パス / 失敗しました

基本的な例

事業者コード:

def add(a, b):
    return a + b

テスト:

def test_add():
    assert add(1, 2) == 3

ビジネスコードが誤って次のように変更された場合:

def add(a, b):
    return a - b

テストは失敗します:

AssertionError: assert -1 == 3

pytest は Python をネイティブ assert として使用し、詳細な障害情報を提供します。

2. インストールとプロジェクトの構成

Django + DRF プロジェクトは一般的に使用されます。

pip install pytest pytest-django pytest-mock pytest-cov
ツール機能
pytest基本的なテストフレームワーク
pytest-django統合された Django テスト環境
pytest-mockmocker Fixtureを提供してください
pytest-cov統計コードのテスト範囲

pytest.ini

プロジェクトのルート ディレクトリに作成します。

[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
testpaths = tests apps common/tests
addopts = --strict-config --strict-markers --import-mode=importlib

このうち、testpaths は実際のプロジェクト ディレクトリに応じて調整する必要があり、リストされたディレクトリが存在する必要があります。

重要な構成:

  • DJANGO_SETTINGS_MODULE:Django Settingsを指定してください。
  • python_files: テスト ファイルの命名規則を指定します。
  • testpaths: テストを検索するデフォルトのディレクトリを指定します。
  • strict-config: 構成を厳密に確認してください。
  • strict-markers: 未登録のカスタム Marker の使用を禁止します。
  • import-mode=importlib: importlib を使用してテスト モジュールをインポートし、パスとモジュールの名前の競合を減らします。

3. よく使用される実行コマンド

# すべてのテストを実行する
pytest

# 指定されたファイルを実行する
pytest tests/test_user.py

# 指定された関数を実行する
pytest tests/test_user.py::test_get_profile

# 詳細を表示
pytest -v

# 最初の失敗後に停止する
pytest -x

# 名前でフィルタリングする
pytest -k profile

# ショー print 出力
pytest -s

# 最後に失敗したテストのみを実行する
pytest --lf

# 最も遅いものを表示 10 テスト
pytest --durations=10

4. Fixture: 依存関係と環境の準備をテストする

Fixture は、テスト データ、依存オブジェクト、環境初期化ロジックを再利用するために使用されます。

使用:

import pytest


@pytest.fixture
def user(db):
    return User.objects.create_user(
        email="test@example.com",
        password="password123",
    )

テスト機能:

def test_user_email(user):
    assert user.email == "test@example.com"

pytest は、関数パラメータ名に基づいて Fixture を自動的に検索して実行し、戻り値をテスト関数に挿入します。

Fixture 依存関係

Fixture は他の Fixture に依存できます。

@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def authenticated_client(api_client, user):
    api_client.force_authenticate(user=user)
    return api_client


@pytest.fixture
def itinerary(db, user):
    return Itinerary.objects.create(
        user=user,
        title="Original Trip",
    )

使用:

def test_update_itinerary(
    authenticated_client,
    itinerary,
):
    response = authenticated_client.patch(
        f"/api/v1/itineraries/{itinerary.id}/",
        {"title": "Tokyo Trip"},
        format="json",
    )

    assert response.status_code == 200

ここでは、インターフェースによりユーザーが自分の旅程を変更できると仮定します。

実行プロセス:

test_update_itinerary
          ↓
authenticated_client ──→ api_client
          │
          └──→ user
                 ↓
              Database

itinerary ──→ user
          └─→ Database

同じテストでは、同じスコープ内の Fixture は通常 1 回だけ実行され、結果は他の依存関係で再利用できます。

conftest.py

複数のテスト ファイルが Fixture を共有する場合、次のように定義できます。

tests/
├── conftest.py
├── test_users.py
├── test_pois.py
└── test_itineraries.py

conftest.py 内の Fixture は、明示的にインポートしなくても、対応するディレクトリ範囲内のテストによって自動的に検出されます。

Fixture Scope

Scopeライフサイクル
functionデフォルトでは、各テスト関数は 1 回実行されます。
classテストクラスごとに 1 回実行されます
moduleテストモジュールごとに 1 回実行
packageテスト パッケージごとに 1 回実行
sessionpytest テスト セッション全体が 1 回実行されます

たとえば:

@pytest.fixture(scope="module")
def sample_config():
    return {"timeout": 30}

Django データベースを含む Fixture の場合、より大きな Scope を任意に使用することはできず、データベース Fixture のライフ サイクルとデータ分離を考慮する必要があります。

yield Fixture

リソースをクリーンアップする必要がある場合に使用されます。

@pytest.fixture
def temporary_file(tmp_path):
    file_path = tmp_path / "test.txt"
    file_path.write_text("hello")

    yield file_path

    file_path.unlink(missing_ok=True)

yield は、初期化前の初期化と後のクリーニングを担当します。

5.単体テスト(Unit Test)

単体テストでは主に、単一の関数またはモジュールのビジネス ロジックを検証します。

def calculate_score(a, b):
    return a * 2 + b

テスト:

def test_calculate_score():
    assert calculate_score(3, 4) == 10

特徴:

  • 通常はデータベースに依存しません。
  • 実行速度が速い。
  • 問題を簡単に特定できます。
  • 計算、変換、検証などの純粋なビジネス ロジックのテストに適しています。

単体テストでは、可能な限り複雑なビジネス計算を独立した関数に分割することをお勧めします。

6. Django データベースのテスト

pytest-django デフォルトでは、明示的に許可されていないデータベース アクセスは禁止されています。

次のものが使用できます。

import pytest


@pytest.mark.django_db
def test_create_user():
    user = User.objects.create(
        username="alice"
    )

    assert user.username == "alice"
    assert User.objects.count() == 1

組み込みの Fixture を利用することもできます。

def test_create_user(db):
    user = User.objects.create(
        username="alice"
    )

    assert User.objects.filter(
        username="alice"
    ).exists()

どちらの方法でもデータベースにアクセスできます。

テストデータベース

pytest-django は通常、Django テスト データベース メカニズムを使用して、独立したテスト データベースを作成し、テスト間でデータを分離します。

通常の構成では実稼働データベースは直接変更されませんが、テスト環境とデータベース接続構成は正しく設定されている必要があります。

データベーストランザクション

一般的な django_db テストでは、通常、トランザクション ロールバックなどのメカニズムを使用してデータを分離します。

実際のトランザクションのコミット、on_commit()、およびその他の動作をテストする必要がある場合:

@pytest.mark.django_db(transaction=True)
def test_transaction():
    ...

このモードではトランザクション テスト メカニズムが使用されますが、通常は通常のデータベース テストよりも時間がかかります。

テストデータベースを再利用する

pytest --reuse-db

複数のテスト実行間でテスト データベースを保持できるため、データベースを繰り返し作成するオーバーヘッドが軽減されます。

モデル構造または Migration が変更された後は、以下を使用できます。

pytest --reuse-db --create-db

テスト データベースを強制的に再作成します。

7. API統合テスト(Integration Test)

Django REST Framework は、HTTP リクエストをエミュレートする APIClient を提供します。

from rest_framework.test import APIClient

Fixture:

@pytest.fixture
def api_client():
    return APIClient()

テスト:

@pytest.mark.django_db
def test_get_profile(api_client, user):
    api_client.force_authenticate(user=user)

    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code == 200
    assert response.data["email"] == user.email

テストプロセス:

APIClient
    ↓
Django URL Routing
    ↓
Authentication / Permissions
    ↓
View
    ↓
Serializer
    ↓
Database
    ↓
Response

ここでシミュレートしているのは Django/DRF 内部のリクエスト処理であり、実際の HTTP サーバーを起動する必要はありません。

したがって、インターフェイス ロジックをテストできますが、Nginx、TLS、ブラウザ、または完全なネットワーク伝送リンクがテストされたことを意味するわけではありません。

POST テスト

response = api_client.post(
    "/api/v1/pois/",
    {
        "name": "Tokyo Tower",
        "score": 90,
    },
    format="json",
)

assert response.status_code == 201

PATCH テスト

response = api_client.patch(
    "/api/v1/pois/1/",
    {"name": "New Name"},
    format="json",
)

DELETE テスト

response = api_client.delete(
    "/api/v1/pois/1/"
)

assert response.status_code == 204

8. 認証と権限のテスト

認証 (Authentication) はユーザーの ID を検証し、権限 (Authorization) はユーザーが操作を実行できるかどうかを決定します。

これら 2 つは個別にテストする必要があります。

ログインせずにアクセス

@pytest.mark.django_db
def test_anonymous_access(api_client):
    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code in (401, 403)

DRF 異なる認証クラス設定では、認証されていないリクエストが 401 または 403 を返す場合があります。

正式なテストでは、長期的には両方を許可するよりも、プロジェクト構成に基づいて正確なステータス コードをアサートする方が良いでしょう。

ログインしてアクセスしました

@pytest.mark.django_db
def test_authenticated_access(api_client, user):
    api_client.force_authenticate(user=user)

    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code == 200

オブジェクトレベルの権限

ユーザーは自分の旅程のみを変更できると仮定します。

@pytest.mark.django_db
def test_cannot_update_other_user_itinerary(
    api_client,
    user,
    other_user,
    other_user_itinerary,
):
    api_client.force_authenticate(user=user)

    response = api_client.patch(
        f"/api/v1/itineraries/{other_user_itinerary.id}/",
        {"title": "Unauthorized"},
        format="json",
    )

    assert response.status_code in (403, 404)

    other_user_itinerary.refresh_from_db()

    assert other_user_itinerary.title != "Unauthorized"

API のリソース可視性設計に応じて 403 または 404。

ステータス コードのチェックに加えて、権限テストでは、データベースに予期しない変更が発生していないかどうかもチェックする必要があります。

force_authenticate と本認証

api_client.force_authenticate(user=user)

これにより、実際の認証クラスの検証ロジックがバイパスされ、ユーザーが強制的に認証された状態になります。

テストに適しています:

  • 認証されたユーザーがアクセスできるかどうか。
  • オブジェクトレベルの権限。
  • ビジネスルール。
  • データの更新。

しかし、検証できません:

  • Session Cookieは正しいですか?
  • JWT の署名と有効期限の検証。
  • ログインプロセスは正常ですか?
  • CSRFって効果あるの?

Session 認定試験

Django Session ベースの認証の場合、次を使用できます。

api_client.login(
    email="test@example.com",
    password="password123",
)

login() は Django で構成された Authentication Backend を使用するため、パラメーターはプロジェクトの実際の認証方法と一致する必要があります。

CSRF テスト

DRF の APIClient は、デフォルトでは CSRF チェックを強制しません。

CSRF を確認する必要がある場合:

api_client = APIClient(
    enforce_csrf_checks=True
)

次に、実際の Session でログインし、CSRF Token を保持しているか欠落しているかによって、対応するリクエストを検証します。

注: CSRF 保護は SessionAuthentication の動作に関連しており、プロジェクトで採用されている認証アーキテクチャに従って特定のテストを設計する必要があります。

9. パラメトリックテスト (parametrize)

@pytest.mark.parametrize を使用すると、同じテスト関数で複数のデータ セットを自動的に実行できます。

@pytest.mark.parametrize(
    "username",
    [
        "",
        "a",
        "x" * 101,
    ],
)
def test_invalid_username(username):
    ...

これは、3 セットのテストを実行することに相当します。

マルチパラメータテスト

@pytest.mark.parametrize(
    "a,b,expected",
    [
        (1, 2, 3),
        (0, 0, 0),
        (-1, 1, 0),
    ],
)
def test_add(a, b, expected):
    assert add(a, b) == expected

API パラメトリック テスト

@pytest.mark.django_db
@pytest.mark.parametrize(
    "payload",
    [
        {"username": ""},
        {"username": "x" * 101},
        {"username": None},
    ],
)
def test_invalid_user_creation(
    api_client,
    payload,
):
    response = api_client.post(
        "/api/v1/users/",
        payload,
        format="json",
    )

    assert response.status_code == 400

この書き方は、Serializer のフィールド検証をテストするのに非常に適しています。

10. 例外テスト

pytest.raises() を使用して、関数が指定された例外をスローするかどうかを確認します。

def parse_age(age):
    if age < 0:
        raise ValueError("Age must be positive")
    return age

テスト:

def test_invalid_age():
    with pytest.raises(ValueError):
        parse_age(-1)

検証エラーメッセージ:

def test_invalid_age():
    with pytest.raises(
        ValueError,
        match="Age must be positive",
    ):
        parse_age(-1)

例外オブジェクトもチェックできます。

def test_invalid_age():
    with pytest.raises(ValueError) as exc_info:
        parse_age(-1)

    assert str(exc_info.value) == (
        "Age must be positive"
    )

11. Mock: 外部依存関係をシミュレートする

Mock は、テストで実際に実行されることが予想されていない外部依存関係を置き換えるために使用されます。

一般的なシナリオ:

  • 社外品HTTP API。
  • メールが送信されました。
  • 第三者による支払い。
  • LLM API。
  • ファイルストレージ。
  • 時間のかかる作業。
  • 安定した再現が難しい外部サービス。

pytest-mock

mocker Fixtureはpytest-mockをインストールすると使用可能になります。

事業者コード:

# services/weather.py
import requests


def get_weather():
    response = requests.get(
        "https://api.example.com/weather",
        timeout=5,
    )
    response.raise_for_status()
    return response.json()

テスト:

def test_get_weather(mocker):
    mock_get = mocker.patch(
        "services.weather.requests.get"
    )

    mock_get.return_value.json.return_value = {
        "temperature": 20
    }

    result = get_weather()

    assert result["temperature"] == 20
    mock_get.assert_called_once()

Mockの核となるコンセプト

方法機能
mocker.patch()指定したオブジェクトを置き換えます
return_valueシミュレーションの戻り値を設定する
side_effect例外またはカスタム動作を設定する
assert_called_once()一度通話を確認する
assert_called_with()呼び出しパラメータを確認する
assert_not_called()呼び出されないことを確認する
mocker.spy()実際の関数呼び出しを監視する

例外をシミュレートする

def test_weather_error(mocker):
    mock_get = mocker.patch(
        "services.weather.requests.get"
    )

    mock_get.side_effect = requests.Timeout()

    with pytest.raises(requests.Timeout):
        get_weather()

Patchの重要な原則

Patch テスト対象のコードの依存関係が見つかる場所は、依存関係が最初に定義された場所であるとは限りません。

たとえば、ビジネス コードでは次のものが使用されます。

from services.email import send_email

この場合、テストでは通常、元のモジュールの盲目的な Patch ではなく、send_email ビジネス モジュールの Patch 参照が必要になります。

Mockの制限事項

Mock はテスト速度と安定性を向上させることができますが、外部システムが実際に使用できることを証明するものではありません。

したがって、重要なサードパーティ統合のために、少数の統合テストまたは契約テストを保持する必要があります。

12. データベースクエリ時間のテスト

DjangoはSQLのテストコードが何回実行されたかを確認できます。

from django.test.utils import CaptureQueriesContext
from django.db import connection

たとえば:

@pytest.mark.django_db
def test_query_count():
    with CaptureQueriesContext(connection) as queries:
        list(POI.objects.select_related("city"))

    assert len(queries) <= 2

pytest-django は django_assert_num_queries Fixture も提供します:

def test_query_count(
    db,
    django_assert_num_queries,
):
    with django_assert_num_queries(1):
        list(
            POI.objects.select_related("city")
        )

N+1 Query の問題の検出に特に役立ちます。

たとえば、インターフェイスが元々 SQL を 2 回実行したが、Serializer を変更すると SQL が 101 回になった場合、クエリ数テストを通じて問題を発見できます。

注: クエリの数は、認証、Session、キャッシュ、データベースの初期化などの要因の影響を受ける可能性があります。安定した明確なコードパスに対してアサーションを行うことが最善です。

13. Factory: テスト データ ファクトリ

テストで多数の複雑な Model インスタンスの作成が必要な場合は、factory_boy などの Factory ツールを使用できます。

通常のFixture:

@pytest.fixture
def user(db):
    return User.objects.create_user(
        email="test@example.com",
        password="password123",
    )

テストユーザー、POI、旅程、その他のモデルが多数ある場合、手動作成で重複したコードが表示されます。

factory_boy の例

import factory


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    email = factory.Sequence(
        lambda n: f"user{n}@example.com"
    )
    password = factory.PostGenerationMethodCall(
        "set_password",
        "password123",
    )

使用:

@pytest.mark.django_db
def test_users():
    user1 = UserFactory()
    user2 = UserFactory()

    assert user1.email != user2.email

Factory と Fixture の違い:

  • Fixture: テストの依存関係の作成とライフサイクルを制御します。
  • Factory: さまざまなプロパティを持つテスト オブジェクトを便利に生成します。

両方を組み合わせて使用​​できます。

14. テストカバレッジ (Coverage)

テスト カバレッジは、テスト実行中にカバーされるコードの量を示します。

pytest-cov を使用します。

pytest --cov=apps

欠落している行を表示:

pytest --cov=apps --cov-report=term-missing

HTML レポートを生成します。

pytest --cov=apps --cov-report=html

生成されます:

htmlcov/
└── index.html

カバレッジメトリクス

インジケーター意味
Line Coverage行カバレッジ
Branch Coverageブランチカバレッジ
Function Coverage機能範囲

ブランチ カバレッジを有効にする:

pytest --cov=apps --cov-branch

最小カバレッジしきい値を設定します。

pytest --cov=apps --cov-fail-under=80

カバレッジが 80% 未満の場合、コマンドは失敗します。

注: カバレッジが高いことは、テストの品質が高いことを意味するわけではありません。

たとえば、テストで特定のコードが実行されたものの、有効なチェック結果が得られなかった場合でも、高いカバー率を得ることが可能です。

単に 100% を追求するのではなく、重要なビジネス ロジックとリスクの高いコード パスに重点を置く必要があります。

15. テストの分類と優先順位

一般的なテストの種類:

タイプ試験内容特長
Unit Test単一の機能またはモジュール素早く簡単に見つけられる
Integration Test複数のコンポーネントの連携実際のビジネスプロセスをカバー
API TestHTTP インターフェイスの動作ステータスコード、データ、権限を確認する
Regression Test既存の機能が壊れているのでしょうか?リファクタリングによるエラーの発生を防ぐ
End-to-End Testユーザー操作プロセスを完了するカバー範囲が広く、コストが高い

Django + DRF プロジェクトの場合は、テストを優先することをお勧めします。

  1. 主要なビジネス ロジック。
  2. Serializer データの検証。
  3. 重要 API の成功応答とエラー応答。
  4. ユーザー認証とオブジェクトレベルの権限。
  5. データベースの変更が正しいかどうか。
  6. 外部サービス呼び出しが失敗した場合の処理​​。
  7. 重要なクエリで重大なパフォーマンスの低下が発生するかどうか。

単純な Model フィールドごとに個別のテストを作成するのではなく、ビジネス ルール、分岐ロジック、および失敗のリスクで機能をカバーすることを優先する必要があります。

16. CI/CD で pytest を実行します

pytest は通常、継続的インテグレーション (CI) の自動テスト リンクとして使用されます。

たとえば、GitHub Actions:

name: Backend Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

      - name: Validate OpenAPI
        run: |
          python manage.py spectacular \
            --file openapi.yaml \
            --validate \
            --fail-on-warn

これは、プロジェクトのテスト構成で SQLite を使用するか、必要なテスト データベースにすでに接続できることを前提とした簡略化された例です。

プロジェクトが PostgreSQL や Redis などのサービスに依存している場合、対応するサービス コンテナー、環境変数、依存関係を CI で追加構成する必要があります。

一般的なプロセス:

Push / Pull Request
         ↓
GitHub Actions
         ↓
Install Dependencies
         ↓
      pytest
         ↓
  OpenAPI Validation
         ↓
      Build
         ↓
      Deploy

テストが失敗した場合は、その後のデプロイメントを阻止して、明らかに問題のあるコードが実稼働環境に入るのを防ぐことができます。

17. テスト作成の原則

1. Arrange / Act / Assert

次の 3 つの段階に従ってテストを編成することをお勧めします。

def test_add():
    # Arrange
    a = 1
    b = 2

    # Act
    result = add(a, b)

    # Assert
    assert result == 3

それぞれ対応する:

  • Arrange: データと環境を準備します。
  • Act: テスト対象の操作を実行します。
  • Assert:検証結果。

2. テストは独立している必要があります

異なるテストは実行順序に依存したり、他のテストによって生成されたデータに依存したりしてはなりません。

3. 過剰な Mock を避ける

必要な外部依存関係だけを Mock してください。すべてのコンポーネントを偽のオブジェクトに置き換えると、実際の連携をテストで検証できなくなります。

4. 実装の詳細ではなく、動作の確認を優先する

たとえば、API をテストするときは、通常、次の点を確認することに重点を置く必要があります。

assert response.status_code == 200
assert response.data["name"] == "Tokyo"

内部関数の呼び出し回数に依存しすぎるのではなく。

5. 成功シナリオと失敗シナリオの両方をカバーする

たとえば、旅程変更インターフェイスは次のとおりです。

ユーザーが旅程を変更する → 200
ログインしていないユーザーによる変更 → 401/403
他のユーザーの旅程を変更する → 403/404
旅程が存在しません → 404
不正なパラメータ → 400
データベースの変更が成功しました → データは正しいです

ステータス コードは、実際の API 契約に基づいている必要があります。

概要

pytest の焦点は 4 つのレベルに分けることができます。

レベル知っておくべきこと
基本assert、テスト検出、コマンド実行、parametrize、raises
依存関係をテストするFixture、conftest.py、Mock、Factory
Django / DRFdjango_db、APIClient、認証局、データベース検証
エンジニアリングの実践Coverage、クエリ時間、CI/CD、回帰テスト

Django REST Framework プロジェクトの場合、最も一般的な組み合わせは次のとおりです。

pytest
    +
pytest-django
    +
DRF APIClient
    +
Fixture
    +
pytest-mock
    +
pytest-cov

その中でも、Fixture、APIClient、django_db、パラメータ化、Mock は、日々の開発において最も優先すべきものです。


参考資料