第9部:実践プロジェクト Step 35 / 36

テスト・CI/CD構築

Claude Codeと一緒にテストを書き、CI/CDパイプラインを構築します。テストピラミッドに従い、単体テストからE2Eテストまで段階的に品質を担保していきましょう。

テスト戦略

テストピラミッド

テストピラミッドとは、テストを3つのレベルに分け、下層ほど多く・高速に、上層ほど少なく・重要なフローに絞る考え方です。TaskFlowプロジェクトでは以下の構成で進めます。

☑ 単体テスト(pytest)

サービス層・リポジトリ層のビジネスロジックを検証。最も数が多く、実行が高速。

カバレッジ目標:サービス層 80%以上

☑ APIテスト(pytest + httpx)

エンドポイントの統合テスト。リクエスト/レスポンスの形式、認証、エラーハンドリングを検証。

カバレッジ目標:主要エンドポイント 100%

☑ E2Eテスト(Playwright)

ブラウザ上で主要ユーザーフローを自動操作。認証フロー、タスク操作など重要な導線のみ。

カバレッジ目標:主要フロー(認証、タスクCRUD、カンバン操作)

Step 1:単体テスト

Claude Codeへの指示

タスクサービスの単体テストを書いてください。

【テスト対象】
backend/app/services/task_service.py

【テストケース】
- タスク作成(正常系、バリデーションエラー)
- タスク更新(正常系、権限エラー)
- ステータス変更のビジネスルール

【方針】
- pytest を使用
- DBアクセスはモックで置き換え
- conftest.py にフィクスチャを定義
- 各テストは独立して実行可能にする

conftest.py(テスト共通設定)

# backend/tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient

from app.main import app
from app.database import Base, get_db
from app.models.user import User
from app.auth.jwt import create_access_token

# テスト用DB(SQLite in-memory)
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

@pytest.fixture(scope="function")
def db_session():
    """各テストごとにクリーンなDBセッションを提供"""
    Base.metadata.create_all(bind=engine)
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.close()
        Base.metadata.drop_all(bind=engine)

@pytest.fixture(scope="function")
def client(db_session):
    """テスト用FastAPIクライアント"""
    def override_get_db():
        try:
            yield db_session
        finally:
            pass
    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()

@pytest.fixture
def test_user(db_session):
    """テスト用ユーザーを作成"""
    user = User(email="test@example.com", name="Test User")
    user.set_password("password123")
    db_session.add(user)
    db_session.commit()
    db_session.refresh(user)
    return user

@pytest.fixture
def auth_headers(test_user):
    """認証済みヘッダーを返す"""
    token = create_access_token(data={"sub": str(test_user.id)})
    return {"Authorization": f"Bearer {token}"}

test_task_service.py

# backend/tests/services/test_task_service.py
import pytest
from unittest.mock import MagicMock, patch
from app.services.task_service import TaskService
from app.schemas.task import TaskCreate, TaskUpdate
from app.exceptions import ValidationError, PermissionError

class TestTaskService:
    def setup_method(self):
        self.db = MagicMock()
        self.service = TaskService(self.db)

    # --- タスク作成 ---
    def test_create_task_success(self):
        """正常系:タスクが正しく作成される"""
        task_data = TaskCreate(
            title="新機能の実装",
            description="ログイン機能を追加",
            project_id=1,
            assignee_id=1
        )
        result = self.service.create_task(task_data, user_id=1)

        assert result.title == "新機能の実装"
        assert result.status == "todo"
        self.db.add.assert_called_once()
        self.db.commit.assert_called_once()

    def test_create_task_validation_error(self):
        """異常系:タイトル空でバリデーションエラー"""
        with pytest.raises(ValidationError) as exc_info:
            task_data = TaskCreate(
                title="",
                project_id=1
            )
            self.service.create_task(task_data, user_id=1)
        assert "title" in str(exc_info.value)

    # --- タスク更新 ---
    def test_update_task_success(self):
        """正常系:タスクが正しく更新される"""
        mock_task = MagicMock()
        mock_task.project.member_ids = [1, 2]
        self.db.query.return_value.get.return_value = mock_task

        update_data = TaskUpdate(title="更新後のタイトル")
        result = self.service.update_task(
            task_id=1, data=update_data, user_id=1
        )

        assert result is not None
        self.db.commit.assert_called_once()

    def test_update_task_permission_error(self):
        """異常系:プロジェクト外のユーザーは更新不可"""
        mock_task = MagicMock()
        mock_task.project.member_ids = [1, 2]
        self.db.query.return_value.get.return_value = mock_task

        with pytest.raises(PermissionError):
            update_data = TaskUpdate(title="不正な更新")
            self.service.update_task(
                task_id=1, data=update_data, user_id=999
            )

    # --- ステータス変更ビジネスルール ---
    def test_status_change_todo_to_in_progress(self):
        """todo -> in_progress は許可"""
        mock_task = MagicMock()
        mock_task.status = "todo"
        mock_task.project.member_ids = [1]
        self.db.query.return_value.get.return_value = mock_task

        result = self.service.change_status(
            task_id=1, new_status="in_progress", user_id=1
        )
        assert result.status == "in_progress"

    def test_status_change_invalid_transition(self):
        """done -> todo は許可しない"""
        mock_task = MagicMock()
        mock_task.status = "done"
        mock_task.project.member_ids = [1]
        self.db.query.return_value.get.return_value = mock_task

        with pytest.raises(ValidationError, match="無効なステータス遷移"):
            self.service.change_status(
                task_id=1, new_status="todo", user_id=1
            )

✓ 動作確認

# Claude Codeへの指示
pytest backend/tests/services/ -v を実行して、全テストがパスすることを確認してください。

Step 2:APIテスト

Claude Codeへの指示

API統合テストを書いてください。

【テスト対象】
- 認証エンドポイント(/api/auth/*)
- タスクエンドポイント(/api/tasks/*)

【方針】
- httpx.AsyncClient と TestClient を使用
- 実際のDB(テスト用SQLite)を使用
- 認証が必要なエンドポイントは auth_headers フィクスチャを使用
- レスポンスのステータスコードとボディ形式を検証

test_auth.py(認証APIテスト)

# backend/tests/api/test_auth.py
import pytest
from fastapi.testclient import TestClient

class TestAuthAPI:
    def test_register(self, client: TestClient):
        """ユーザー登録が成功する"""
        response = client.post("/api/auth/register", json={
            "email": "new@example.com",
            "name": "New User",
            "password": "securePass123"
        })
        assert response.status_code == 201
        data = response.json()
        assert data["email"] == "new@example.com"
        assert "password" not in data

    def test_register_duplicate_email(self, client: TestClient, test_user):
        """重複メールアドレスで登録失敗"""
        response = client.post("/api/auth/register", json={
            "email": "test@example.com",
            "name": "Duplicate",
            "password": "password123"
        })
        assert response.status_code == 409

    def test_login(self, client: TestClient, test_user):
        """ログインでトークンが返される"""
        response = client.post("/api/auth/login", json={
            "email": "test@example.com",
            "password": "password123"
        })
        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data
        assert data["token_type"] == "bearer"

    def test_login_wrong_password(self, client: TestClient, test_user):
        """不正なパスワードでログイン失敗"""
        response = client.post("/api/auth/login", json={
            "email": "test@example.com",
            "password": "wrongpassword"
        })
        assert response.status_code == 401

    def test_me_endpoint(self, client: TestClient, auth_headers):
        """認証済みユーザー情報の取得"""
        response = client.get("/api/auth/me", headers=auth_headers)
        assert response.status_code == 200
        data = response.json()
        assert data["email"] == "test@example.com"

test_tasks.py(タスクAPIテスト)

# backend/tests/api/test_tasks.py
import pytest
from fastapi.testclient import TestClient

class TestTasksAPI:
    def test_create_task(self, client: TestClient, auth_headers):
        """タスク作成が成功する"""
        response = client.post("/api/tasks/", json={
            "title": "テストタスク",
            "description": "APIテスト用のタスク",
            "project_id": 1,
            "priority": "high"
        }, headers=auth_headers)
        assert response.status_code == 201
        data = response.json()
        assert data["title"] == "テストタスク"
        assert data["status"] == "todo"
        assert "id" in data

    def test_list_tasks(self, client: TestClient, auth_headers):
        """タスク一覧の取得"""
        # まずタスクを作成
        client.post("/api/tasks/", json={
            "title": "タスク1",
            "project_id": 1
        }, headers=auth_headers)
        client.post("/api/tasks/", json={
            "title": "タスク2",
            "project_id": 1
        }, headers=auth_headers)

        response = client.get(
            "/api/tasks/?project_id=1",
            headers=auth_headers
        )
        assert response.status_code == 200
        data = response.json()
        assert len(data) >= 2

    def test_unauthorized_access(self, client: TestClient):
        """認証なしでのアクセスは拒否"""
        response = client.get("/api/tasks/")
        assert response.status_code == 401
        assert response.json()["detail"] == "Not authenticated"

    def test_update_task(self, client: TestClient, auth_headers):
        """タスク更新が成功する"""
        # タスクを作成
        create_resp = client.post("/api/tasks/", json={
            "title": "更新前",
            "project_id": 1
        }, headers=auth_headers)
        task_id = create_resp.json()["id"]

        # タスクを更新
        response = client.patch(f"/api/tasks/{task_id}", json={
            "title": "更新後のタイトル",
            "priority": "low"
        }, headers=auth_headers)
        assert response.status_code == 200
        assert response.json()["title"] == "更新後のタイトル"

✓ 動作確認

# Claude Codeへの指示
pytest backend/tests/ -v --cov=app --cov-report=term-missing を実行してください。
カバレッジレポートを確認し、不足しているテストがあれば追加してください。

Step 3:E2Eテスト(Playwright)

Claude Codeへの指示

PlaywrightでE2Eテストを構築してください。

【セットアップ】
- frontend/ディレクトリにPlaywrightをインストール
- playwright.config.ts を作成

【テストケース】
1. 認証フロー(ユーザー登録 -> ログイン -> ログアウト)
2. タスク操作(タスク作成 -> カンバンで確認 -> ドラッグで移動)

【注意点】
- テスト用のbaseURLはlocalhost:3000
- バックエンドAPIはlocalhost:8000で起動済みとする
- 各テストは独立して実行可能にする

playwright.config.ts

// frontend/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

auth.spec.ts(認証フローテスト)

// frontend/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('認証フロー', () => {
  test('ユーザー登録 -> ログイン -> ログアウト', async ({ page }) => {
    // ユーザー登録
    await page.goto('/register');
    await page.fill('[name="name"]', 'テストユーザー');
    await page.fill('[name="email"]', `test-${Date.now()}@example.com`);
    await page.fill('[name="password"]', 'Password123!');
    await page.fill('[name="passwordConfirm"]', 'Password123!');
    await page.click('button[type="submit"]');

    // ダッシュボードにリダイレクトされる
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('text=テストユーザー')).toBeVisible();

    // ログアウト
    await page.click('[data-testid="user-menu"]');
    await page.click('text=ログアウト');
    await expect(page).toHaveURL(/\/login/);
  });

  test('不正なパスワードでログイン失敗', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    await expect(
      page.locator('text=メールアドレスまたはパスワードが正しくありません')
    ).toBeVisible();
  });
});

tasks.spec.ts(タスク操作テスト)

// frontend/e2e/tasks.spec.ts
import { test, expect } from '@playwright/test';

test.describe('タスク操作', () => {
  test.beforeEach(async ({ page }) => {
    // ログイン済み状態にする
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'Password123!');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL(/\/dashboard/);
  });

  test('タスクを作成してカンバンで確認', async ({ page }) => {
    // プロジェクトページへ移動
    await page.click('[data-testid="project-link"]');

    // タスク作成ダイアログを開く
    await page.click('button:has-text("タスク追加")');
    await page.fill('[name="title"]', 'E2Eテスト用タスク');
    await page.fill('[name="description"]', 'Playwrightから作成');
    await page.selectOption('[name="priority"]', 'high');
    await page.click('button:has-text("作成")');

    // Todoカラムにタスクが表示される
    const todoColumn = page.locator('[data-testid="column-todo"]');
    await expect(todoColumn.locator('text=E2Eテスト用タスク')).toBeVisible();
  });

  test('タスクをドラッグして別カラムに移動', async ({ page }) => {
    await page.click('[data-testid="project-link"]');

    // タスクカードを取得
    const taskCard = page.locator('[data-testid="task-card"]').first();
    const inProgressColumn = page.locator('[data-testid="column-in_progress"]');

    // ドラッグ&ドロップ
    await taskCard.dragTo(inProgressColumn);

    // In Progressカラムに移動したことを確認
    await expect(
      inProgressColumn.locator('[data-testid="task-card"]')
    ).toHaveCount(1);
  });
});

✓ 動作確認

# Claude Codeへの指示
cd frontend && npx playwright test を実行してください。
失敗するテストがあれば原因を調査して修正してください。

Step 4:GitHub Actions CI/CD

Claude Codeへの指示

GitHub ActionsでCI/CDパイプラインを構築してください。

【CI(PR時に実行)】
- lint と型チェック(フロントエンド・バックエンド並列)
- バックエンドテスト(pytest + MySQL)
- フロントエンドテスト(Playwright)

【CD(mainマージ時に実行)】
- フロントエンド → Vercel にデプロイ
- バックエンド → Railway にデプロイ

MySQLサービスコンテナを使ってテスト用DBを構築してください。

.github/workflows/ci.yml

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  lint-and-type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        target: [frontend, backend]
    steps:
      - uses: actions/checkout@v4

      # フロントエンド
      - name: Setup Node.js
        if: matrix.target == 'frontend'
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - name: Install & Lint (Frontend)
        if: matrix.target == 'frontend'
        run: |
          cd frontend
          npm ci
          npm run lint
          npm run type-check

      # バックエンド
      - name: Setup Python
        if: matrix.target == 'backend'
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install & Lint (Backend)
        if: matrix.target == 'backend'
        run: |
          cd backend
          pip install -r requirements.txt
          ruff check .
          mypy app/

  test-backend:
    runs-on: ubuntu-latest
    needs: lint-and-type-check
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: testpass
          MYSQL_DATABASE: taskflow_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          cd backend
          pip install -r requirements.txt
          pip install pytest-cov

      - name: Run tests
        env:
          DATABASE_URL: mysql+pymysql://root:testpass@localhost:3306/taskflow_test
          JWT_SECRET: test-secret-key
        run: |
          cd backend
          pytest tests/ -v --cov=app --cov-report=xml

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: backend-coverage
          path: backend/coverage.xml

  test-frontend:
    runs-on: ubuntu-latest
    needs: lint-and-type-check
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        run: |
          cd frontend
          npm ci

      - name: Install Playwright browsers
        run: |
          cd frontend
          npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: |
          cd frontend
          npx playwright test

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: frontend/playwright-report/

.github/workflows/deploy.yml

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy-frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
          working-directory: frontend

  deploy-backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm i -g @railway/cli

      - name: Deploy to Railway
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          cd backend
          railway up --detach

✓ 動作確認

# Claude Codeへの指示
作成したワークフローファイルをGitHubにプッシュしてください。
git add .github/workflows/ && git commit -m "Add CI/CD pipelines" && git push

その後、GitHubのActionsタブでワークフローの実行状況を確認します。

注意:シークレットの設定

デプロイ用のワークフローを動作させるには、GitHubリポジトリの Settings > Secrets and variables > Actions で以下のシークレットを設定してください。

  • VERCEL_TOKEN - Vercelのアクセストークン
  • VERCEL_ORG_ID - VercelのOrganization ID
  • VERCEL_PROJECT_ID - VercelのProject ID
  • RAILWAY_TOKEN - Railwayのアクセストークン

テスト実行結果の確認

pytestの出力の読み方

# 実行結果の例
tests/services/test_task_service.py::TestTaskService::test_create_task_success PASSED
tests/services/test_task_service.py::TestTaskService::test_create_task_validation_error PASSED
tests/services/test_task_service.py::TestTaskService::test_update_task_success PASSED
tests/services/test_task_service.py::TestTaskService::test_update_task_permission_error PASSED
tests/api/test_auth.py::TestAuthAPI::test_register PASSED
tests/api/test_auth.py::TestAuthAPI::test_login PASSED

---------- coverage: ----------
Name                          Stmts   Miss  Cover
--------------------------------------------------
app/services/task_service.py     45      5    89%
app/routers/auth.py              32      0   100%
app/routers/tasks.py             58      3    95%
--------------------------------------------------
TOTAL                           280     28    90%
  • PASSED - テストが正常に通過
  • FAILED - テストが失敗(期待値と実際の値が不一致)
  • Cover列 - 各ファイルのカバレッジ率。Missはテストで実行されなかった行数

Playwrightレポートの確認

# HTMLレポートを開く
cd frontend && npx playwright show-report
  • ✓ ブラウザでレポートが開き、各テストの結果を視覚的に確認できます
  • ✓ 失敗したテストにはスクリーンショットとトレースが添付されます
  • ✓ トレースビューアでは、テスト実行中の各ステップを再生できます

CI/CDパイプラインの確認

  • GitHubのActionsタブでワークフローの実行状況をリアルタイムで確認
  • ✓ 各ジョブのログをクリックして詳細な実行結果を確認
  • ✓ PRにステータスチェックが表示され、全テスト通過でマージ可能になります
  • ArtifactsからカバレッジレポートやPlaywrightレポートをダウンロードできます
実装フェーズ2 次へ:デプロイ・運用