VS CodeとGitHub CopilotでNext.jsの開発と単体テストを実装する #Developer&Designer Advent Calendar 2024

Developer&Designer Advent Calendar 2024

はじめに

Developer&Designer Advent Calendar 2024 9日目の記事です

こんにちは、クライアントサービス開発部のエンジニアの小高です。

弊社では開発環境に Github Copilot を導入しており開発効率が向上したことで開発体験が大きく向上しています。 Github Copilot は日々のアップデートにより、コード提案の精度が向上し、より実践的なコードを生成できるようになりました。 この記事では、シンプルな Todo アプリケーションを例に、Github Copilot を使ったコーディングと単体テストが実装できるのかを紹介したいと思います。

実装するアプリケーションの概要

技術スタック

  • Next.js (App Router)
  • TypeScript
  • Jest + React Testing Library

機能要件

  • Todo の追加、削除機能
  • Todo の完了・未完了の切り替え

GitHub Copilot を活用したコーディング

ディレクトリ構造

src/
  ├── app/
  │   ├── page.tsx
  │   └── layout.tsx
  └──  components/
      ├── TodoList/
      │   ├── TodoList.tsx
      │   └── TodoList.test.tsx
      ├── TodoItem/
      │   ├── TodoItem.tsx
      │   └── TodoItem.test.tsx
      └── TodoInput/
          ├── TodoInput.tsx
          └── TodoInput.test.tsx

コンポーネントの実装例

Github Copilotの指示はエディタ内のインラインチャットでプロンプトで指示を出しています。 指示内容についてはコメントアウトで表示している内容です

プロンプトで指示を出して実装した内容に対して不完全な箇所を手動で修正を加えて開発をしています。 手動で修正した箇所はコメントで記しています

プロンプトで指示を出す操作のイメージ

完成コンポーネント

コンポーネントのUIと責務

完成した挙動の動画

TodoList

'use client';

// TodoListコンポーネントの実装
// 要件:
// - Todoアイテムの一覧表示
// - 新規Todo入力フォームの表示
// - Todoの追加、削除、完了状態の切り替え機能
// TodoInput/TodoInputを読み込む
// TodoItem/TodoItemを読み込む

import React, { useState } from 'react';
import TodoInput from '../TodoInput/TodoInput';
import TodoItem from '../TodoItem/TodoItem';

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

const TodoList: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [nextId, setNextId] = useState(1);

  const addTodo = (text: string) => {
    const newTodo: Todo = { id: nextId, text, completed: false };
    setTodos([...todos, newTodo]);
    setNextId(nextId + 1);
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div>
      <h1>Todo List</h1>
      {/* <TodoInput addTodo={addTodo} /> は手動で修正 */}
      <TodoInput onAddTodo={addTodo} />
      <ul>
        {todos.map(todo => (
          <TodoItem
            key={todo.id}
            // todo={todo} は手動で修正
            id={todo.id}
            title={todo.text}
            completed={todo.completed}
            // deleteTodo={deleteTodo} は手動で修正
            onDelete={deleteTodo}
            // toggleTodo={toggleTodo} は手動で修正
            onToggle={toggleTodo}
          />
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

TodoInput

// TodoInput コンポーネントの実装
// 要件:
// - フォームバリデーション(空文字チェック)
// - 入力中のトリム処理
// - エンターキーでの送信対応
// - 送信後の入力フィールドクリア
// any は使わない
import React, { useState } from 'react';

interface TodoInputProps {
  onAddTodo: (todo: string) => void;
}

const TodoInput: React.FC<TodoInputProps> = ({ onAddTodo }) => {
  const [inputValue, setInputValue] = useState<string>('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value.trimStart());
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (inputValue.trim() === '') {
      return;
    }
    onAddTodo(inputValue.trim());
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Add a new todo"
      />
      <button type="submit">Add</button>
    </form>
  );
};

export default TodoInput;

TodoItem

"use client";
// TodoItemコンポーネントの実装
// 要件:
// - チェックボックスによる完了状態の切り替え
// - 削除ボタンの実装
// - 完了状態の場合はタイトルに取り消し線を表示

// useStateは使われていないので手動で削除
// import React, { useState } from 'react';

import React from 'react';

interface TodoItemProps {
  id: number;
  title: string;
  completed: boolean;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}

const TodoItem: React.FC<TodoItemProps> = ({ id, title, completed, onToggle, onDelete }) => {
  const handleToggle = () => {
    onToggle(id);
  };

  const handleDelete = () => {
    onDelete(id);
  };

  return (
    <div className="todo-item">
      <input type="checkbox" checked={completed} onChange={handleToggle} />
      <span style={{ textDecoration: completed ? 'line-through' : 'none' }}>{title}</span>
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
};

export default TodoItem;

単体テストの実装

単体テストの実行結果の動画も載せています。

TodoList.test.tsx

// TodoListコンポーネントのテスト実装
// テストケース:
// - Todoの追加
// テストケースは日本語で表示

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
// 手動で修正
import '@testing-library/jest-dom';
import TodoList from './TodoList';

describe('TodoListコンポーネントのテスト', () => {
  test('Todoの追加', () => {
    render(<TodoList />);
    
    const inputElement = screen.getByPlaceholderText('Add a new todo');
    const addButton = screen.getByText('Add');

    fireEvent.change(inputElement, { target: { value: '新しいTodo' } });
    fireEvent.click(addButton);

    expect(screen.getByText('新しいTodo')).toBeInTheDocument();
  });
});

テスト実行

TodoInput.test.tsx

// TodoInputコンポーネントのテスト実装
// テストケース:
// - フォームバリデーション(空文字チェック)
// - 入力中のトリム処理
// - 送信後の入力フィールドクリア
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
// 手動で追加
import '@testing-library/jest-dom';

import TodoInput from './TodoInput';

describe('TodoInputコンポーネントのテスト', () => {
  test('フォームバリデーション(空文字チェック)', () => {
    const onAddTodo = jest.fn();
    const { getByPlaceholderText, getByText } = render(<TodoInput onAddTodo={onAddTodo} />);
    const input = getByPlaceholderText('Add a new todo');
    const button = getByText('Add');

    fireEvent.change(input, { target: { value: ' ' } });
    fireEvent.click(button);

    expect(onAddTodo).not.toHaveBeenCalled();
  });

  test('入力中のトリム処理', () => {
    const onAddTodo = jest.fn();
    const { getByPlaceholderText } = render(<TodoInput onAddTodo={onAddTodo} />);
    const input = getByPlaceholderText('Add a new todo');

    fireEvent.change(input, { target: { value: '  new todo' } });

    expect(input).toHaveValue('new todo');
  });

  test('送信後の入力フィールドクリア', () => {
    const onAddTodo = jest.fn();
    const { getByPlaceholderText, getByText } = render(<TodoInput onAddTodo={onAddTodo} />);
    const input = getByPlaceholderText('Add a new todo');
    const button = getByText('Add');

    fireEvent.change(input, { target: { value: 'new todo' } });
    fireEvent.click(button);

    expect(input).toHaveValue('');
  });
});

テスト実行

TodoItem.test.tsx

// TodoItemコンポーネントのテスト実装
// テストケース:
// - 完了状態の切り替え
// - 削除機能の動作確認
// - スタイルの適用(完了時の取り消し線)
// テストケースは日本語で表示

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import TodoItem from './TodoItem';
// 手動で追加
import '@testing-library/jest-dom';


describe('TodoItemコンポーネントのテスト', () => {
  const mockOnToggle = jest.fn();
  const mockOnDelete = jest.fn();

  const defaultProps = {
    id: 1,
    title: 'Test Todo',
    completed: false,
    onToggle: mockOnToggle,
    onDelete: mockOnDelete,
  };

  test('完了状態の切り替え', () => {
    const { getByRole } = render(<TodoItem {...defaultProps} />);
    const checkbox = getByRole('checkbox');

    fireEvent.click(checkbox);

    expect(mockOnToggle).toHaveBeenCalledWith(defaultProps.id);
  });

  test('削除機能の動作確認', () => {
    const { getByText } = render(<TodoItem {...defaultProps} />);
    const deleteButton = getByText('Delete');

    fireEvent.click(deleteButton);

    expect(mockOnDelete).toHaveBeenCalledWith(defaultProps.id);
  });

  test('スタイルの適用(完了時の取り消し線)', () => {
    const { getByText, rerender } = render(<TodoItem {...defaultProps} completed={false} />);
    const titleElement = getByText(defaultProps.title);

    expect(titleElement).toHaveStyle('text-decoration: none');

    rerender(<TodoItem {...defaultProps} completed={true} />);

    expect(titleElement).toHaveStyle('text-decoration: line-through');
  });
});

テスト実行

まとめ

実装のポイント

  • コメントで具体的な要件を記述することで適切なコードが生成される
  • テストケースについても要件を指示することでテストケースを実装可能

開発効率化のポイント

  • コンポーネントの責務を明確にするコメントを記述
  • エッジケースを考慮したテストケースの実装

今回は簡単なプロンプトの指示で実装を行いましたが、 テストケースの充実、エラーハンドリング、アクセシビリティを考慮した実装も行ってみたいと思います。 GitHub Copilotを活用することで、品質の高いコードを効率的に開発できることが確認できました。 今後もより良い開発体験を求めて試行錯誤していきたいと思います。

alt

小高 武士 Takeshi Odaka

プロダクト統括部クライアントサービス開発部 HR_forecasterエンジニアリンググループ サブマネジャー

受託開発と事業会社での開発経験を経て、2024年にWebエンジニアとしてパーソルキャリアに中途入社。プロダクトの成長に貢献してより多くの人に使っていただけるようにすることがモチベーションです。

※2024年12月現在の情報です。