📄
概念 📚 beginner-stepup

テスト設計の基本

単体・統合・E2Eテストの考え方とテストピラミッドを理解し、効果的なテスト戦略を設計できるようになる

テストはコードが意図通りに動くことを自動的に確認する仕組みである。「変更してもバグが出ないか確認する作業」を手動からプログラムに置き換えることで、安心して機能追加やリファクタリングができるようになる。Pythonのpytestと同様の概念がJavaScript/TypeScriptにも存在する。

テストピラミッド

         /\
        /E2E\          少量・実行が遅い・壊れやすい
       /------\
      /統合テスト\      適量・ミドルレイヤー
     /----------\
    /  単体テスト  \   大量・実行が速い・安定
   /--------------\

単体テスト(Unit Test): 関数・コンポーネント単体をテストする。外部依存をモックして分離する。
統合テスト(Integration Test): 複数モジュールの連携をテストする。APIエンドポイントをDBと合わせて確認する。
E2Eテスト(End-to-End Test): ブラウザを自動操作してユーザーの操作フロー全体をテストする。

Vitest(単体テスト)

// utils/format.ts
export function formatDate(date: Date): string {
  return date.toLocaleDateString("ja-JP");
}

// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatDate } from "./format";

describe("formatDate", () => {
  it("日本語形式で日付をフォーマットする", () => {
    const date = new Date("2026-04-19");
    expect(formatDate(date)).toBe("2026/4/19");
  });

  it("無効な入力は例外を投げる", () => {
    expect(() => formatDate(null as any)).toThrow();
  });
});
npx vitest          # ウォッチモードで実行
npx vitest run      # 1回実行
npx vitest --coverage # カバレッジレポート

React Testing Library(コンポーネントテスト)

import { render, screen, fireEvent } from "@testing-library/react";
import { Counter } from "./Counter";

describe("Counter", () => {
  it("初期値が0で表示される", () => {
    render(<Counter />);
    expect(screen.getByText("カウント: 0")).toBeInTheDocument();
  });

  it("+1ボタンを押すとカウントが増える", () => {
    render(<Counter />);
    fireEvent.click(screen.getByRole("button", { name: "+1" }));
    expect(screen.getByText("カウント: 1")).toBeInTheDocument();
  });
});

「実装の詳細ではなく、ユーザーが見るもの・操作するものをテストする」 がReact Testing Libraryの設計思想。getByRolegetByText のようにユーザー視点のクエリを使う。

Playwright(E2Eテスト)

import { test, expect } from "@playwright/test";

test("患者一覧ページを表示できる", async ({ page }) => {
  await page.goto("/patients");
  await expect(page).toHaveTitle("患者一覧");
  await expect(page.getByRole("list")).toBeVisible();
});

test("患者を新規登録できる", async ({ page }) => {
  await page.goto("/patients/new");
  await page.fill('[name="name"]', "テスト太郎");
  await page.click('[type="submit"]');
  await expect(page).toHaveURL("/patients");
  await expect(page.getByText("テスト太郎")).toBeVisible();
});
npx playwright test              # ヘッドレスで実行
npx playwright test --headed     # ブラウザを表示して実行
npx playwright codegen           # 操作を記録してテストコードを自動生成

テストを書く優先度

すべてにテストを書く必要はない。優先度の目安:

  1. ビジネスロジック(計算・変換・バリデーション)— 必ず書く
  2. APIエンドポイント(Happy path + エラーケース)— 書く
  3. 重要なUIの操作フロー(ログイン・主要な機能)— E2Eで書く
  4. 単純なコンポーネント(Propsを表示するだけ)— 省略可

Claude Codeにテストを書かせる場合、「このファイルのユニットテストを書いて。Vitestを使う。エラーケースも含めて」と具体的に指示する。


Human-in-the-loop テスト設計

AIが出力したものを「人が確認してから本番に反映する」フローは、特に医療・製薬データを扱うWebアプリで重要なパターンである。このフロー自体もテスト対象にする。

承認フローの実装とテスト

// 承認フローの型定義
type ApprovalStatus = "pending" | "approved" | "rejected";

interface AiResult {
  id: string;
  aiOutput: string;
  status: ApprovalStatus;
  reviewedBy: string | null;
  reviewedAt: Date | null;
}

// 承認処理の関数
async function approveAiResult(
  resultId: string,
  reviewerId: string
): Promise<AiResult> {
  return db
    .update(aiResults)
    .set({ status: "approved", reviewedBy: reviewerId, reviewedAt: new Date() })
    .where(eq(aiResults.id, resultId))
    .returning()
    .then((rows) => rows[0]);
}
// 承認フローのテスト
describe("approveAiResult", () => {
  it("未承認の結果を承認できる", async () => {
    const result = await approveAiResult("result_01", "reviewer_01");
    expect(result.status).toBe("approved");
    expect(result.reviewedBy).toBe("reviewer_01");
    expect(result.reviewedAt).not.toBeNull();
  });

  it("承認済みの結果は再承認できない", async () => {
    await expect(approveAiResult("already_approved_01", "reviewer_01"))
      .rejects.toThrow("既に承認済みです");
  });
});

AI出力の妥当性チェックテスト

LLMの出力はランダム性があるため、「結果が妥当な範囲か」を検証するテストが重要:

describe("validateLlmSummary", () => {
  it("空の要約を拒否する", () => {
    expect(() => validateLlmSummary("")).toThrow();
  });

  it("規定文字数を超える要約を拒否する", () => {
    const tooLong = "a".repeat(10001);
    expect(() => validateLlmSummary(tooLong)).toThrow();
  });

  it("禁止キーワードを含む出力を拒否する", () => {
    // 個人情報(氏名・生年月日など)が漏れていないか確認
    expect(() => validateLlmSummary("患者の山田太郎さんは...")).toThrow();
  });
});

Dify/n8nワークフローとWebアプリの統合テスト

Difyで作ったワークフローをWebアプリから呼び出す場合の統合テスト:

describe("POST /api/analyze", () => {
  it("Difyの結果が返ってきてDBに保存される", async () => {
    // Dify APIをモック(テスト時は外部APIを呼ばない)
    mockFetch.mockResolvedValue({
      ok: true,
      json: async () => ({ answer: "要約テキスト", usage: { tokens: 100 } }),
    });

    const response = await request(app).post("/api/analyze").send({
      reportId: "report_01",
    });

    expect(response.status).toBe(200);
    // DBに保存されたか確認
    const saved = await db.query.aiResults.findFirst({
      where: eq(aiResults.reportId, "report_01"),
    });
    expect(saved?.status).toBe("pending");  // 承認待ちで保存
    expect(saved?.approvedAt).toBeNull();   // まだ承認されていない
  });
});

status: "pending" で保存され、approvedAtnull であることを確認するのがポイント。AIの処理が完了しても、人間の承認前は本番に反映されない設計になっていることをテストで保証する。

sns_clone 参考実装

参考実装リポ subaru-hello/fullstack_typescript_curriculum のテスト関連。


参考リソース


確認クイズ

Q1. テストピラミッドにおいて最も数が多く、実行が速く安定しているテスト種別はどれですか? A. E2Eテスト B. 統合テスト C. 単体テスト D. パフォーマンステスト

正解: C

解説: テストピラミッドでは、底辺に単体テストが位置し「大量・実行が速い・安定」という特徴を持つ。E2Eテストはピラミッドの頂点に位置し、少量・実行が遅い・壊れやすいという特徴がある。

Q2. Vitestでテストを1回だけ実行するコマンドはどれですか? A. npx vitest B. npx vitest run C. npx vitest once D. npx vitest --single

正解: B

解説: npx vitest はウォッチモードでファイルの変更を監視して自動で再実行する。npx vitest run は1回だけ実行して終了する。CI環境などでは run オプションを使うのが一般的。

Q3. React Testing Libraryの設計思想として正しいものはどれですか? A. コンポーネントの内部実装(state・propsの詳細)を中心にテストする B. 実装の詳細ではなく、ユーザーが見るもの・操作するものをテストする C. レンダリングの速度を最優先にテストする D. コンポーネントの再レンダリング回数をテストする

正解: B

解説: React Testing Libraryは「実装の詳細ではなく、ユーザーが見るもの・操作するものをテストする」という設計思想を持つ。そのため getByRolegetByText のようなユーザー視点のクエリを使うことが推奨される。

Q4. Playwrightでブラウザを表示しながらE2Eテストを実行するコマンドはどれですか? A. npx playwright test --browser B. npx playwright test --ui C. npx playwright test --headed D. npx playwright test --visible

正解: C

解説: npx playwright test --headed でブラウザを表示しながらテストを実行できる。デフォルト(npx playwright test)はヘッドレスモードで実行される。また npx playwright codegen を使うとブラウザ操作を記録してテストコードを自動生成できる。

Q5. テストを書く優先度として最も高いものはどれですか? A. 単純なコンポーネント(Propsを表示するだけ) B. 重要なUIの操作フロー C. ビジネスロジック(計算・変換・バリデーション) D. スタイリングの確認

正解: C

解説: ビジネスロジック(計算・変換・バリデーション)は最優先でテストを書くべきとされている。単純なコンポーネントはテストを省略可能とされている。優先度はビジネスロジック > APIエンドポイント > 重要なUIフロー > 単純なコンポーネントの順。

Q6. Human-in-the-loopパターンにおいて、AI処理後のデータが status: "pending" で保存されることの意味を説明してください。

正解: AIの処理が完了しても、人間の承認前は本番に反映されない設計になっていることを示す

解説: 医療・製薬データのような重要なデータでは、AIが処理した結果をそのまま本番に適用せず承認待ち状態で保存する。人間がレビュー・承認した後に初めて本番に反映されるため、AIの誤出力による影響を防げる。

Q7. LLMの出力に対してバリデーションテストを書く際、このファイルで示されていた確認項目を3つ挙げてください。

正解: 空の要約を拒否する、規定文字数を超える要約を拒否する、禁止キーワードを含む出力を拒否する

解説: LLMの出力はランダム性があるため「結果が妥当な範囲か」を検証することが重要。特に個人情報(氏名・生年月日など)が漏れていないかを確認する禁止キーワードチェックは、医療・製薬データを扱う場合に不可欠。

Q8. Dify APIを呼び出す統合テストで、実際の外部APIを呼ばないようにする手法を何と言いますか?

正解: モック(mock)

解説: テスト時に外部APIを実際に呼び出すと、テストの実行が不安定になったり費用が発生したりする。mockFetch.mockResolvedValue(...) のようにAPIの応答をモック(偽の応答に差し替え)することで、外部依存を排除して安定したテストが書ける。

Q9. Vitestでカバレッジレポートを出力するコマンドはどれですか? A. npx vitest --report B. npx vitest --coverage C. npx vitest coverage D. npx vitest --cov

正解: B

解説: npx vitest --coverage でカバレッジレポートを出力できる。カバレッジはコードのどの部分がテストされているかを示す指標で、テストの網羅性を確認するために使う。

Q10. 統合テストの説明として最も正しいものはどれですか? A. 関数・コンポーネント単体を外部依存をモックして分離してテストする B. ブラウザを自動操作してユーザーの操作フロー全体をテストする C. 複数モジュールの連携をテストする(例: APIエンドポイントをDBと合わせて確認する) D. ネットワーク遅延をシミュレートしてパフォーマンスをテストする

正解: C

解説: 統合テストは複数モジュールの連携をテストする。APIエンドポイントをDBと合わせて確認する例が典型的。単体テストとE2Eテストの中間に位置し、テストピラミッドでは「適量・ミドルレイヤー」と位置づけられる。

発展:API 統合テスト(実 DB に当てる)

ユニットテスト(モック DB)では「クエリが正しく書けているか」は検証できません。Week 16 以降では 実 PostgreSQL に対して API エンドポイントを叩く統合テストを書きます。

テスト用 DB の分離方針

方式設計メリットデメリット
スキーマ分離同じ DB に test_ schema起動が速い並列実行で衝突
DB 分離snsclone_test DB を別に作る本番と完全分離2 つの DB を管理
コンテナ起動Testcontainers で毎回新規完全な独立性起動が遅い(5〜10 秒)

本カリキュラムの方針: DB 分離(2 つ目)。docker-compose.yml に test 用 Postgres を追加:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: snsclone
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    ports: ["5432:5432"]

  postgres-test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: snsclone_test
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    ports: ["5433:5432"]  # ポートを分ける
    tmpfs: /var/lib/postgresql/data  # インメモリで超高速化

.env.test

DATABASE_URL=postgresql://app:app@localhost:5433/snsclone_test

統合テストのセットアップ

api/src/__tests__/setup.ts

import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const testDb = drizzle(pool);

// 全テストの前に migrate
export async function setupTestDb() {
  await migrate(testDb, { migrationsFolder: "./drizzle" });
}

// 各テストの前に TRUNCATE で初期化
export async function resetTestDb() {
  await pool.query(`
    TRUNCATE TABLE reactions, comments, follows, posts, users RESTART IDENTITY CASCADE;
  `);
}

export async function teardownTestDb() {
  await pool.end();
}

api/vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    env: {
      DATABASE_URL: "postgresql://app:app@localhost:5433/snsclone_test",
      JWT_ACCESS_SECRET: "test-secret-access",
      JWT_REFRESH_SECRET: "test-secret-refresh",
    },
    globalSetup: ["./src/__tests__/global-setup.ts"],
    setupFiles: ["./src/__tests__/per-test-setup.ts"],
  },
});

api/src/__tests__/global-setup.ts(全テスト実行前に 1 回):

import { setupTestDb, teardownTestDb } from "./setup";

export async function setup() {
  await setupTestDb();
}

export async function teardown() {
  await teardownTestDb();
}

api/src/__tests__/per-test-setup.ts(各テスト前):

import { beforeEach } from "vitest";
import { resetTestDb } from "./setup";

beforeEach(async () => {
  await resetTestDb();
});

実例:POST /posts の統合テスト

import { describe, it, expect } from "vitest";
import app from "../index";
import { testDb } from "./setup";
import { users } from "../db/schema";
import { hashPassword } from "../lib/password";
import { signAccessToken } from "../lib/auth";

describe("POST /api/posts", () => {
  it("認証済みユーザーは投稿を作成できる", async () => {
    // 1. テストユーザーを作成
    const [user] = await testDb
      .insert(users)
      .values({
        email: "test@example.com",
        passwordHash: await hashPassword("SecurePass123!"),
        name: "テスト太郎",
      })
      .returning();

    const token = await signAccessToken(user.id);

    // 2. POST リクエストを発火
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ content: "Hello world" }),
    });

    // 3. レスポンスと DB 状態を検証
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.content).toBe("Hello world");
    expect(body.authorId).toBe(user.id);

    const allPosts = await testDb.select().from(posts);
    expect(allPosts).toHaveLength(1);
  });

  it("未認証では 401", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: "anon post" }),
    });
    expect(res.status).toBe(401);
  });

  it("content が空なら 400", async () => {
    const [user] = await testDb.insert(users).values({
      email: "x@example.com",
      passwordHash: await hashPassword("aaa"),
      name: "x",
    }).returning();
    const token = await signAccessToken(user.id);

    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ content: "" }),
    });
    expect(res.status).toBe(400);
  });
});

ポイント: Hono は app.request() で HTTP サーバーを立てずに fetch API 互換のレスポンスを取れます。supertest を入れる必要がありません。

CI での並列実行

Vitest はデフォルトでファイル単位並列実行。DB をアクセスする場合、テストファイル間で DB を共有するとフレーク します。

対処:

  • vitest.config.tsfileParallelism: false にする
  • または poolOptions: { threads: { singleThread: true } }
  • 本格対応は テストごとにスキーマを切る(DB 名でなく PostgreSQL schema)

本カリキュラムでは シングルスレッド実行 を推奨(CI の遅延は 30 秒程度)。

GitHub Actions で統合テストを回す

.github/workflows/ci.yml

jobs:
  api-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: snsclone_test
        ports: ["5433:5432"]
        options: >-
          --health-cmd "pg_isready -U app" --health-interval 10s
          --health-timeout 5s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm db:generate
      - run: pnpm -C api test
        env:
          DATABASE_URL: postgresql://app:app@localhost:5433/snsclone_test

services.postgres が CI 環境に一時的な PostgreSQL を立ててくれます。

ユニット / 統合 / E2E の書き分け(まとめ)

ツールDBブラウザ個数の目安
ユニットVitestモックなし数百
統合Vitest + 実 DB実 Postgresなし数十
E2EPlaywright実 Postgres実 Chromium5〜20

書く順序: まずユニットで関数レベルを固め、次に統合で API の契約を固定し、最後に E2E で画面遷移を担保。逆順に書くと崩れやすい。

詳細な Playwright 運用は b22-playwright を参照。


参考リソース

生きているコード

本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。

ブランチの作り方・見方は b00-curriculum-map を参照してください。

📚 beginner-stepup 全 53 章
導入編 13 章
  1. 1. 📄Web とは何か
  2. 2. 📄URL を打ってから画面が表示されるまで
  3. 3. 📄ネットワーク基礎(TCP/IP・DNS・HTTPS)
  4. 4. 📄【発展】物理層から通信が成立するまで(電力・Ethernet・Wi-Fi・Bluetooth)
  5. 5. 📄WSL2・Docker セットアップ詳細(Windows 向け)
  6. 6. 📄環境構築の段階的導入(macOS / Windows)
  7. 7. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
  8. 8. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
  9. 9. 📄シェル・ターミナル基礎
  10. 10. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
  11. 11. 📄Git基礎
  12. 12. 📄GitHubワークフロー
  13. 13. 📄パッケージ管理(pnpm workspace)
応用編 16 章 ● この編を閲覧中
  1. 1. 📄AWSインフラ基礎
  2. 2. 📄AWS Budget Alert の設定(Month 5 Week 17)
  3. 3. 📄環境変数管理
  4. 4. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
  5. 5. 📄CI/CD基礎
  6. 6. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
  7. 7. 📄テスト設計の基本
  8. 8. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
  9. 9. 📄CLAUDE.md・プロジェクト設定
  10. 10. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
  11. 11. 📄タスク分解・仕様の書き方
  12. 12. 📄Playwright で E2E テスト(Month 6 Week 22)
  13. 13. 📄生成コードのレビュー・デバッグの勘所
  14. 14. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
  15. 15. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
  16. 16. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)