Pythonテスト #5 外部世界のテスト — ファイル、HTTP、DB、Webフレームワーク

#4 mock と monkeypatch では外部依存を偽物に置き換える方法を扱いました。ところが mock を身につけると、新しい悩みが生まれます。すべて偽物で塞いだテストは速くて安定していますが、肝心の本物のファイルが書かれるか、本物の DB に保存されるかは何も保証しません。mock が全部通っても、デプロイすると壊れるコードはいくらでも作れます。

今回の記事では、外部世界の 4 つの領域であるファイル、HTTP、DB、Web フレームワークを取り上げ、どこまで本物を使い、どこから偽物で塞ぐかという領域別の基準を整理します。

ファイル: tmp_path で本物のファイルシステムを使います #

ファイル I/O は mock の対象ではありません。ローカルディスクは速くて決定的なので、偽物で塞いで得るものより失うもののほうが多いです。pytest 組み込みフィクスチャの tmp_path が、テストごとに空の一時ディレクトリを作ってくれます。

tests/test_config.py
import json
from pathlib import Path

def save_config(path: Path, config: dict) -> None:
    path.write_text(json.dumps(config), encoding="utf-8")

def test_save_config(tmp_path):
    config_file = tmp_path / "config.json"

    save_config(config_file, {"debug": True, "port": 8000})

    assert json.loads(config_file.read_text()) == {"debug": True, "port": 8000}

tmp_pathpathlib.Path オブジェクトです。テストごとに別々のディレクトリが割り当てられ、終わると pytest が自動で片付けます。本物のファイルを書いて読んだので、エンコーディング、パス処理、シリアライズまで全部が実際に検証されたことになります。前提条件は 1 つです。上の save_config のように、コードがパスを引数で受け取っていれば tmp_path を差し込めます。作業ディレクトリ基準で動くコードなら、monkeypatch.chdir(tmp_path) でカレントディレクトリ自体を移す方法もあります。

HTTP: ネットワーク境界だけを偽物で塞ぎます #

mock.patchfetch_user 関数自体を差し替えると、URL の組み立てやエラー処理といった関数内部のロジックはまったく実行されません。より良い遮断位置はネットワーク境界です。自分のコードは最後まで実行し、実際のソケットが開く直前だけを横取りする方式です。httpx を使うなら respx がこの役割を担います。pip install respx でインストールします。

tests/test_api_client.py
import httpx
import respx

def fetch_user(user_id: int) -> dict:
    response = httpx.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

@respx.mock
def test_fetch_user():
    respx.get("https://api.example.com/users/1").mock(
        return_value=httpx.Response(200, json={"id": 1, "name": "curtis"})
    )

    assert fetch_user(1)["name"] == "curtis"

fetch_user の URL 組み立て、raise_for_status()、JSON パースが全部実際に実行され、ソケットだけが偽のレスポンスに置き換わります。httpx.Response(404) を登録すれば、エラー経路も同じように再現できます。requests を使うなら同じ役割の responses があり、使い方もほぼ同じです。モダンPython実践 #6 で使った pytest-httpx も同じ系統です。こうしたライブラリは、登録していない URL へリクエストが出るとエラーを出すため、テスト中にうっかり本物のネットワークへ出てしまう事故まで防いでくれます。

実際の API を呼ぶテストはマーカーで分離します #

ところで、偽のレスポンスが実際の API 仕様とずれていたら、どうやって気づけるでしょうか? これは本物の呼び出しでしか検証できません。#3 で見たマーカーで分離しておきます。

実際の呼び出しテスト
@pytest.mark.external
def test_real_api_contract():
    response = httpx.get("https://api.example.com/users/1")
    assert "name" in response.json()
pyproject.toml
[tool.pytest.ini_options]
markers = ["external: 実際の外部 API を呼び出すテスト"]
addopts = "-m 'not external'"

普段の実行では除外され、pytest -m external と明示したときだけ回ります。遅くてたまに失敗してもよい、別グループに移しておいたわけです。

DB: テストごとにきれいな状態を作ります #

DB テストの核心的な問題は状態です。前のテストが残したデータが後ろのテストに影響すると、実行順序によって結果が変わります。きれいな状態を作る戦略は大きく 3 つです。最も速い SQLite インメモリ、実際のスキーマをそのまま使うトランザクションロールバック、最も遅いものの本番と同一のコンテナ DB です。

sqlite:///:memory: はディスクすら経由しないので最速です。ただし本番が PostgreSQL なら、限界を知って使う必要があります。JSON 演算子、配列型、型チェックの厳格さ、並行動作が異なるため、SQLite で通ったクエリが PostgreSQL で壊れることもあり、その逆もあります。

トランザクションロールバックパターン #

テスト開始時にトランザクションを開き、終わったらコミットの代わりにロールバックします。データをいちいち消す必要なく、最初の状態に戻ります。

tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models import Base

@pytest.fixture(scope="session")
def engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection, join_transaction_mode="create_savepoint")

    yield session

    session.close()
    transaction.rollback()
    connection.close()

join_transaction_mode="create_savepoint" のおかげで、テストコード内の commit() は SAVEPOINT までしか反映されず、フィクスチャ最後の rollback() がすべてを巻き戻します。テストは db_session を受け取って普段どおり add()commit() を呼べばよく、DB には何も残らないので、次のテストは常に空の状態から始まります。

クエリが PostgreSQL 専用機能に依存しているなら、testcontainers でテストの間だけ Docker コンテナを立てる選択肢があります。PostgresContainer("postgres:16") をセッションスコープのフィクスチャとして一度だけ立て、テストごとの分離は上のロールバックパターンをそのまま重ねます。起動に数秒かかる代わりに、本番と同じ DB で検証できます。

Webフレームワーク: サーバーなしでリクエストを送ります #

FastAPI の TestClient は、サーバーを立てずに in-process でルートを呼び出します。

tests/test_app.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_health():
    response = client.get("/health")
    assert response.status_code == 200

リクエストのパース、依存性注入、レスポンスのシリアライズまで、フレームワーク全体が実際に実行されます。本物の DB 依存をテスト用に差し替える dependency_overrides と非同期クライアントまで含めた詳細パターンは、モダンPython実践 #6 で扱いました。

Django のテストクライアントも同じ形です。TestCase の中で self.client.get("/polls/") とビューを呼び出し、テストごとにトランザクションロールバックで DB を戻すところまで、上で見たパターンがフレームワークに組み込まれています。Django 自体は Django基礎 シリーズで扱います。

ユニットと統合の境界: 遅さと信頼のトレード #

ここまでの選択を一行でまとめると、すべての境界で同じトレードをしています。本物に近いほど信頼が上がり、速度が下がります。

領域速い側本物側
ファイルtmp_path (すでに十分本物)そのまま使用
HTTPrespx / responsesexternal マーカーで実際に呼び出し
DBSQLite インメモリロールバックパターン、testcontainers
WebTestClient (in-process)ステージング環境の E2E

デフォルトは速い側に置き、本物側のテストを少数精鋭で維持する構成が実務の標準です。コミットごとに回るのは速いテストで、デプロイ前や 1 日 1 回回るのが本物のテストです。

まとめ #

  • ファイルは mock せず、tmp_path で本物のファイルシステムを使います
  • HTTP は関数ではなくネットワーク境界を塞ぎます。httpx には respx、requests には responses です
  • 実際の API を呼ぶテストは external マーカーで分離し、普段の実行から除外します
  • DB はトランザクションロールバックパターンでテストごとにきれいな状態を作り、PostgreSQL 専用機能は testcontainers で検証します
  • FastAPI の TestClient と Django のテストクライアントは、サーバーなしでフレームワーク全体を実行します
  • 本物に近いほど遅くなり、信頼できるようになります。デフォルトは速く、本物は少数で維持します

次回 #6 では、テスト設計とカバレッジを扱います。どのテストをどれだけ書くか、良いテストと悪いテストを分ける基準、カバレッジの数字の読み方まで整理します。

X