<CodeLearn/>
テスト レッスン5

テスト総合演習

TODOアプリにユニットテスト、コンポーネントテスト、E2Eテストを書こう

演習の概要

この演習では、TODOアプリに対して3種類のテストを書きます。 テストピラミッドに従い、ユニットテスト → コンポーネントテスト → E2Eテストの順に進めましょう。

演習1: ユニットテスト

TODOのビジネスロジック(追加、削除、フィルタリング)をテスト

演習2: コンポーネントテスト

TODOの各コンポーネントをReact Testing Libraryでテスト

演習3: E2Eテスト

PlaywrightでTODOアプリの操作フロー全体をテスト

テスト対象:TODOアプリのコード

まず、テスト対象となるTODOアプリのコードを確認しましょう。 ビジネスロジック、コンポーネント、APIの3層に分かれています。

// types.ts - 型定義
export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

export type FilterType = "all" | "active" | "completed";
// todo-logic.ts - ビジネスロジック(純粋関数)
import { Todo, FilterType } from "./types";

export function createTodo(title: string): Todo {
  if (!title.trim()) {
    throw new Error("タイトルは必須です");
  }
  return {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: new Date(),
  };
}

export function toggleTodo(todo: Todo): Todo {
  return { ...todo, completed: !todo.completed };
}

export function deleteTodo(todos: Todo[], id: string): Todo[] {
  return todos.filter((todo) => todo.id !== id);
}

export function filterTodos(todos: Todo[], filter: FilterType): Todo[] {
  switch (filter) {
    case "active":
      return todos.filter((todo) => !todo.completed);
    case "completed":
      return todos.filter((todo) => todo.completed);
    default:
      return todos;
  }
}

export function countRemaining(todos: Todo[]): number {
  return todos.filter((todo) => !todo.completed).length;
}

export function clearCompleted(todos: Todo[]): Todo[] {
  return todos.filter((todo) => !todo.completed);
}
// TodoApp.tsx - メインコンポーネント
import { useState } from "react";
import { Todo, FilterType } from "./types";
import {
  createTodo, toggleTodo, deleteTodo,
  filterTodos, countRemaining, clearCompleted
} from "./todo-logic";

export function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");
  const [filter, setFilter] = useState<FilterType>("all");
  const [error, setError] = useState("");

  const handleAdd = () => {
    try {
      const newTodo = createTodo(input);
      setTodos([...todos, newTodo]);
      setInput("");
      setError("");
    } catch (e) {
      setError(e instanceof Error ? e.message : "エラーが発生しました");
    }
  };

  const handleToggle = (id: string) => {
    setTodos(todos.map((t) => (t.id === id ? toggleTodo(t) : t)));
  };

  const handleDelete = (id: string) => {
    setTodos(deleteTodo(todos, id));
  };

  const filtered = filterTodos(todos, filter);
  const remaining = countRemaining(todos);

  return (
    <div>
      <h1>TODOアプリ</h1>
      <div>
        <input
          placeholder="新しいタスク"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleAdd()}
        />
        <button onClick={handleAdd}>追加</button>
      </div>
      {error && <p role="alert">{error}</p>}
      <ul>
        {filtered.map((todo) => (
          <li key={todo.id} data-testid="todo-item">
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
              aria-label={`${todo.title}を完了にする`}
            />
            <span style={{
              textDecoration: todo.completed ? "line-through" : "none"
            }}>
              {todo.title}
            </span>
            <button onClick={() => handleDelete(todo.id)}
              aria-label={`${todo.title}を削除`}>削除</button>
          </li>
        ))}
      </ul>
      <div>
        <span data-testid="remaining-count">{remaining}件の未完了</span>
        <button onClick={() => setFilter("all")}>すべて</button>
        <button onClick={() => setFilter("active")}>未完了</button>
        <button onClick={() => setFilter("completed")}>完了済み</button>
        <button onClick={() => setTodos(clearCompleted(todos))}>
          完了済みを削除
        </button>
      </div>
    </div>
  );
}

演習1: ユニットテスト

todo-logic.tsの 各関数をテストしましょう。純粋関数なのでモック不要で簡単にテストできます。

// todo-logic.test.ts
import { describe, test, expect } from "vitest";
import {
  createTodo, toggleTodo, deleteTodo,
  filterTodos, countRemaining, clearCompleted,
} from "./todo-logic";
import { Todo } from "./types";

// テスト用のヘルパー
function makeTodo(overrides: Partial<Todo> = {}): Todo {
  return {
    id: "test-1",
    title: "テスト用タスク",
    completed: false,
    createdAt: new Date("2026-01-01"),
    ...overrides,
  };
}

describe("createTodo", () => {
  test("タイトルからTODOを作成できる", () => {
    const todo = createTodo("買い物に行く");
    expect(todo.title).toBe("買い物に行く");
    expect(todo.completed).toBe(false);
    expect(todo.id).toBeDefined();
    expect(todo.createdAt).toBeInstanceOf(Date);
  });

  test("タイトルの前後の空白を除去する", () => {
    const todo = createTodo("  掃除する  ");
    expect(todo.title).toBe("掃除する");
  });

  test("空文字列でエラーを投げる", () => {
    expect(() => createTodo("")).toThrow("タイトルは必須です");
  });

  test("空白だけの文字列でエラーを投げる", () => {
    expect(() => createTodo("   ")).toThrow("タイトルは必須です");
  });
});

describe("toggleTodo", () => {
  test("未完了のTODOを完了にする", () => {
    const todo = makeTodo({ completed: false });
    const toggled = toggleTodo(todo);
    expect(toggled.completed).toBe(true);
  });

  test("完了のTODOを未完了にする", () => {
    const todo = makeTodo({ completed: true });
    const toggled = toggleTodo(todo);
    expect(toggled.completed).toBe(false);
  });

  test("元のオブジェクトを変更しない(イミュータブル)", () => {
    const todo = makeTodo({ completed: false });
    const toggled = toggleTodo(todo);
    expect(todo.completed).toBe(false); // 元は変わらない
    expect(toggled).not.toBe(todo);     // 別のオブジェクト
  });
});

describe("deleteTodo", () => {
  test("指定したIDのTODOを削除する", () => {
    const todos = [
      makeTodo({ id: "1", title: "タスク1" }),
      makeTodo({ id: "2", title: "タスク2" }),
      makeTodo({ id: "3", title: "タスク3" }),
    ];
    const result = deleteTodo(todos, "2");
    expect(result).toHaveLength(2);
    expect(result.map((t) => t.id)).toEqual(["1", "3"]);
  });

  test("存在しないIDの場合、配列は変わらない", () => {
    const todos = [makeTodo({ id: "1" })];
    const result = deleteTodo(todos, "999");
    expect(result).toHaveLength(1);
  });
});

describe("filterTodos", () => {
  const todos = [
    makeTodo({ id: "1", completed: false }),
    makeTodo({ id: "2", completed: true }),
    makeTodo({ id: "3", completed: false }),
  ];

  test("'all' ですべてのTODOを返す", () => {
    expect(filterTodos(todos, "all")).toHaveLength(3);
  });

  test("'active' で未完了のTODOだけ返す", () => {
    const result = filterTodos(todos, "active");
    expect(result).toHaveLength(2);
    expect(result.every((t) => !t.completed)).toBe(true);
  });

  test("'completed' で完了済みのTODOだけ返す", () => {
    const result = filterTodos(todos, "completed");
    expect(result).toHaveLength(1);
    expect(result[0].completed).toBe(true);
  });
});

describe("countRemaining", () => {
  test("未完了のTODOの数を返す", () => {
    const todos = [
      makeTodo({ completed: false }),
      makeTodo({ completed: true }),
      makeTodo({ completed: false }),
    ];
    expect(countRemaining(todos)).toBe(2);
  });

  test("空配列で0を返す", () => {
    expect(countRemaining([])).toBe(0);
  });
});

describe("clearCompleted", () => {
  test("完了済みのTODOを除去する", () => {
    const todos = [
      makeTodo({ id: "1", completed: false }),
      makeTodo({ id: "2", completed: true }),
      makeTodo({ id: "3", completed: true }),
    ];
    const result = clearCompleted(todos);
    expect(result).toHaveLength(1);
    expect(result[0].id).toBe("1");
  });
});

演習2: コンポーネントテスト

React Testing Libraryを使って、TODOアプリのコンポーネントをテストします。 ユーザーが実際に行う操作をシミュレートしましょう。

// TodoApp.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoApp } from "./TodoApp";

describe("TodoApp コンポーネント", () => {
  test("初期状態で空のリストが表示される", () => {
    render(<TodoApp />);

    expect(screen.getByText("TODOアプリ")).toBeInTheDocument();
    expect(screen.getByPlaceholderText("新しいタスク")).toBeInTheDocument();
    expect(screen.getByTestId("remaining-count")).toHaveTextContent("0件の未完了");
    expect(screen.queryAllByTestId("todo-item")).toHaveLength(0);
  });

  test("タスクを追加できる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    await user.type(screen.getByPlaceholderText("新しいタスク"), "牛乳を買う");
    await user.click(screen.getByText("追加"));

    expect(screen.getByText("牛乳を買う")).toBeInTheDocument();
    expect(screen.getByTestId("remaining-count")).toHaveTextContent("1件の未完了");
  });

  test("Enterキーでタスクを追加できる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    const input = screen.getByPlaceholderText("新しいタスク");
    await user.type(input, "洗濯する{Enter}");

    expect(screen.getByText("洗濯する")).toBeInTheDocument();
  });

  test("空のタスクを追加するとエラーが出る", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    await user.click(screen.getByText("追加"));

    expect(screen.getByRole("alert")).toHaveTextContent("タイトルは必須です");
  });

  test("タスクを完了にできる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // タスクを追加
    await user.type(screen.getByPlaceholderText("新しいタスク"), "掃除する");
    await user.click(screen.getByText("追加"));

    // チェックボックスをクリック
    await user.click(screen.getByLabelText("掃除するを完了にする"));

    // 完了状態を確認
    expect(screen.getByLabelText("掃除するを完了にする")).toBeChecked();
    expect(screen.getByTestId("remaining-count")).toHaveTextContent("0件の未完了");
  });

  test("タスクを削除できる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // タスクを追加
    await user.type(screen.getByPlaceholderText("新しいタスク"), "料理する");
    await user.click(screen.getByText("追加"));

    // 削除ボタンをクリック
    await user.click(screen.getByLabelText("料理するを削除"));

    // 削除を確認
    expect(screen.queryByText("料理する")).not.toBeInTheDocument();
  });

  test("フィルターで表示を切り替えられる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // 2つのタスクを追加
    const input = screen.getByPlaceholderText("新しいタスク");
    await user.type(input, "タスクA{Enter}");
    await user.type(input, "タスクB{Enter}");

    // タスクAを完了にする
    await user.click(screen.getByLabelText("タスクAを完了にする"));

    // 「未完了」フィルター
    await user.click(screen.getByText("未完了"));
    expect(screen.queryByText("タスクA")).not.toBeInTheDocument();
    expect(screen.getByText("タスクB")).toBeInTheDocument();

    // 「完了済み」フィルター
    await user.click(screen.getByText("完了済み"));
    expect(screen.getByText("タスクA")).toBeInTheDocument();
    expect(screen.queryByText("タスクB")).not.toBeInTheDocument();

    // 「すべて」フィルター
    await user.click(screen.getByText("すべて"));
    expect(screen.getByText("タスクA")).toBeInTheDocument();
    expect(screen.getByText("タスクB")).toBeInTheDocument();
  });

  test("完了済みを一括削除できる", async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // タスクを追加して1つ完了にする
    const input = screen.getByPlaceholderText("新しいタスク");
    await user.type(input, "残すタスク{Enter}");
    await user.type(input, "消すタスク{Enter}");
    await user.click(screen.getByLabelText("消すタスクを完了にする"));

    // 完了済みを削除
    await user.click(screen.getByText("完了済みを削除"));

    expect(screen.getByText("残すタスク")).toBeInTheDocument();
    expect(screen.queryByText("消すタスク")).not.toBeInTheDocument();
  });
});

演習3: E2Eテスト

Playwrightを使って、TODOアプリの操作フロー全体をブラウザ上でテストします。 実際のユーザー体験を再現するテストを書きましょう。

// tests/todo-app.spec.ts
import { test, expect } from "@playwright/test";

test.describe("TODOアプリ E2Eテスト", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/");
  });

  test("ページタイトルが表示される", async ({ page }) => {
    await expect(
      page.getByRole("heading", { name: "TODOアプリ" })
    ).toBeVisible();
  });

  test("タスクの追加から削除までの完全フロー", async ({ page }) => {
    const input = page.getByPlaceholder("新しいタスク");

    // ステップ1: タスクを3つ追加
    await input.fill("朝のランニング");
    await page.getByRole("button", { name: "追加" }).click();

    await input.fill("メールを確認");
    await page.getByRole("button", { name: "追加" }).click();

    await input.fill("レポートを書く");
    await input.press("Enter"); // Enterキーでも追加

    // 3つのタスクが表示されている
    await expect(page.getByTestId("todo-item")).toHaveCount(3);
    await expect(page.getByTestId("remaining-count"))
      .toHaveText("3件の未完了");

    // ステップ2: 1つのタスクを完了にする
    await page.getByLabel("朝のランニングを完了にする").check();
    await expect(page.getByTestId("remaining-count"))
      .toHaveText("2件の未完了");

    // ステップ3: フィルターを切り替え
    await page.getByRole("button", { name: "完了済み" }).click();
    await expect(page.getByTestId("todo-item")).toHaveCount(1);
    await expect(page.getByText("朝のランニング")).toBeVisible();

    await page.getByRole("button", { name: "未完了" }).click();
    await expect(page.getByTestId("todo-item")).toHaveCount(2);

    await page.getByRole("button", { name: "すべて" }).click();
    await expect(page.getByTestId("todo-item")).toHaveCount(3);

    // ステップ4: タスクを削除
    await page.getByLabel("メールを確認を削除").click();
    await expect(page.getByTestId("todo-item")).toHaveCount(2);
    await expect(page.getByText("メールを確認")).not.toBeVisible();

    // ステップ5: 完了済みを一括削除
    await page.getByRole("button", { name: "完了済みを削除" }).click();
    await expect(page.getByTestId("todo-item")).toHaveCount(1);
    await expect(page.getByText("レポートを書く")).toBeVisible();
  });

  test("空のタスクを追加できない", async ({ page }) => {
    await page.getByRole("button", { name: "追加" }).click();

    await expect(page.getByRole("alert")).toHaveText("タイトルは必須です");
    await expect(page.getByTestId("todo-item")).toHaveCount(0);
  });

  test("多数のタスクを管理できる", async ({ page }) => {
    const input = page.getByPlaceholder("新しいタスク");
    const tasks = ["タスクA", "タスクB", "タスクC", "タスクD", "タスクE"];

    // 5つのタスクを追加
    for (const task of tasks) {
      await input.fill(task);
      await input.press("Enter");
    }
    await expect(page.getByTestId("todo-item")).toHaveCount(5);

    // 奇数番目を完了にする
    await page.getByLabel("タスクAを完了にする").check();
    await page.getByLabel("タスクCを完了にする").check();
    await page.getByLabel("タスクEを完了にする").check();

    await expect(page.getByTestId("remaining-count"))
      .toHaveText("2件の未完了");

    // 完了済みを一括削除
    await page.getByRole("button", { name: "完了済みを削除" }).click();
    await expect(page.getByTestId("todo-item")).toHaveCount(2);
    await expect(page.getByText("タスクB")).toBeVisible();
    await expect(page.getByText("タスクD")).toBeVisible();
  });

  test("スクリーンショットを撮影する", async ({ page }) => {
    const input = page.getByPlaceholder("新しいタスク");

    await input.fill("スクリーンショット用タスク");
    await page.getByRole("button", { name: "追加" }).click();

    // ビジュアルリグレッションテスト
    await expect(page).toHaveScreenshot("todo-with-item.png");
  });
});

まとめ

  • ビジネスロジックを純粋関数として切り出すと、ユニットテストが格段に書きやすくなる
  • テスト用ヘルパー関数(makeTodo等)を作ると、テストコードの重複を減らせる
  • コンポーネントテストではユーザーの操作(入力、クリック)をシミュレートする
  • aria-label を設定しておくと、テストでもアクセシビリティでも役立つ
  • E2Eテストは重要なユーザーフローに絞り、ステップを明確に区切って書く
  • テストピラミッドに従い、ユニットテストを多く、E2Eテストは重要なフローに限定する