コンテンツにスキップ

テスト・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]

インストール:

pip install pre-commit
pre-commit install

テスト実行コマンド

# すべてのテスト実行
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パイプラインの構成

  1. コミット時: pre-commit フック(format/lint)
  2. プッシュ時: 自動テスト実行
  3. PR時: Reviewdog自動レビュー + カバレッジチェック
  4. マージ時: Dockerイメージビルド + デプロイ

次のステップ: ログ・監視設定 | DDD設計