TypeScript で構築した Next.js を使用した Todo アプリの作成方法と、そのテストコード（Jest＋React Testing Library）

本記事では、TypeScript で構築した Next.js を使用した Todo アプリの作成方法と、そのテストコード（Jest＋React Testing Library）の記述方法をご紹介します。ローカルで簡易的に試せるので、フロントエンドの学習や TypeScript の基本的な活用方法を知りたい方におすすめです。

**前提環境**

Node.js 16 以上

npm もしくは yarn などのパッケージマネージャー

Next.js 最新バージョン

TypeScript 最新バージョン

Jest & React Testing Library

プロジェクトをセットアップする前に、Node.js と npm（または yarn）がインストールされていることを確認しましょう。

**プロジェクトの作成**

以下のコマンドで Next.js プロジェクトを TypeScript 環境で作成します。npx コマンドを利用する例を示しますが、yarn を使用しても構いません。
```
npx create-next-app@latest my-todo-app --typescript
```
コマンドが完了すると、my-todo-app ディレクトリが作成され、その中に Next.js プロジェクトが生成されます。

フォルダ構成

Next.js（13 以降）のルーティングシステムは大きく 2 つの種類があります。

/pages ディレクトリを使用した従来のルーティング。

/app ディレクトリを使用した App Router（最新のルーティング）。

**本記事では最新の機能を使うため、/app ディレクトリ（App Router）を前提とした構成を例に解説します。以下のような構成を想定しています**。

my-todo-app/
 ┣ app/
 ┃  ┣ page.tsx
 ┃  ┣ layout.tsx
 ┃  ┗ (その他のコンポーネント)
 ┣ components/
 ┣ lib/
 ┣ __tests__/
 ┣ package.json
 ┣ tsconfig.json
 ┗ ...

app/page.tsx: アプリケーションのメインページ。

components/: 再利用可能な UI コンポーネント。

tests/: テストコード配置用。

Todo アプリの実装

# 1. Todo の型定義

TypeScript ではまずデータ構造を定義しましょう。ここではシンプルに id、title、completed の 3 つを持つ Todo 型を定義します。
```Typescript
// types/todo.ts
export type Todo = {
  id: string;
  title: string;
  completed: boolean;
};
```

# 2. TodoList コンポーネント

Todo のリストを表示し、チェックボックスで完了状態を変更できるコンポーネントを作成します。
```Typescript
// components/TodoList.tsx
'use client';

import React, { useState } from 'react';
import { Todo } from '@/types/todo';
import { v4 as uuidv4 } from 'uuid';

interface TodoListProps {
  initialTodos?: Todo[];
}

const TodoList: React.FC<TodoListProps> = ({ initialTodos = [] }) => {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);
  const [inputValue, setInputValue] = useState('');

  const handleAddTodo = () => {
    if (!inputValue.trim()) return;
    const newTodo: Todo = {
      id: uuidv4(),
      title: inputValue.trim(),
      completed: false,
    };
    setTodos([...todos, newTodo]);
    setInputValue('');
  };

  const handleToggleComplete = (id: string) => {
    const updatedTodos = todos.map((todo) => {
      if (todo.id === id) {
        return { ...todo, completed: !todo.completed };
      }
      return todo;
    });
    setTodos(updatedTodos);
  };

  return (
    <div className="max-w-md mx-auto p-4 bg-white rounded-2xl shadow">
      <h1 className="text-xl font-bold mb-4">Todo List</h1>
      <div className="flex mb-4">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          className="flex-1 border border-gray-300 rounded-l-lg p-2 focus:outline-none"
          placeholder="Add a new task"
        />
        <button
          onClick={handleAddTodo}
          className="bg-blue-500 text-white p-2 rounded-r-lg"
        >
          Add
        </button>
      </div>
      <ul className="space-y-2">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
          >
            <div className="flex items-center">
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggleComplete(todo.id)}
                className="mr-2"
              />
              <span className={todo.completed ? 'line-through text-gray-400' : ''}>
                {todo.title}
              </span>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;
```

useState フックで Todo の配列と入力値を管理しています。

handleAddTodo 関数で新しい Todo を追加します。todo の id は簡単のため uuid パッケージで生成。

handleToggleComplete 関数で完了状態（completed）を切り替えます。

# 3. メインページへの組み込み

app/page.tsx に上記の TodoList を配置し、Todo アプリのメイン画面として表示します。
```Typescript
// app/page.tsx
import React from 'react';
import TodoList from '@/components/TodoList';

export default function HomePage() {
  return (
    <main className="min-h-screen bg-gray-50 p-4">
      <TodoList />
    </main>
  );
}
```
この時点で、npm run dev でローカルサーバーを起動し、実際にブラウザで Todo アプリを操作できます。



# テスト環境のセットアップ

1. 依存パッケージのインストール

Next.js + TypeScript プロジェクトで Jest と React Testing Library を使うため、以下のパッケージをインストールします。
```
npm install --save-dev jest @types/jest ts-jest \
  testing-library/react testing-library/jest-dom \
  @testing-library/user-event
```

2. Jest の設定ファイル

プロジェクトルートに jest.config.js を作成します。
```Typescript
// jest.config.js
const nextJest = require('next/jest');

const createJestConfig = nextJest({
  dir: './',
});

/** @type {import('jest').Config} */
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jsdom',
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
};

module.exports = createJestConfig(customJestConfig);
```
この設定で、Next.js の設定を基に Jest を動かすようになります。

3. Jest のセットアップファイル

jest.setup.js ファイルを作成し、React Testing Library が提供するカスタムマッチャーや環境を設定します。
```Typescript
// jest.setup.js
import '@testing-library/jest-dom/extend-expect';
```
4. テストスクリプト設定

package.json に以下のようにテストスクリプトを追加します。
```Typescript
{
  "scripts": {
    "test": "jest"
  }
}
```

**テストコードの例**

それでは実際に TodoList コンポーネントをテストしてみましょう。__tests__/TodoList.test.tsx ファイルを作成し、React Testing Library を使って動作を検証します。
```Typescript
// __tests__/TodoList.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from '@/components/TodoList';

describe('TodoList component', () => {
  test('初期表示でタイトルが表示されること', () => {
    render(<TodoList />);
    expect(screen.getByText('Todo List')).toBeInTheDocument();
  });

  test('タスクを追加できること', async () => {
    render(<TodoList />);
    const input = screen.getByPlaceholderText('Add a new task');
    const addButton = screen.getByRole('button', { name: 'Add' });

    await userEvent.type(input, 'Test Task');
    await userEvent.click(addButton);

    expect(screen.getByText('Test Task')).toBeInTheDocument();
  });

  test('タスクを完了状態に切り替えられること', async () => {
    const initialTodos = [
      { id: '1', title: 'Existing Task', completed: false },
    ];
    render(<TodoList initialTodos={initialTodos} />);

    const checkbox = screen.getByRole('checkbox');
    await userEvent.click(checkbox);

    // 完了したタスクは取り消し線とグレーの文字になる。
    expect(screen.getByText('Existing Task')).toHaveClass('line-through');
  });
});
```

render 関数でコンポーネントを仮想 DOM にレンダリング

screen.getByText, screen.getByRole などで要素を検索

userEvent.type や userEvent.click でユーザー操作をエミュレート

アサーション（expect().toBeInTheDocument(), toHaveClass() など）で挙動を検証

**テストの実行**

以下のコマンドでテストを実行し、テスト結果を確認します。
```
npm run test
```

緑色の成功メッセージが表示されれば OK です。

テストコードをより詳細に理解するために

より本格的にテストを書いていくにあたって、以下のようなポイントを押さえておくと良いでしょう。

**1. テストケースの構成**

テストコードを書くときには、以下の 3 ステップを意識すると整理しやすくなります。

Arrange（準備）：テストに必要な初期値やモック関数などを用意する。

Act（実行）：ユーザー操作や関数呼び出しなど、実際のアクションを発火する。

Assert（検証）：最終的に得られる結果が期待どおりかどうかを expect で検証する。

React Testing Library では、render 関数を使ってコンポーネントを仮想 DOM に配置し、その後 screen 経由で要素を取得、userEvent で操作、最後に expect で検証という流れが基本となります。

**2. テスト対象の切り分け**

小規模アプリではコンポーネント単位でテストを行うことが多いですが、ビジネスロジックやデータ取得などが複雑になるにつれて、以下の観点でテストを切り分けるとメンテナンスしやすくなります。

ユニットテスト（Unit Test）：1 つの関数やコンポーネントなど、最小単位のロジックが正しく動作するかをチェック。

コンポーネントテスト：単一のコンポーネントが、UI として期待どおり描画・操作できるかを確認。

インテグレーションテスト（Integration Test）：複数のコンポーネントや API を組み合わせたロジックが正しく動作するかをチェック。

E2E テスト（End-to-End Test）：実際のブラウザ操作をシミュレートし、画面遷移やデータ処理が最終的に期待どおりの結果となるかを確認。

React Testing Library は主にユニットテストおよびコンポーネントテストで活用することが多いですが、Cypress や Playwright などを使った E2E テストも併用すると、より網羅的なテストが可能です。

**3. userEvent の活用方法**

React Testing Library の userEvent はユーザー操作をシミュレートするためのライブラリで、fireEvent よりも実際の操作に近い形でイベントを発火します。たとえば、キー入力、クリック、ドラッグなどを簡単にテストコードで再現できます。

よく使うメソッド：
```Typescript
userEvent.type(inputElement, '文字列') : テキスト入力をシミュレート

userEvent.click(buttonElement) : クリック動作

userEvent.selectOptions(selectElement, 'value') : セレクトボックスの選択

userEvent.upload(inputElement, file) : ファイルのアップロード
```

**4. テスト時のエラーメッセージの活用**

テストが失敗したときは、Jest と Testing Library が表示するエラーメッセージをよく確認し、どの要素が取得できなかったか、どのアサーションが失敗したかを分析しましょう。React Testing Library のデバッグ機能を使うと、テスト時点の仮想 DOM を出力しやすくなります。

// デバッグ例
const { debug } = render(<TodoList />);

// どこかで呼び出し
debug(); // コンソールに仮想DOMの構造を表示

**5. テストカバレッジの計測**

Jest にはテストカバレッジ（全コードのうち何％がテストで網羅されているか）を出力する機能があります。設定ファイル（jest.config.js）や CLI オプションで有効化すると、テスト実行後にカバレッジレポートが表示されます。

npm run test -- --coverage

必要に応じて coverageThreshold を設定し、一定水準以上のカバレッジを保つようにすることで、品質の担保に役立ちます。

# まとめ

本記事では、Next.js（最新の App Router）を TypeScript で活用した Todo アプリの構築方法と、Jest ＋ React Testing Library を用いたテストコードの例および、テストを行う上でのポイントを詳しくご紹介しました。

今回解説した主なポイント

テストコードの基礎構造：Arrange → Act → Assert の流れを理解する。

React Testing Library での操作：render, screen, userEvent を使って実際のユーザー操作に近い形でテストを記述。

テストの切り分け：ユニット、コンポーネント、インテグレーション、E2E などの種類によって適切にテストを配置。

デバッグとカバレッジ：テスト失敗時には仮想 DOM のデバッグ機能を活用し、カバレッジを計測して網羅性をチェック。