テスト・CI/CD完全ガイド¶
概要¶
本プロジェクトは、テスト駆動開発(TDD)と継続的インテグレーション/デリバリー(CI/CD)を実践し、高品質なコードベースを維持します。
テスト戦略¶
テストピラミッド¶
graph TB
E2E[E2Eテスト<br/>10%]
INT[統合テスト<br/>30%]
UNIT[ユニットテスト<br/>60%]
E2E --> INT
INT --> UNIT
style E2E fill:#FFB6C1
style INT fill:#87CEEB
style UNIT fill:#90EE90
テスト種別と責務¶
| テスト種別 | 対象層 | ツール | 実行速度 | カバレッジ目標 |
|---|---|---|---|---|
| ユニットテスト | Domain層 | pytest | 超高速 | 90%以上 |
| 統合テスト | Infrastructure層 | pytest + testcontainers | 高速 | 70%以上 |
| E2Eテスト | Interfaces層 | pytest + TestClient | 中速 | 主要シナリオ |
| 契約テスト | API層 | pytest + pact | 高速 | 全API |
ディレクトリ構成¶
tests/
├─ unit/ # ユニットテスト
│ ├─ domain/
│ │ ├─ test_user.py
│ │ └─ test_value_objects.py
│ └─ application/
│ └─ test_user_usecase.py
├─ integration/ # 統合テスト
│ ├─ infrastructure/
│ │ ├─ test_corporate_repository.py
│ │ ├─ test_auth_repository.py
│ │ └─ test_vector_repository.py
│ └─ test_database.py
├─ e2e/ # E2Eテスト
│ ├─ test_user_api.py
│ └─ test_auth_flow.py
├─ factories/ # factory_boyファクトリ
│ ├─ user_factory.py
│ └─ document_factory.py
├─ fixtures/ # 共通フィクスチャ
│ ├─ database_fixtures.py
│ └─ api_fixtures.py
├─ conftest.py # pytest設定
└─ pytest.ini # pytest設定ファイル
pytest設定¶
pytest.ini¶
[pytest]
# Pythonパス設定
pythonpath = .
# テストディレクトリ
testpaths = tests
# オプション
addopts =
-v # 詳細出力
--strict-markers # 未定義マーカー禁止
--tb=short # トレースバック短縮
--cov=app # カバレッジ測定
--cov-report=html # HTMLレポート生成
--cov-report=term-missing # ターミナル表示
--cov-fail-under=80 # カバレッジ80%未満で失敗
# マーカー定義
markers =
unit: ユニットテスト(外部依存なし)
integration: 統合テスト(DB接続あり)
e2e: E2Eテスト(API経由)
slow: 時間のかかるテスト
# 非同期テスト設定
asyncio_mode = auto
conftest.py¶
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from testcontainers.postgres import PostgresContainer
from app.main import app
from app.core.database import Base, get_db
# ============================================================
# テスト用データベースセットアップ
# ============================================================
@pytest.fixture(scope="session")
def postgres_container():
"""PostgreSQLテストコンテナ"""
with PostgresContainer("postgres:16") as postgres:
postgres.with_env("POSTGRES_DB", "test_db")
yield postgres
@pytest.fixture(scope="session")
def test_engine(postgres_container):
"""テスト用SQLAlchemyエンジン"""
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def test_db_session(test_engine):
"""テスト用DBセッション(各テスト後にロールバック)"""
connection = test_engine.connect()
transaction = connection.begin()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection)
session = SessionLocal()
yield session
session.close()
transaction.rollback()
connection.close()
# ============================================================
# FastAPI TestClient
# ============================================================
@pytest.fixture(scope="module")
def client():
"""FastAPI TestClient"""
with TestClient(app) as test_client:
yield test_client
@pytest.fixture(scope="function")
def client_with_db(test_db_session):
"""DB接続を注入したTestClient"""
def override_get_db():
try:
yield test_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 sample_user_data():
"""サンプルユーザーデータ"""
return {
"id": 1,
"name": "田中太郎",
"email": "tanaka@example.com",
"joined_at": "2024-01-01T00:00:00"
}
ユニットテスト実装例¶
Domain層のテスト¶
# tests/unit/domain/test_user.py
import pytest
from datetime import datetime, timedelta
from app.domain.user import UserProfile
class TestUserProfile:
"""UserProfileエンティティのテスト"""
def test_create_user_profile(self):
"""ユーザープロファイル作成"""
user = UserProfile(
id=1,
name="田中太郎",
email="tanaka@example.com",
joined_at=datetime.now()
)
assert user.id == 1
assert user.name == "田中太郎"
assert user.email == "tanaka@example.com"
def test_is_premium_user_true(self):
"""プレミアムユーザー判定(31日以上)"""
joined_date = datetime.now() - timedelta(days=31)
user = UserProfile(
id=1,
name="田中太郎",
email="tanaka@example.com",
joined_at=joined_date
)
assert user.is_premium_user() is True
def test_is_premium_user_false(self):
"""プレミアムユーザー判定(30日以下)"""
joined_date = datetime.now() - timedelta(days=29)
user = UserProfile(
id=1,
name="田中太郎",
email="tanaka@example.com",
joined_at=joined_date
)
assert user.is_premium_user() is False
def test_to_dict(self):
"""辞書変換"""
user = UserProfile(
id=1,
name="田中太郎",
email="tanaka@example.com",
joined_at=datetime(2024, 1, 1)
)
result = user.to_dict()
assert result["id"] == 1
assert result["name"] == "田中太郎"
assert result["email"] == "tanaka@example.com"
assert "is_premium" in result
Application層のテスト(モック使用)¶
# tests/unit/application/test_user_usecase.py
import pytest
from unittest.mock import Mock, MagicMock
from datetime import datetime
from app.application.user_summary_usecase import UserSummaryUseCase
from app.domain.user import UserProfile
class TestUserSummaryUseCase:
"""ユーザーサマリーユースケースのテスト"""
@pytest.fixture
def mock_corporate_repo(self):
"""Corporate Repositoryのモック"""
return Mock()
@pytest.fixture
def mock_auth_repo(self):
"""Auth Repositoryのモック"""
return Mock()
@pytest.fixture
def usecase(self, mock_corporate_repo, mock_auth_repo):
"""テスト対象のユースケース"""
return UserSummaryUseCase(
corporate_repo=mock_corporate_repo,
auth_repo=mock_auth_repo
)
def test_execute_success(self, usecase, mock_corporate_repo, mock_auth_repo):
"""正常系: ユーザーサマリー取得成功"""
# Arrange(準備)
user_id = 1
profile = UserProfile(
id=1,
name="田中太郎",
email="tanaka@example.com",
joined_at=datetime(2024, 1, 1)
)
auth_info = UserProfile(
id=1,
name="田中太郎",
email="auth@example.com",
joined_at=datetime(2024, 1, 1)
)
mock_corporate_repo.find_by_id.return_value = profile
mock_auth_repo.find_by_id.return_value = auth_info
# Act(実行)
result = usecase.execute(user_id)
# Assert(検証)
assert result["user_id"] == 1
assert result["name"] == "田中太郎"
assert result["email"] == "auth@example.com" # auth DBの情報を優先
assert "joined_at" in result
mock_corporate_repo.find_by_id.assert_called_once_with(user_id)
mock_auth_repo.find_by_id.assert_called_once_with(user_id)
def test_execute_user_not_found(self, usecase, mock_corporate_repo):
"""異常系: ユーザーが見つからない"""
# Arrange
user_id = 999
mock_corporate_repo.find_by_id.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="User 999 not found"):
usecase.execute(user_id)
統合テスト実装例¶
Infrastructure層のテスト¶
# tests/integration/infrastructure/test_corporate_repository.py
import pytest
from datetime import datetime
from app.infrastructure.corporate.repository import CorporateUserRepository
from app.infrastructure.corporate.orm_model import UserORM
from app.domain.user import UserProfile
@pytest.mark.integration
class TestCorporateUserRepository:
"""Corporate User Repositoryの統合テスト"""
@pytest.fixture
def repository(self, test_db_session):
"""テスト用リポジトリ"""
return CorporateUserRepository(test_db_session)
def test_find_by_id_exists(self, repository, test_db_session):
"""find_by_id: ユーザーが存在する場合"""
# Arrange: テストデータをDBに挿入
user_orm = UserORM(
id=1,
name="田中太郎",
email="tanaka@example.com",
created_at=datetime(2024, 1, 1)
)
test_db_session.add(user_orm)
test_db_session.commit()
# Act
result = repository.find_by_id(1)
# Assert
assert result is not None
assert isinstance(result, UserProfile)
assert result.id == 1
assert result.name == "田中太郎"
assert result.email == "tanaka@example.com"
def test_find_by_id_not_exists(self, repository):
"""find_by_id: ユーザーが存在しない場合"""
# Act
result = repository.find_by_id(999)
# Assert
assert result is None
def test_save_new_user(self, repository, test_db_session):
"""save: 新規ユーザー保存"""
# Arrange
user = UserProfile(
id=2,
name="佐藤花子",
email="sato@example.com",
joined_at=datetime(2024, 2, 1)
)
# Act
saved_user = repository.save(user)
# Assert
assert saved_user.id == 2
# DBに保存されたか確認
db_user = test_db_session.query(UserORM).filter(UserORM.id == 2).first()
assert db_user is not None
assert db_user.name == "佐藤花子"
pgvector統合テスト¶
# tests/integration/infrastructure/test_vector_repository.py
import pytest
import numpy as np
from app.infrastructure.vector.repository import VectorRepository
from app.infrastructure.vector.model import DocumentEmbeddingORM
@pytest.mark.integration
class TestVectorRepository:
"""pgvectorリポジトリの統合テスト"""
@pytest.fixture
def repository(self, test_db_session):
"""テスト用ベクトルリポジトリ"""
return VectorRepository(test_db_session)
def test_insert_embedding(self, repository, test_db_session):
"""Embedding挿入テスト"""
# Arrange
embedding = np.random.rand(1536).tolist()
# Act
doc_id = repository.insert(
title="テストドキュメント",
content="これはテストです",
embedding=embedding
)
# Assert
assert doc_id is not None
db_doc = test_db_session.query(DocumentEmbeddingORM).filter(
DocumentEmbeddingORM.id == doc_id
).first()
assert db_doc.title == "テストドキュメント"
assert len(db_doc.embedding) == 1536
def test_cosine_similarity_search(self, repository, test_db_session):
"""コサイン類似度検索テスト"""
# Arrange: 3つのドキュメントを挿入
embedding1 = np.random.rand(1536).tolist()
embedding2 = np.random.rand(1536).tolist()
embedding3 = embedding1.copy() # embedding1と完全一致
repository.insert("Doc1", "Content1", embedding1)
repository.insert("Doc2", "Content2", embedding2)
repository.insert("Doc3", "Content3", embedding3)
# Act: embedding1に近い文書を検索
results = repository.search_similar(embedding1, limit=2)
# Assert
assert len(results) == 2
assert results[0]["title"] in ["Doc1", "Doc3"] # 類似度高
E2Eテスト実装例¶
# tests/e2e/test_user_api.py
import pytest
from fastapi import status
@pytest.mark.e2e
class TestUserAPI:
"""ユーザーAPI E2Eテスト"""
def test_health_check(self, client):
"""ヘルスチェックエンドポイント"""
response = client.get("/health")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"status": "ok"}
def test_get_user_summary_success(self, client_with_db, test_db_session):
"""ユーザーサマリー取得: 正常系"""
# Arrange: テストデータ準備
from app.infrastructure.corporate.orm_model import UserORM
from datetime import datetime
user = UserORM(
id=1,
name="田中太郎",
email="tanaka@example.com",
created_at=datetime(2024, 1, 1)
)
test_db_session.add(user)
test_db_session.commit()
# Act
response = client_with_db.get("/api/users/1")
# Assert
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["user_id"] == 1
assert data["name"] == "田中太郎"
assert data["email"] == "tanaka@example.com"
def test_get_user_summary_not_found(self, client_with_db):
"""ユーザーサマリー取得: ユーザー不在"""
# Act
response = client_with_db.get("/api/users/999")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
factory_boy によるテストデータ生成¶
# tests/factories/user_factory.py
import factory
from datetime import datetime
from app.domain.user import UserProfile
from app.infrastructure.corporate.orm_model import UserORM
class UserProfileFactory(factory.Factory):
"""UserProfile Domainエンティティのファクトリ"""
class Meta:
model = UserProfile
id = factory.Sequence(lambda n: n)
name = factory.Faker("name", locale="ja_JP")
email = factory.Faker("email")
joined_at = factory.Faker("date_this_decade")
class UserORMFactory(factory.alchemy.SQLAlchemyModelFactory):
"""UserORM モデルのファクトリ"""
class Meta:
model = UserORM
sqlalchemy_session_persistence = "commit"
id = factory.Sequence(lambda n: n)
name = factory.Faker("name", locale="ja_JP")
email = factory.Faker("email")
created_at = factory.Faker("date_time_this_decade")
# 使用例
def test_with_factory(test_db_session):
"""ファクトリを使用したテスト"""
# ファクトリでユーザー生成
user = UserORMFactory.create_batch(5, _session=test_db_session)
assert len(user) == 5
assert all(u.name for u in user)
CI/CD パイプライン¶
GitHub Actions ワークフロー¶
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
# ============================================================
# テスト実行
# ============================================================
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11"]
services:
postgres:
image: ankane/pgvector:pg16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio
- name: Run unit tests
run: |
pytest tests/unit -v --cov=app --cov-report=xml --cov-report=term
- name: Run integration tests
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
run: |
pytest tests/integration -v --cov=app --cov-append --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
# ============================================================
# Lint & Format チェック
# ============================================================
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install linters
run: |
pip install ruff black isort mypy
- name: Run Ruff
run: ruff check . --output-format=github
- name: Run Black
run: black --check .
- name: Run isort
run: isort --check-only .
- name: Run mypy
run: mypy app/
# ============================================================
# Reviewdog(自動コードレビュー)
# ============================================================
reviewdog:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Ruff
run: pip install ruff
- name: Run Ruff with Reviewdog
uses: reviewdog/action-ruff@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
level: warning
# ============================================================
# セキュリティスキャン
# ============================================================
security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Bandit security scan
run: |
pip install bandit
bandit -r app/ -f json -o bandit-report.json
- name: Upload Bandit report
uses: actions/upload-artifact@v3
with:
name: bandit-report
path: bandit-report.json
# ============================================================
# Docker イメージビルド
# ============================================================
docker:
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: |
${{ secrets.DOCKER_USERNAME }}/fastapi-app:latest
${{ secrets.DOCKER_USERNAME }}/fastapi-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ============================================================
# MkDocs ドキュメント自動デプロイ
# ============================================================
deploy-docs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install MkDocs
run: |
pip install mkdocs-material pymdown-extensions
- name: Build MkDocs
run: mkdocs build --clean
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
Pre-commit フック¶
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.11
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
インストール:
テスト実行コマンド¶
# すべてのテスト実行
pytest
# ユニットテストのみ
pytest tests/unit -v
# 統合テストのみ
pytest tests/integration -v
# E2Eテストのみ
pytest tests/e2e -v
# カバレッジレポート付き
pytest --cov=app --cov-report=html
# 特定のテストファイル
pytest tests/unit/domain/test_user.py -v
# 特定のテストケース
pytest tests/unit/domain/test_user.py::TestUserProfile::test_create_user_profile -v
# マーカー指定
pytest -m unit # ユニットテストのみ
pytest -m "not slow" # 遅いテストを除外
# 並列実行(高速化)
pytest -n auto # CPU数に応じて並列化
カバレッジ測定¶
# カバレッジ測定 + HTMLレポート
pytest --cov=app --cov-report=html
# ブラウザで確認
open htmlcov/index.html
# カバレッジ80%未満で失敗
pytest --cov=app --cov-fail-under=80
# 特定ディレクトリのみ
pytest --cov=app/domain --cov-report=term-missing
まとめ¶
テスト戦略のポイント¶
| 項目 | 内容 |
|---|---|
| テストピラミッド | ユニット60% / 統合30% / E2E10% |
| TDD実践 | テストファースト開発 |
| カバレッジ目標 | 80%以上 |
| CI/CD | GitHub Actions自動化 |
| 品質ゲート | テスト失敗でマージ禁止 |
CI/CDパイプラインの構成¶
- コミット時: pre-commit フック(format/lint)
- プッシュ時: 自動テスト実行
- PR時: Reviewdog自動レビュー + カバレッジチェック
- マージ時: Dockerイメージビルド + デプロイ