テスト・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レポートをダウンロードできます