TDD Next.js TypeScript Jest

TDDでNext.jsアプリに機能追加してみた

TDD Next.js TypeScript Jest

はじめに

こんにちは、minne事業部Webエンジニアの@inowayです。7/6に公開したtwadaさんによる2023年度版TDDワークショップを開催しましたの記事に執筆者の一人として参加していました。単独での記事公開は今回が初めてです。

@t_wada さんのワークショップを通して、自動テストの重要性を改めて実感しました。参加後に、おすすめされていた『テスト駆動開発』『単体テストの考え方/使い方』を読み、今では完全に自動テストの魅力にはまっています。

参加直後は「実務でTDDを実践するぞ!」と息巻いていましたが、タイムリミットのある状況下で慣れない開発手法を実践するのは心理的ハードルが高く、Red->Green->Refactoringのステップを踏みながら開発することができていませんでした。そこで、本記事では「TDDに慣れる」をテーマに、TDDで機能追加することに挑戦します。アジャイルの思想に従い、最初から完璧なプロセスは目指さず、最低限、テストから書く(テストファースト)を実践できれば良しとします。

何を開発するのか

今回はNext.jsを使用した個人開発アプリにTDDで機能追加していきます。

アプリ概要

犬の画像と応援メッセージがランダムに表示される日めくりカレンダーです。「きのう」「あした」ボタンを押すたびに、日付・画像・メッセージが切り替わります。

dog-calendar

追加する機能

現状はDBを使用していないので、フロントエンドのみで実装できて、かつTDDの練習に適した簡単な機能を追加していきたいです。その観点で、今回は「日付欄に曜日を表示する」実装を行っていきたいと思います。

TDDのおさらい

テスト駆動開発(TDD)のプロセスには、さまざまな手法があると思いますが、今回は『テスト駆動開発』に記載されている内容を参照することにします。

  1. まずはテストを1つ書く
  2. すべてのテストを走らせ、新しいテストの失敗を確認する
  3. 小さな変更を行う
  4. すべてのテストを走らせ、すべて成功することを確認する
  5. リファクタリングを行って重複を除去する

『テスト駆動開発』 p.1より

要約すると、失敗するテストを書き(Red)、テストを成功させる実装を行い(Green)、重複を除去する(Refactoring)というステップになりそうです。

混同されがちなテストファーストとの違いとして、TDDは「テストを先に書く」だけに留まりません。Red->Green->Refactoringのサイクルを回しながら、設計を最適化していくことに重きを置いています。

ここでは省かれていますが、テストを書く前に、何を実装するかをメモしておくTodoリストを作ります。要件定義のようなものですね。

実装を先に行いテストを最後に書く場合や単なるテストファーストと比較して、TDDの何が優れているのか、以降の実践で確かめていきたいと思います。

TDDの実践

まず、既存コードは以下のようになっていて、1つのHomeコンポーネントのみで構成されています。既存機能のテストも追加済みです。

既存のコード

実装

import { useState, useEffect } from 'react';
import Image from 'next/image';

export default function Home() {
  const messages = [
    '今日も一日がんばるワン!',
    'いつでも君を応援しているワン!',
    '君ならできるワン!',
    '前向きに取り組むワン!',
    'どんな困難も乗り越えるワン!'
  ];
  const randomPickMessage = (): string => {
    const index = Math.floor(Math.random() * messages.length);
    return messages[index];
  };

  const [currentDate, setCurrentDate] = useState<Date>(new Date());
  const [imageNum, setImageNum] = useState<string>('');
  const [encourageMessage, setEncourageMessage] = useState<string>('');

  const imageCount = 10;
  const getImageNum = (): void => {
    const random = Math.floor(Math.random() * imageCount);
    setImageNum(random.toString());
  };

  const getMessage = (): void => {
    setEncourageMessage(randomPickMessage());
  };

  const changeDate = (offset: number): void => {
    setCurrentDate(prevDate => {
      const newDate = new Date(prevDate.getTime());
      newDate.setDate(newDate.getDate() + offset);
      return newDate;
    });
  };

  const formatDate = (date: Date) => {
    const month = date.getMonth() + 1;
    const day = date.getDate();
    return `${month}月${day}日`;
  };

  const goYesterday = (): void => changeDate(-1);
  const goTomorrow = (): void => changeDate(1);

  useEffect(() => {
    getImageNum();
    getMessage();
  }, [currentDate]);

  return (
    <div className="flex flex-col items-center justify-center h-screen space-y-5">
      <div className="flex items-center space-x-2">
        <h1 className="text-center text-4xl font-serif">{formatDate(currentDate)}</h1>
      </div>
      <div className="flex items-center justify-between px-4">
        <button onClick={goYesterday} className="px-4 py-2 border rounded-md">
          きのう
        </button>
        <button onClick={goTomorrow} className="px-4 py-2 border rounded-md">
          あした
        </button>
      </div>
      <div>
        <Image
          src={`/images/golden_retriever/${imageNum}.jpg`}
          alt="Random Dog Image"
          className='w-[375px] h-[375px] object-cover'
          width={375}
          height={375}
        />
      </div>
      <div className="p-4 rounded-xl bg-blue-200">
        <p className="font-rounded">{encourageMessage}</p>
      </div>
      <div className="flex items-center space-x-2 text-gray-500 text-sm">
        当サイトでは
        <a href="http://vision.stanford.edu/aditya86/ImageNetDogs/" className="text-gray-500 underline">
          Stanford Dogs Dataset
        </a>
        の画像を使用しています。
      </div>
    </div>
  );
}

テストコード

import { render, screen } from '@testing-library/react'
import Home from '../pages/index'
import '@testing-library/jest-dom'
import { fireEvent } from '@testing-library/react'

describe('Home', () => {
  let originalDate: DateConstructor;

  beforeAll(() => {
    originalDate = global.Date;
    global.Date = class extends originalDate {
      constructor() {
        super();
        return new originalDate('2023-01-01');
      }
    };
  });

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

  afterAll(() => {
    global.Date = originalDate;
  });

  test('応援メッセージが表示されること', () => {
    jest.spyOn(global.Math, 'random').mockReturnValue(0);
    render(<Home />);
    expect(screen.getByText('今日も一日がんばるワン!')).toBeInTheDocument();
  });

  test('あしたボタンを押すと、翌日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('あした'));
    expect(screen.getByText('1月2日')).toBeInTheDocument();
  });

  test('きのうボタンを押すと、前日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('きのう'));
    expect(screen.getByText('12月31日')).toBeInTheDocument();
  });

  test('画像が表示されること', () => {
    render(<Home />);
    expect(screen.getByRole('img')).toBeInTheDocument();
  });
});

Todoリストの作成

「日付欄に曜日を表示する」をシステム要件としてのTodoリストに落とし込むと、以下のようになりました。

- 日付の末尾に(曜日)と表示する ex. 1月1日(日)
- 曜日は日、月、火、水、木、金、土、日、月、火...と同じサイクルで繰り返される
- 日付が前日になると曜日は1つ前に戻る ex. 日 -> 土
- 日付が翌日になると曜日は1つ先に進む ex. 日 -> 月

失敗するテストを書く(Red)

既存のテストでは、Date.newは2023年1月1日を返すようにモックしているので、1月1日を基準に考えます。 調べたところ2023年1月1日は日曜日だったので、以下のようにテストを追加してみます。

+ test('曜日が表示されること', () => {
+   render(<Home />);
+   expect(screen.getByText('1月1日(日)')).toBeInTheDocument();
+ });

テストは意図通り失敗します。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (28 ms)
✓ あしたボタンを押すと、翌日の日付が表示されること (11 ms)
✓ きのうボタンを押すと、前日の日付が表示されること (6 ms)
✓ 画像が表示されること (21 ms)
✕ 曜日が表示されること (3 ms)

Tests:       1 failed, 4 passed, 5 total

テストを成功させる(Green)

失敗したテストを成功させるための実装を行います。日付の末尾に(日)を加えればテストは通るはずです。

  const formatDate = (date: Date) => {
    const month = date.getMonth() + 1;
    const day = date.getDate();
    // (日)を追加
+   return `${month}月${day}日(日)`;
  };

このような変更に何の意味があるのかと一見思いますが、TDDの真髄は「心理的負担を減らしながら、リファクタリングを行えること」にあり、そのためにまずはテストをGreenの状態にする必要があります。そして、こまめにテストを実行しながら変更を行うことで、いつ壊れたのかがすぐに分かります。変更中にテストが失敗してRedになったら、そこでリファクタリングの足を止めて、またGreenに戻すというサイクルを踏むことで、実装にリズムが生まれ、進捗感を得ながら先に進んでいくことができます。

テストを実行すると、新規追加したテストは通りましたが、既存のテストが2件落ちてしまいました。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (30 ms)
✕ あしたボタンを押すと、翌日の日付が表示されること (12 ms)
✕ きのうボタンを押すと、前日の日付が表示されること (6 ms)
✓ 画像が表示されること (19 ms)
✓ 曜日が表示されること (3 ms)

Tests:       2 failed, 3 passed, 5 total
  test('あしたボタンを押すと、翌日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('あした'));
    expect(screen.getByText('1月2日')).toBeInTheDocument();
  });

  test('きのうボタンを押すと、前日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('きのう'));
    expect(screen.getByText('12月31日')).toBeInTheDocument();
  });

テストが落ちた状態だとRefactoringが安全であるという自信が持てないので、まず落ちているテストをGreenにしてから、Refactoringに進むようにします。

要件を満たすテストに修正する(Red)

落ちたテストを通すことに意識を向ける前に、既存のテストは期待する要件を満たしているか確認します。テスト内容自体が誤っていれば、実装も正しいものにはできないからです。 確認すると、2件のテストには曜日の表記が無かったため落ちていたようです。期待する仕様をテストに反映します。

  // 翌日は(月)、前日は(土)になることを期待する
  test('あしたボタンを押すと、翌日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('あした'));
+   expect(screen.getByText('1月2日(月)')).toBeInTheDocument();
  });

  test('きのうボタンを押すと、前日の日付が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('きのう'));
+   expect(screen.getByText('12月31日(土)')).toBeInTheDocument();
  });

この状態で再びテストを実行すると、依然としてテストは落ちます。なぜなら、曜日の表記は(日)のみを返す実装になっているからです。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (30 ms)
✕ あしたボタンを押すと、翌日の日付が表示されること (12 ms)
✕ きのうボタンを押すと、前日の日付が表示されること (6 ms)
✓ 画像が表示されること (19 ms)
✓ 曜日が表示されること (3 ms)

Tests:       2 failed, 3 passed, 5 total

再びテストを成功させる(Green)

Todoリストに記載した要件は既存のテストで満たせているので、このタイミングで実際に機能するロジックを書いていきます。

  // getDay()は日曜日を基準に0~6の整数値を返す
  const formatDate = (date: Date) => {
    const month = date.getMonth() + 1;
    const day = date.getDate();
+   const dayOfWeek = ['日', '月', '火', '水', '木', '金', '土'][date.getDay()];
+   return `${month}月${day}日(${dayOfWeek})`;
  };

この状態でテストを実行すると、晴れて全てのテストが通りました。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (27 ms)
✓ あしたボタンを押すと、翌日の日付が表示されること (9 ms)
✓ きのうボタンを押すと、前日の日付が表示されること (7 ms)
✓ 画像が表示されること (21 ms)
✓ 曜日が表示されること (3 ms)

Tests:       5 passed, 5 total

重複を取り除く(Refactoring)

これで全てのテストが通ったので終わり、ではなく、ようやく自信を持ってリファクタリングを行える状態になりました。 今回の例では大きくリファクタリングする余地はなさそうですが、曜日の表記が変更になる可能性があるのと、他のコンポーネントでも使用する可能性があることを見越して、曜日の配列を定数化させることにします。

+ const DAYS_OF_WEEK = ['日', '月', '火', '水', '木', '金', '土'];

  export default function Home() {

    const formatDate = (date: Date) => {
      const month = date.getMonth() + 1;
      const day = date.getDate();
+     const dayOfWeek = DAYS_OF_WEEK[date.getDay()];
      return `${month}月${day}日(${dayOfWeek})`;
    };
  };

変更後、テストを実行し、全て通ることを確認します。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (27 ms)
✓ あしたボタンを押すと、翌日の日付が表示されること (9 ms)
✓ きのうボタンを押すと、前日の日付が表示されること (7 ms)
✓ 画像が表示されること (21 ms)
✓ 曜日が表示されること (3 ms)

Tests:       5 passed, 5 total

ここで改めてテストケースのリストを眺めてみると、当日の表示のみ「曜日が表示されること」となっており、前日・翌日とテストケース名の粒度が揃っていません。

また、日付と曜日は異なる概念なので、「日付と曜日が表示されること」と書くべきです。

自動テストをドキュメントとして機能させることを考慮すると、テストケース名が異なることによる不要な混乱を招くことは避けたいです。そこで以下のように修正します。

  // 当日のテストケースを上に移動させた
+ test('初回表示時に、当日の日付と曜日が表示されること', () => {
+   render(<Home />);
+   expect(screen.getByText('1月1日(日)')).toBeInTheDocument();
+ });

+ test('あしたボタンを押すと、翌日の日付と曜日が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('あした'));
    expect(screen.getByText('1月2日(月)')).toBeInTheDocument();
  });

+ test('きのうボタンを押すと、前日の日付と曜日が表示されること', () => {
    render(<Home />);
    fireEvent.click(screen.getByText('きのう'));
    expect(screen.getByText('12月31日(土)')).toBeInTheDocument();
  });

再びテストを実行すると、全て通りました。

$ yarn test
// 実行結果
✓ 応援メッセージが表示されること (27 ms)
✓ 初回表示時に、当日の日付と曜日が表示されること (3 ms)
✓ あしたボタンを押すと、翌日の日付と曜日が表示されること (9 ms)
✓ きのうボタンを押すと、前日の日付と曜日が表示されること (7 ms)
✓ 画像が表示されること (21 ms)

Tests:       5 passed, 5 total

以上のようにして、Red->Green->Refactoringのサイクルを回しながら、機能追加を実現することができました。

おわりに

『テスト駆動開発』を読んだ時にも感じましたが、TDDはメンタルを安定させながら開発を進めていくための手法なのだと実感しました。もしテストを書かずに実装を進めていた場合、ロジックが複雑になるにつれて次第に視野が狭くなり、いつの間にか何を実装しようとしていたのかを忘れてしまいます。テストを先に書いておき、こまめにテストを実行してフィードバックを得ながら実装を進めるようにすると、そのような事態を防ぐことができます。

また、本書の中では、不確実性が高い状況においては、歩幅を小さくして、少しずつ変更を加えていくことが重要だと述べられています。本記事では触れられませんでしたが、「三角測量」「明白な実装」といった概念も含めて理解することで、歩幅をどのように調整していくか判断できます。次回はRed->Green->Refactoringを回すだけでなく、より精緻なTDDにチャレンジしてみたいと思います。