← トップページに戻る

TDD実践ガイド

🤖第15章 テスト任せとコンパイラ任せ(混合タイプ)

TODOアイテムの削除機能を実装しながら、TypeScriptコンパイラとテストの協調関係を学びます。 複数の操作タイプ(追加、切り替え、削除)を統合的に扱う方法を実践します。

とうとう「$5 + 10 CHF」のテストを書ける段階までやってきた。思えばこれが最初に思いついたTODO項目だった。
— Kent Beck『テスト駆動開発』第15章より

📋TODOリスト

  • TODOアイテムの完了切り替え機能
  • 混合タイプの処理(削除機能)
  • TypeScriptコンパイラとテストの協調
  • • フィルタリング機能

🤝TypeScriptコンパイラとテストの協調

Kent Beckの原著では、異なる通貨の混合計算を扱いましたが、ここでは異なるアクションタイプ(CRUD操作)の 混合処理をTypeScriptの型システムで安全に実装します。

🏗️1. TypeScript型定義による設計指針

まず、アクションの型を定義してコンパイラに設計を導いてもらいましょう。

types/TodoActions.ts
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export type TodoAction = 
  | { type: 'ADD'; payload: { text: string } }
  | { type: 'TOGGLE'; payload: { id: number } }
  | { type: 'DELETE'; payload: { id: number } }
  | { type: 'UPDATE'; payload: { id: number; text: string } };

export type TodoActionType = TodoAction['type'];

この型定義により、TypeScriptコンパイラが不正な操作を防いでくれます。

🔴2. レッド:削除機能のテスト

まず削除機能のテストを書きましょう。

components/TodoItem.test.tsx
describe('TodoItem コンポーネント - 削除機能のテスト', () => {
  it('削除ボタンクリック時にonDeleteが呼ばれること', () => {
    const mockOnDelete = jest.fn();
    const todo: Todo = { id: 1, text: 'テスト用TODO', completed: false };
    
    render(<TodoItem todo={todo} onDelete={mockOnDelete} />);
    
    const deleteButton = screen.getByRole('button', { name: /削除/i });
    fireEvent.click(deleteButton);
    
    expect(mockOnDelete).toHaveBeenCalledWith(1);
    expect(mockOnDelete).toHaveBeenCalledTimes(1);
  });

  it('確認ダイアログで誤削除を防ぐこと', () => {
    const mockOnDelete = jest.fn();
    // window.confirmをモック
    const confirmSpy = jest.spyOn(window, 'confirm');
    confirmSpy.mockReturnValue(false); // ユーザーがキャンセル
    
    const todo: Todo = { id: 1, text: '重要なTODO', completed: false };
    
    render(<TodoItem todo={todo} onDelete={mockOnDelete} />);
    
    const deleteButton = screen.getByRole('button', { name: /削除/i });
    fireEvent.click(deleteButton);
    
    expect(confirmSpy).toHaveBeenCalledWith('このTODOを削除してもよろしいですか?');
    expect(mockOnDelete).not.toHaveBeenCalled();
    
    confirmSpy.mockRestore();
  });
});

🧭3. TypeScriptコンパイラの指導に従う

まず、型定義に合わせてTodoItemを更新します。TypeScriptコンパイラが要求する変更を順番に適用していきます。

components/TodoItem.tsx
"use client";

import React from 'react';

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoItemProps {
  todo: Todo;
  onToggle?: (id: number) => void;
  onDelete?: (id: number) => void;
}

export default function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  const textStyle = {
    textDecoration: todo.completed ? 'line-through' : 'none',
  };

  const handleToggle = () => {
    if (onToggle) {
      onToggle(todo.id);
    }
  };

  const handleDelete = () => {
    if (onDelete && window.confirm('このTODOを削除してもよろしいですか?')) {
      onDelete(todo.id);
    }
  };

  return (
    <li style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span style={textStyle}>{todo.text}</span>
      {onDelete && (
        <button onClick={handleDelete} style={{ color: 'red' }}>
          削除
        </button>
      )}
    </li>
  );
}

⚙️4. Reducerパターンによる状態管理

TypeScriptの力を借りて、安全なstate管理を実装します。

hooks/useTodoReducer.ts
import { useReducer } from 'react';
import { Todo, TodoAction } from '../types/TodoActions';

interface TodoState {
  todos: Todo[];
  nextId: number;
}

const initialState: TodoState = {
  todos: [],
  nextId: 1
};

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD':
      return {
        todos: [
          ...state.todos,
          {
            id: state.nextId,
            text: action.payload.text,
            completed: false
          }
        ],
        nextId: state.nextId + 1
      };
    
    case 'TOGGLE':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    
    case 'DELETE':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id)
      };
    
    case 'UPDATE':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, text: action.payload.text }
            : todo
        )
      };
    
    default:
      // TypeScriptがこのケースに到達不可能であることを保証
      const _exhaustiveCheck: never = action;
      return state;
  }
}

🧪5. Reducerパターンのテスト

Reducerの動作を確認するテストを作成します。

hooks/useTodoReducer.test.ts
import { renderHook, act } from '@testing-library/react';
import { useTodoReducer } from './useTodoReducer';

describe('useTodoReducer のテスト', () => {
  it('TODOを正しく削除すること', () => {
    const { result } = renderHook(() => useTodoReducer());
    
    act(() => {
      result.current.addTodo('削除予定のTODO');
      result.current.addTodo('残すTODO');
    });
    
    expect(result.current.todos).toHaveLength(2);
    
    act(() => {
      result.current.deleteTodo(1);
    });
    
    expect(result.current.todos).toHaveLength(1);
    expect(result.current.todos[0].text).toBe('残すTODO');
  });

  it('複数の操作を順次処理すること', () => {
    const { result } = renderHook(() => useTodoReducer());
    
    // 複数の操作を順次実行
    act(() => {
      result.current.addTodo('最初のTODO');
      result.current.addTodo('2番目のTODO');
      result.current.addTodo('3番目のTODO');
    });
    
    act(() => {
      result.current.toggleTodo(2); // 2番目を完了
    });
    
    act(() => {
      result.current.deleteTodo(1); // 最初を削除
    });
    
    expect(result.current.todos).toHaveLength(2);
    expect(result.current.todos[0].text).toBe('2番目のTODO');
    expect(result.current.todos[0].completed).toBe(true);
    expect(result.current.todos[1].text).toBe('3番目のTODO');
    expect(result.current.todos[1].completed).toBe(false);
  });
});

🔗6. TodoListコンポーネントの更新

components/TodoList.tsx をReducerパターンで更新します。

components/TodoList.tsx
"use client";

import React from 'react';
import TodoItem from './TodoItem';
import TodoForm from './TodoForm';
import { useTodoReducer } from '../hooks/useTodoReducer';

export default function TodoList() {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodoReducer();

  return (
    <div>
      <TodoForm onSubmit={addTodo} />
      {todos.length === 0 ? (
        <p>まだTODOがありません!上記から追加してください。</p>
      ) : (
        <ul>
          {todos.map(todo => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={toggleTodo}
              onDelete={deleteTodo}
            />
          ))}
        </ul>
      )}
    </div>
  );
}

🧪7. 統合テストの更新

components/TodoList.test.tsx に削除機能のテストを追加します。

components/TodoList.test.tsx
describe('TodoList コンポーネント - 削除機能のテスト', () => {
  beforeEach(() => {
    // window.confirmをモック
    jest.spyOn(window, 'confirm').mockReturnValue(true);
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('削除ボタンクリック時にTODOが削除されること', () => {
    render(<TodoList />);
    
    const input = screen.getByRole('textbox');
    const submitButton = screen.getByRole('button', { name: /todo を追加/i });
    
    // 2つのTODOを追加
    fireEvent.change(input, { target: { value: '最初のTODO' } });
    fireEvent.click(submitButton);
    
    fireEvent.change(input, { target: { value: '2番目のTODO' } });
    fireEvent.click(submitButton);
    
    expect(screen.getByText(/最初のTODO/i)).toBeInTheDocument();
    expect(screen.getByText(/2番目のTODO/i)).toBeInTheDocument();
    
    // 最初のTODOを削除
    const deleteButtons = screen.getAllByRole('button', { name: /削除/i });
    fireEvent.click(deleteButtons[0]);
    
    // 最初のTODOが削除され、2番目が残っていることを確認
    expect(screen.queryByText(/最初のTODO/i)).not.toBeInTheDocument();
    expect(screen.getByText(/2番目のTODO/i)).toBeInTheDocument();
  });
});

🛡️8. TypeScript型の恩恵を活かしたテスト

型安全性を確認するテストを作成します。

__tests__/type-safety/todo-actions.test.ts
import { TodoAction } from '../../types/TodoActions';

describe('型安全性のテスト', () => {
  it('正しいアクション構造を強制すること', () => {
    // 正しいアクション
    const validActions: TodoAction[] = [
      { type: 'ADD', payload: { text: '新しいTODO' } },
      { type: 'TOGGLE', payload: { id: 1 } },
      { type: 'DELETE', payload: { id: 2 } },
      { type: 'UPDATE', payload: { id: 3, text: '更新されたテキスト' } }
    ];

    // TypeScriptコンパイラが型の正しさを保証
    expect(validActions).toHaveLength(4);
    
    // 各アクションが適切な型であることを確認
    validActions.forEach(action => {
      expect(action).toHaveProperty('type');
      expect(action).toHaveProperty('payload');
    });
  });

  // 以下はTypeScriptコンパイラが防ぐため、実際には書けません
  // これらをコメントアウトして実際に試してみてください
  
  /*
  it('無効なアクションを防ぐこと(これらはコンパイルエラーになります)', () => {
    // これらは型エラーになるため実際には書けません
    
    // const invalidAction1: TodoAction = { type: 'INVALID', payload: { text: 'test' } };
    // const invalidAction2: TodoAction = { type: 'ADD', payload: { id: 1 } }; // 間違ったpayload
    // const invalidAction3: TodoAction = { type: 'ADD' }; // payloadが欠けている
  });
  */
});

💡TypeScriptコンパイラとテストの協調の考察

この章で実践した手法:

  • 型主導設計: TypeScriptの型定義が設計の制約となり、不正な操作を防止
  • コンパイル時エラー防止: 実行時ではなくコンパイル時に問題を発見
  • Exhaustiveness checking: すべてのケースを処理していることをコンパイラが保証
  • 型安全なテスト: テストコード自体も型安全性の恩恵を受ける

TODOリストの更新

  • TODOアイテムの完了切り替え機能
  • 混合タイプの処理(削除機能)
  • TypeScriptコンパイラとテストの協調
  • • フィルタリング機能

🚀コミットとCIの確認

git add . git commit -m 'feat: Add delete functionality with TypeScript type safety (Chapter 15)' git push

次の章では、将来の読み手を考慮したテスト設計と抽象化について学びます。