FastAPIのテスト完全ガイド - pytest活用による品質保証
約51分で読めます
by ぽんたぬき
FastAPIのテスト完全ガイド - pytest活用による品質保証
品質の高いWebアプリケーションを開発するためには、包括的なテストが不可欠です。この記事では、FastAPIアプリケーションでpytestを使用した効果的なテスト手法を詳しく解説します。
テストの基礎知識
テストの種類
- ユニットテスト: 個別の関数やクラスのテスト
- 統合テスト: 複数のコンポーネントを組み合わせたテスト
- 機能テスト: エンドツーエンドの機能テスト
- パフォーマンステスト: 性能要件のテスト
FastAPIテストの特徴
- TestClient: FastAPIのテスト専用クライアント
- 依存性注入のオーバーライド: テスト用の依存性に置き換え
- 非同期テスト: asyncio対応のテスト実行
- データベースのモック: テスト専用データベースの使用
環境構築
必要なパッケージのインストール
# テスト関連パッケージ
pip install pytest pytest-asyncio httpx
# カバレッジ測定
pip install pytest-cov
# ファクトリボーイ(テストデータ生成)
pip install factory-boy
# モック関連
pip install pytest-mock responsesプロジェクト構成
project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ ├── crud.py
│ └── dependencies.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # テスト設定
│ ├── test_main.py # メインAPIテスト
│ ├── test_crud.py # CRUD操作テスト
│ ├── test_auth.py # 認証テスト
│ └── factories.py # テストデータファクトリ
├── pytest.ini # pytest設定
└── requirements-test.txt # テスト用依存関係
テスト設定(conftest.py)
基本設定
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import get_db, Base
from app.dependencies import get_current_user
from app import models
# テスト用データベース設定
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="session")
def db_engine():
"""データベースエンジンのフィクスチャ"""
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def db_session(db_engine):
"""データベースセッションのフィクスチャ"""
connection = db_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture(scope="function")
def client(db_session):
"""テストクライアントのフィクスチャ"""
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def test_user(db_session):
"""テスト用ユーザーのフィクスチャ"""
user = models.User(
username="testuser",
email="test@example.com",
hashed_password="hashed_password",
full_name="Test User",
is_active=True
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def authenticated_client(client, test_user):
"""認証済みクライアントのフィクスチャ"""
def override_get_current_user():
return test_user
app.dependency_overrides[get_current_user] = override_get_current_user
yield client
if get_current_user in app.dependency_overrides:
del app.dependency_overrides[get_current_user]ファクトリパターンによるテストデータ生成
# tests/factories.py
import factory
from factory import Faker
from factory.alchemy import SQLAlchemyModelFactory
from app import models
from tests.conftest import TestingSessionLocal
class UserFactory(SQLAlchemyModelFactory):
class Meta:
model = models.User
sqlalchemy_session = TestingSessionLocal
sqlalchemy_session_persistence = "commit"
username = Faker('user_name')
email = Faker('email')
full_name = Faker('name')
hashed_password = "hashed_password"
is_active = True
is_admin = False
class PostFactory(SQLAlchemyModelFactory):
class Meta:
model = models.Post
sqlalchemy_session = TestingSessionLocal
sqlalchemy_session_persistence = "commit"
title = Faker('sentence', nb_words=4)
content = Faker('text', max_nb_chars=1000)
published = True
author = factory.SubFactory(UserFactory)
class TagFactory(SQLAlchemyModelFactory):
class Meta:
model = models.Tag
sqlalchemy_session = TestingSessionLocal
sqlalchemy_session_persistence = "commit"
name = Faker('word')APIエンドポイントのテスト
基本的なエンドポイントテスト
# tests/test_main.py
import pytest
from fastapi import status
def test_read_root(client):
"""ルートエンドポイントのテスト"""
response = client.get("/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Hello World"}
def test_read_item(client):
"""アイテム取得エンドポイントのテスト"""
response = client.get("/items/42?q=test")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"item_id": 42, "q": "test"}
def test_read_item_without_query(client):
"""クエリパラメータなしのテスト"""
response = client.get("/items/1")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"item_id": 1, "q": None}
class TestUserEndpoints:
"""ユーザーエンドポイントのテストクラス"""
def test_create_user(self, client):
"""ユーザー作成テスト"""
user_data = {
"username": "newuser",
"email": "newuser@example.com",
"password": "StrongPassword123!",
"full_name": "New User"
}
response = client.post("/users/", json=user_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["username"] == user_data["username"]
assert data["email"] == user_data["email"]
assert data["full_name"] == user_data["full_name"]
assert "id" in data
assert "hashed_password" not in data # パスワードは返されない
def test_create_user_duplicate_email(self, client, test_user):
"""重複メールアドレスでのユーザー作成テスト"""
user_data = {
"username": "anotheruser",
"email": test_user.email, # 既存のメールアドレス
"password": "StrongPassword123!"
}
response = client.post("/users/", json=user_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "このメールアドレスは既に登録されています" in response.json()["detail"]
def test_get_users(self, client, db_session):
"""ユーザー一覧取得テスト"""
# テストデータの作成
from tests.factories import UserFactory
users = UserFactory.create_batch(5, sqlalchemy_session=db_session)
response = client.get("/users/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 5
assert all("username" in user for user in data)
def test_get_user_by_id(self, client, test_user):
"""ユーザー詳細取得テスト"""
response = client.get(f"/users/{test_user.id}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == test_user.id
assert data["username"] == test_user.username
assert data["email"] == test_user.email
def test_get_nonexistent_user(self, client):
"""存在しないユーザーの取得テスト"""
response = client.get("/users/99999")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "ユーザーが見つかりません" in response.json()["detail"]
def test_update_user(self, client, test_user):
"""ユーザー更新テスト"""
update_data = {
"full_name": "Updated Name",
"is_active": False
}
response = client.put(f"/users/{test_user.id}", json=update_data)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["full_name"] == "Updated Name"
assert data["is_active"] is False
def test_delete_user(self, client, test_user):
"""ユーザー削除テスト"""
response = client.delete(f"/users/{test_user.id}")
assert response.status_code == status.HTTP_200_OK
assert "ユーザーが削除されました" in response.json()["message"]
# 削除後の確認
get_response = client.get(f"/users/{test_user.id}")
assert get_response.status_code == status.HTTP_404_NOT_FOUNDパラメータ化テスト
import pytest
class TestParameterizedEndpoints:
"""パラメータ化テストの例"""
@pytest.mark.parametrize("item_id,expected_status", [
(1, 200),
(999, 200),
(-1, 422), # バリデーションエラー
("invalid", 422),
])
def test_get_item_various_ids(self, client, item_id, expected_status):
"""様々なアイテムIDでのテスト"""
response = client.get(f"/items/{item_id}")
assert response.status_code == expected_status
@pytest.mark.parametrize("username,email,password,expected_status", [
("validuser", "valid@example.com", "StrongPass123!", 201),
("", "valid@example.com", "StrongPass123!", 422), # 空のユーザー名
("validuser", "invalid-email", "StrongPass123!", 422), # 無効なメール
("validuser", "valid@example.com", "weak", 422), # 弱いパスワード
])
def test_create_user_validation(self, client, username, email, password, expected_status):
"""ユーザー作成バリデーションテスト"""
user_data = {
"username": username,
"email": email,
"password": password
}
response = client.post("/users/", json=user_data)
assert response.status_code == expected_status認証機能のテスト
JWT認証テスト
# tests/test_auth.py
import pytest
from fastapi import status
from app.security import create_access_token, verify_token
from datetime import timedelta
class TestAuthentication:
"""認証機能のテストクラス"""
def test_login_success(self, client, test_user):
"""ログイン成功テスト"""
login_data = {
"username": test_user.username,
"password": "test_password" # 実際のテストではハッシュ化前のパスワード
}
response = client.post("/auth/token", data=login_data)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "access_token" in data
assert "token_type" in data
assert data["token_type"] == "bearer"
def test_login_invalid_credentials(self, client, test_user):
"""無効な認証情報でのログインテスト"""
login_data = {
"username": test_user.username,
"password": "wrong_password"
}
response = client.post("/auth/token", data=login_data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "ユーザー名またはパスワードが正しくありません" in response.json()["detail"]
def test_login_nonexistent_user(self, client):
"""存在しないユーザーでのログインテスト"""
login_data = {
"username": "nonexistent",
"password": "password"
}
response = client.post("/auth/token", data=login_data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_access_protected_endpoint_without_token(self, client):
"""トークンなしでの保護されたエンドポイントアクセステスト"""
response = client.get("/auth/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_access_protected_endpoint_with_valid_token(self, authenticated_client, test_user):
"""有効なトークンでの保護されたエンドポイントアクセステスト"""
response = authenticated_client.get("/auth/me")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["username"] == test_user.username
assert data["email"] == test_user.email
def test_access_protected_endpoint_with_invalid_token(self, client):
"""無効なトークンでのアクセステスト"""
headers = {"Authorization": "Bearer invalid_token"}
response = client.get("/auth/me", headers=headers)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_token_expiration(self, client, test_user):
"""トークン有効期限テスト"""
# 期限切れトークンの作成
expired_token = create_access_token(
data={"sub": test_user.username},
expires_delta=timedelta(seconds=-1) # 既に期限切れ
)
headers = {"Authorization": f"Bearer {expired_token}"}
response = client.get("/auth/me", headers=headers)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestUserRegistration:
"""ユーザー登録のテストクラス"""
def test_register_user_success(self, client):
"""ユーザー登録成功テスト"""
user_data = {
"username": "newuser",
"email": "newuser@example.com",
"password": "StrongPassword123!",
"full_name": "New User"
}
response = client.post("/auth/register", json=user_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["username"] == user_data["username"]
assert data["email"] == user_data["email"]
assert data["is_active"] is True
def test_register_duplicate_username(self, client, test_user):
"""重複ユーザー名での登録テスト"""
user_data = {
"username": test_user.username,
"email": "different@example.com",
"password": "StrongPassword123!"
}
response = client.post("/auth/register", json=user_data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "このユーザー名は既に使用されています" in response.json()["detail"]データベース操作のテスト
CRUD操作テスト
# tests/test_crud.py
import pytest
from app import crud, schemas
from tests.factories import UserFactory, PostFactory
class TestUserCRUD:
"""ユーザーCRUD操作のテストクラス"""
def test_create_user(self, db_session):
"""ユーザー作成テスト"""
user_data = schemas.UserCreate(
username="testuser",
email="test@example.com",
password="password123"
)
user = crud.UserCRUD.create_user(db_session, user_data)
assert user.username == user_data.username
assert user.email == user_data.email
assert user.hashed_password != user_data.password # ハッシュ化されている
assert user.is_active is True
def test_get_user_by_email(self, db_session):
"""メールアドレスによるユーザー取得テスト"""
user = UserFactory(sqlalchemy_session=db_session)
found_user = crud.UserCRUD.get_user_by_email(db_session, user.email)
assert found_user is not None
assert found_user.id == user.id
assert found_user.email == user.email
def test_get_user_by_email_not_found(self, db_session):
"""存在しないメールアドレスでの検索テスト"""
found_user = crud.UserCRUD.get_user_by_email(db_session, "nonexistent@example.com")
assert found_user is None
def test_get_users_with_pagination(self, db_session):
"""ページネーション付きユーザー取得テスト"""
users = UserFactory.create_batch(15, sqlalchemy_session=db_session)
# 最初の10件
first_page = crud.UserCRUD.get_users(db_session, skip=0, limit=10)
assert len(first_page) == 10
# 次の5件
second_page = crud.UserCRUD.get_users(db_session, skip=10, limit=10)
assert len(second_page) == 5
def test_update_user(self, db_session):
"""ユーザー更新テスト"""
user = UserFactory(sqlalchemy_session=db_session)
update_data = schemas.UserUpdate(
full_name="Updated Name",
is_active=False
)
updated_user = crud.UserCRUD.update_user(db_session, user.id, update_data)
assert updated_user is not None
assert updated_user.full_name == "Updated Name"
assert updated_user.is_active is False
assert updated_user.username == user.username # 変更されていない
def test_delete_user(self, db_session):
"""ユーザー削除テスト"""
user = UserFactory(sqlalchemy_session=db_session)
success = crud.UserCRUD.delete_user(db_session, user.id)
assert success is True
# 削除後の確認
deleted_user = crud.UserCRUD.get_user(db_session, user.id)
assert deleted_user is None
class TestPostCRUD:
"""投稿CRUD操作のテストクラス"""
def test_create_post(self, db_session):
"""投稿作成テスト"""
user = UserFactory(sqlalchemy_session=db_session)
post_data = schemas.PostCreate(
title="Test Post",
content="This is a test post",
published=True
)
post = crud.PostCRUD.create_post(db_session, post_data, user.id)
assert post.title == post_data.title
assert post.content == post_data.content
assert post.published == post_data.published
assert post.author_id == user.id
def test_get_posts_by_user(self, db_session):
"""ユーザー別投稿取得テスト"""
user = UserFactory(sqlalchemy_session=db_session)
posts = PostFactory.create_batch(3, author=user, sqlalchemy_session=db_session)
user_posts = crud.PostCRUD.get_posts_by_user(db_session, user.id)
assert len(user_posts) == 3
assert all(post.author_id == user.id for post in user_posts)
def test_get_published_posts_only(self, db_session):
"""公開投稿のみの取得テスト"""
user = UserFactory(sqlalchemy_session=db_session)
published_posts = PostFactory.create_batch(2, published=True, author=user, sqlalchemy_session=db_session)
unpublished_posts = PostFactory.create_batch(1, published=False, author=user, sqlalchemy_session=db_session)
posts = crud.PostCRUD.get_posts(db_session, published_only=True)
assert len(posts) == 2
assert all(post.published for post in posts)非同期処理のテスト
非同期エンドポイントテスト
# tests/test_async.py
import pytest
import asyncio
from httpx import AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_async_endpoint():
"""非同期エンドポイントのテスト"""
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/async-endpoint")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_concurrent_requests():
"""同時リクエストのテスト"""
async with AsyncClient(app=app, base_url="http://test") as ac:
tasks = [ac.get("/users/") for _ in range(10)]
responses = await asyncio.gather(*tasks)
assert all(response.status_code == 200 for response in responses)モックを使用したテスト
外部APIのモック
# tests/test_external_api.py
import pytest
import responses
from app.external_service import ExternalAPIClient
class TestExternalAPI:
"""外部API呼び出しのテストクラス"""
@responses.activate
def test_external_api_success(self):
"""外部API成功レスポンステスト"""
responses.add(
responses.GET,
"https://api.example.com/data",
json={"status": "success", "data": {"id": 1, "name": "test"}},
status=200
)
client = ExternalAPIClient()
result = client.get_data()
assert result["status"] == "success"
assert result["data"]["id"] == 1
@responses.activate
def test_external_api_error(self):
"""外部APIエラーレスポンステスト"""
responses.add(
responses.GET,
"https://api.example.com/data",
json={"error": "Not found"},
status=404
)
client = ExternalAPIClient()
with pytest.raises(Exception) as exc_info:
client.get_data()
assert "Not found" in str(exc_info.value)パフォーマンステスト
負荷テスト
# tests/test_performance.py
import pytest
import time
from concurrent.futures import ThreadPoolExecutor
import statistics
def test_endpoint_response_time(client):
"""エンドポイントのレスポンス時間テスト"""
start_time = time.time()
response = client.get("/users/")
end_time = time.time()
response_time = end_time - start_time
assert response_time < 1.0 # 1秒以内
assert response.status_code == 200
def test_concurrent_user_creation(client):
"""同時ユーザー作成の負荷テスト"""
def create_user(index):
user_data = {
"username": f"user{index}",
"email": f"user{index}@example.com",
"password": "password123"
}
response = client.post("/users/", json=user_data)
return response.status_code, time.time()
start_time = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(create_user, i) for i in range(100)]
results = [future.result() for future in futures]
end_time = time.time()
total_time = end_time - start_time
# 成功率の確認
success_count = sum(1 for status_code, _ in results if status_code == 201)
success_rate = success_count / len(results)
assert success_rate > 0.95 # 95%以上の成功率
assert total_time < 30.0 # 30秒以内で完了テスト実行とカバレッジ
pytest.ini 設定
# pytest.ini
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--disable-warnings
--cov=app
--cov-report=html
--cov-report=term-missing
--cov-fail-under=80
markers =
slow: marks tests as slow
integration: marks tests as integration tests
unit: marks tests as unit tests
テスト実行コマンド
# 全テストの実行
pytest
# 特定のテストファイルの実行
pytest tests/test_main.py
# 特定のテストクラスの実行
pytest tests/test_main.py::TestUserEndpoints
# 特定のテストメソッドの実行
pytest tests/test_main.py::TestUserEndpoints::test_create_user
# マーカーを使用したテストの実行
pytest -m "not slow" # 遅いテストを除外
pytest -m "unit" # ユニットテストのみ
# カバレッジ付きテスト実行
pytest --cov=app --cov-report=html
# 並列テスト実行(pytest-xdistが必要)
pytest -n auto
# 失敗したテストのみ再実行
pytest --lf
# 詳細な出力
pytest -v -sCI/CDでのテスト自動化
GitHub Actions設定例
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11"]
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
SECRET_KEY: test-secret-key
run: |
pytest --cov=app --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrellaテストのベストプラクティス
1. テストの構造化
# AAAパターン(Arrange, Act, Assert)
def test_user_creation():
# Arrange - テストデータの準備
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": "password123"
}
# Act - テスト対象の実行
response = client.post("/users/", json=user_data)
# Assert - 結果の検証
assert response.status_code == 201
assert response.json()["username"] == user_data["username"]2. テストデータの管理
# テストデータの定数化
class TestData:
VALID_USER = {
"username": "validuser",
"email": "valid@example.com",
"password": "ValidPass123!"
}
INVALID_EMAIL_USER = {
"username": "invaliduser",
"email": "invalid-email",
"password": "ValidPass123!"
}3. エラーメッセージのテスト
def test_validation_error_messages(client):
"""バリデーションエラーメッセージのテスト"""
response = client.post("/users/", json={"username": ""})
assert response.status_code == 422
errors = response.json()["detail"]
# 特定のフィールドのエラーを確認
username_error = next(
(error for error in errors if error["loc"] == ["body", "username"]),
None
)
assert username_error is not None
assert "ensure this value has at least 1 characters" in username_error["msg"]まとめ
FastAPIアプリケーションの効果的なテストには以下が重要です:
- 包括的なテスト戦略: ユニット、統合、機能テストの組み合わせ
- 適切なフィクスチャ: テストデータとセットアップの管理
- 認証テスト: JWT認証やロールベースアクセス制御のテスト
- パフォーマンステスト: 負荷テストとレスポンス時間の検証
- CI/CD統合: 自動化されたテスト実行とカバレッジ測定
次回は、FastAPIのデプロイメントについて詳しく解説します。Docker、Kubernetes、クラウドプラットフォームでの本格的な運用方法を学んでいきましょう。
参考リンク
関連記事
Web API開発の基礎:REST APIの設計原則と実装のベストプラクティス
Web API開発の基礎:REST APIの設計原則と実装のベストプラクティス 現代のWeb開発において、API(Application Programming Interface)は欠かせない技術です。この記事では、初心者の方向けにWeb APIの基本概念から、実際の開発まで詳しく解説します。 ...
FastAPIのデプロイメント完全ガイド - Docker・Kubernetes・クラウド運用
FastAPIのデプロイメント完全ガイド Docker・Kubernetes・クラウド運用 FastAPIアプリケーションを本格的に運用するためには、適切なデプロイメント戦略が必要です。この記事では、Docker、Kubernetes、各種クラウドプラットフォームでのデプロイメント方法を詳しく解説...
FastAPIとデータベース連携 - SQLAlchemyによるORM実装
FastAPIとデータベース連携 SQLAlchemyによるORM実装 FastAPIでWebアプリケーションを開発する際、データベースとの連携は必須の機能です。この記事では、SQLAlchemyを使用してFastAPIでデータベース操作を実装する方法を詳しく解説します。 ...