18
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テスト駆動開発(TDD)でポモドーロタイマー制作【React + TypeScript】

Last updated at Posted at 2021-07-24

image.png

テスト駆動開発で簡単なポモドーロタイマーを作ってみます。
今回は内部の処理は気にせず、React Testing Libraryを使った描画のテストを行っていきます。

途中経過と最終的なコードをGitHubにも上げています。

要件

  • 作業時間は25分、休憩時間は5分。
    ※長い休憩は実装しない。

  • タイマーの残り時間がテキストで表示される(MM:SS形式)。
    最初は作業時間がセットされている。

  • 開始/停止ボタンが表示される。
    タイマーが停止しているときは「開始」、タイマーが作動しているときは「停止」と表示される。最初はタイマーは停止している。

  • 作業/休憩がテキストで表示される。
    タイマーが停止しているときと作業中は「作業」、休憩中は「休憩」と表示される。

  • 開始ボタンを押すと、タイマーのカウントが開始される。カウントは1秒ずつ減っていく。

  • タイマーのカウント時に残り時間が0の場合、作業中の場合は休憩に切り替わり、残り時間に休憩時間がセットされ、休憩中の場合は作業に切り替わり、残り時間に作業時間がセットされる。

  • 停止ボタンを押すと、休憩中の場合は作業に切り替わり、残り時間に作業時間がセットされ、タイマーが止まる。

Reactプロジェクトの作成と起動

React + TypeScriptのプロジェクトを、Create React Appで作成します。

依存関係とバージョン
package.json(一部)
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "@types/jest": "^26.0.15",
    "@types/node": "^12.0.0",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-scripts": "4.0.3",
    "typescript": "^4.1.2",
    "web-vitals": "^1.0.1"
  },
npx create-react-app tdd-pomodoro-react --template typescript
cd tdd-pomodoro-react

開発サーバーを起動します。コードを書き換えると自動で反映されます。

npm start

別タブでテストを実行します。コードを書き換えると自動で再テストされます。

npm test

今回編集するファイルは、src/App.tsxsrc/App.test.tsxのみです。
まずはApp.tsxの不要な記述を削除し、白紙のページにします。

App.tsx
import React from "react";

const App = () => {
  return <></>;
};

export default App;

初期表示のテスト

次に、App.test.tsxに、初期表示のテストのtodoを追加します。

ページ上に描画される要素は3つなので、それぞれが描画されているかのテストを書いていきます。

App.test.tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";

describe("初期表示", () => {
  test.todo("「25:00」が描画されていること");
  test.todo("「開始」が描画されていること");
  test.todo("「作業」が描画されていること");
});

1つ目のtodoを書き換え、タイマーのカウントの初期表示である「25:00」が描画されていることのテストを書きます。

App.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";

describe("初期表示", () => {
  test("「25:00」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timeLeft").textContent).toEqual("25:00");
  });
  test.todo("「開始」が描画されていること");
  test.todo("「作業」が描画されていること");
});

テストはもちろん失敗です。
image.png

次に、App.tsxを編集し、このテストを通過するように最速で実装します。
テストIDが「timeLeft」の要素に「25:00」というテキストがあれば良いので、以下のようになります。

App.tsx
import React from "react";

const App = () => {
  return (
    <>
      <div data-testid="timeLeft">25:00</div>
    </>
  );
};

export default App;

これでテストを通過しました。

image.png

ブラウザにも「25:00」と表示されています。
image.png

ただし、このように値を直書きしたままではカウントに応じて値を変えることができないので、リファクタリングを行います。

残り時間はstateに秒で保持するようにして、MM:SS形式に変換する関数を通して画面に表示します。

App.tsx
import React, { useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

interface State {
  timeLeft: number;
}

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
  });
  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
    </>
  );
};

export default App;

再度テストを通過していることを確認し、リファクタリングを終了します。

次に、開始ボタンが描画されていることのテストを書きます。

App.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";

describe("初期表示", () => {
  test("「25:00」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timeLeft").textContent).toEqual("25:00");
  });
  test("「開始」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timerButton").textContent).toEqual("開始");
  });
  test.todo("「作業」が描画されていること");
});

テストに失敗していることを確認した後、実装を行います。

テストIDが「timerButton」で「開始」と書かれているボタンを追加します。

App.tsx(一部)
return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton">開始</button>
    </>
  );

テストを通過していることを確認した後、リファクタリングを行います。

タイマーか作動しているかどうかをboolean型でstateに保持し、その値に応じて「停止」か「開始」と表示するようにします。

App.tsx
import React, { useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

interface State {
  timeLeft: number;
  isTimerOn: boolean;
}

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
  });
  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton">
        {state.isTimerOn ? "停止" : "開始"}
      </button>
    </>
  );
};

export default App;

再度テストを通過していることを確認し、リファクタリングを終了します。
image.png

次に、「作業」が描画されていることのテストを書きます。

App.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";

describe("初期表示", () => {
  test("「25:00」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timeLeft").textContent).toEqual("25:00");
  });
  test("「開始」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timerButton").textContent).toEqual("開始");
  });
  test("「作業」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timerMode").textContent).toEqual("作業");
  });
});

テストに失敗していることを確認した後、実装を行います。

テストIDが「timerMode」の要素に「作業」というテキストがあれば良いので、以下のようになります。

App.tsx(一部)
  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton">
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">作業</div>
    </>
  );

テストを通過していることを確認した後、リファクタリングを行います。

タイマーモード(作業"work"または休憩"break")をstateに保持し、その値に応じて「作業」か「休憩」と表示するようにします。

App.tsx
import React, { useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

/** タイマーモード */
type TimerMode = "work" | "break";

interface State {
  timeLeft: number;
  isTimerOn: boolean;
  timerMode: TimerMode;
}

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
    timerMode: "work",
  });
  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton">
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">
        {state.timerMode === "work" ? "作業" : "休憩"}
      </div>
    </>
  );
};

export default App;

再度テストを通過していることを確認し、リファクタリングを終了します。

ブラウザの表示は以下のようになります。
image.png

開始ボタンを押した後の表示のテスト

ユーザーが行えるアクションは開始/停止ボタンを押すことだけなので、「開始ボタンを押した後」と「停止ボタンを押した後」の2パターンに分けてテストを書いていきます。

開始ボタンを押した後の表示のテストで最低限必要そうなものをtodoに書き出すと、以下のようになりました。

App.test.tsx(一部)
describe("開始ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押した直後の表示のテスト", () => {
    test.todo("「25:00」が描画されていること");
    test.todo("「停止」が描画されていること");
    test.todo("「作業」が描画されていること");
  });
  describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => {
    test.todo("「25:00」が描画されていること");
  });
  describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => {
    test.todo("「24:59」が描画されていること");
  });
  describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => {
    test.todo("「24:58」が描画されていること");
  });
  describe("開始ボタンを押してから25分後の表示のテスト", () => {
    test.todo("「00:00」が描画されていること");
    test.todo("「作業」が描画されていること");
  });
  describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => {
    test.todo("「04:59」が描画されていること");
    test.todo("「休憩」が描画されていること");
  });
  describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => {
    test.todo("「24:59」が描画されていること");
    test.todo("「作業」が描画されていること");
  });
});

開始ボタンを押した直後の表示のテスト

まずは、開始ボタンを押した直後の表示のテストを書いてみます。

App.test.tsx(一部)
describe("開始ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押した直後の表示のテスト", () => {
    test("「25:00」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「停止」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerButton").textContent).toEqual("停止");
    });
    test("「作業」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });

...

テストの結果を見ると、「停止」が描画されていることで失敗していました。
image.png

App.tsxを編集し、開始ボタンを押すと「停止」と表示されるようにします。

ボタンをクリックするとonButtonClick関数が呼び出されるようにし、stateのisTimerOnの真偽値を反転させます。

App.tsx(一部)
const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
    timerMode: "work",
  });

  const onButtonClick = () => {
    setState((state) => {
      return { ...state, isTimerOn: !state.isTimerOn };
    });
  };

  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton" onClick={onButtonClick}>
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">
        {state.timerMode === "work" ? "作業" : "休憩"}
      </div>
    </>
  );
};

テストを通過していることを確認できました。
image.png

ブラウザで動作を確認すると、ボタンを押すたびに「開始」と「停止」が切り替わっています。タイマーのカウントは進みません。
2021-07-24-17-46-15_Trim.gif

リファクタリングは必要なさそうなので、次に進みます。

開始ボタンを押してから25分後までの表示のテスト

続いては、開始ボタンを押してから25分後までの表示のテストを一気に書きます。

Jestのタイマーモックを使い、時間経過をコントロールします。

App.test.tsx(一部)
...

  describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => {
    test("「25:00」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(999);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
  describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => {
    test("「24:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:59");
    });
  });
  describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => {
    test("「24:58」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:58");
    });
  });
  describe("開始ボタンを押してから25分後の表示のテスト", () => {
    test("「00:00」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(25 * 60 * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("00:00");
    });
    test("「作業」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(25 * 60 * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });

...

テストの結果は以下のようになりました。
タイマーのカウントを減らす処理はまだ実装していないので、カウントの描画のテストに失敗しています。
image.png

App.tsxを編集し、タイマーのカウントを1秒ごとに減らす処理を実装します。

App.tsx(一部)
  const onButtonClick = () => {
    setState((state) => {
      setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: !state.isTimerOn };
    });
  };

  const timerCount = () => {
    setState((state) => {
      return { ...state, timeLeft: state.timeLeft - 1 };
    });
  };

テストを通過しました。
image.png

次に、リファクタリングを行います。
以下の警告が出ているので、これを潰します。

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

アンマウント時にタイマーのカウントのsetIntervalをクリアするようにします。

App.tsx
import React, { useEffect, useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

/** タイマーモード */
type TimerMode = "work" | "break";

interface State {
  timeLeft: number;
  isTimerOn: boolean;
  timerMode: TimerMode;
}

/** タイマーのカウントのsetIntervalのID */
let timerCountInterval = 0;

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
    timerMode: "work",
  });

  useEffect(() => {
    return () => {
      clearInterval(timerCountInterval);
    };
  }, []);

  const onButtonClick = () => {
    setState((state) => {
      timerCountInterval = window.setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: !state.isTimerOn };
    });
  };

  const timerCount = () => {
    setState((state) => {
      return { ...state, timeLeft: state.timeLeft - 1 };
    });
  };

  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton" onClick={onButtonClick}>
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">
        {state.timerMode === "work" ? "作業" : "休憩"}
      </div>
    </>
  );
};

export default App;

作業と休憩が切り替わるタイミングの表示のテスト

次に、作業から休憩に切り替わるタイミング(開始から25分+1秒後)と、休憩から作業に切り替わるタイミング(開始から25分+5分+1秒後)の表示のテストを書きます。

App.test.tsx(一部)
  describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => {
    test("「04:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 1) * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("04:59");
    });
    test("「休憩」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 1) * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("休憩");
    });
  });
  describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => {
    test("「24:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:59");
    });
    test("「作業」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });

テストの結果は以下のようになりました。作業と休憩の切り替えはまだ実装していないので、3つのテストで失敗しています。
image.png

App.tsxを編集し、作業と休憩の切り替え処理を実装します。
カウント時に残り時間が0以下のときに、toggleTimerMode関数が呼び出されます。

App.tsx
import React, { useEffect, useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

/** タイマーモード */
type TimerMode = "work" | "break";

interface State {
  timeLeft: number;
  isTimerOn: boolean;
  timerMode: TimerMode;
}

/** タイマーのカウントのsetIntervalのID */
let timerCountInterval = 0;

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
    timerMode: "work",
  });

  useEffect(() => {
    return () => {
      clearInterval(timerCountInterval);
    };
  }, []);

  const onButtonClick = () => {
    setState((state) => {
      timerCountInterval = window.setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: !state.isTimerOn };
    });
  };

  const timerCount = () => {
    setState((state) => {
      if (state.timeLeft <= 0) {
        state = toggleTimerMode(state);
      }
      return { ...state, timeLeft: state.timeLeft - 1 };
    });
  };

  const toggleTimerMode = (state: State): State => {
    const timeLeft =
      state.timerMode === "work" ? TIMER_LENGTH.break : TIMER_LENGTH.work;
    const timerMode = state.timerMode === "work" ? "break" : "work";
    return {
      ...state,
      timeLeft: timeLeft,
      timerMode: timerMode,
    };
  };

  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton" onClick={onButtonClick}>
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">
        {state.timerMode === "work" ? "作業" : "休憩"}
      </div>
    </>
  );
};

export default App;

テストを通過しました。
image.png

停止ボタンを押した後の表示のテスト

停止ボタンを押すパターンは、大きく分けると

  1. 作業中に停止する場合
  2. 休憩中に停止する場合

の2パターンあります。

todoを書き出すと以下のようになります。

App.test.tsx(一部)
describe("停止ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => {
    test.todo("「25:00」と描画されていること");
    test.todo("「作業」と描画されていること");
    test.todo("停止してから1秒後に「25:00」と描画されていること");
  });
  describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => {
    test.todo("「25:00」と描画されていること");
    test.todo("「作業」と描画されていること");
    test.todo("停止してから1秒後に「25:00」と描画されていること");
  });
});

まずは、「25:00」と描画されていることと、「作業」と描画されていることのテストを書きます。

App.test.tsx(一部)
describe("停止ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => {
    test("「25:00」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「作業」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
    test.todo("停止してから1秒後に「25:00」と描画されていること");
  });
  describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => {
    test("「25:00」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「作業」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
    test.todo("停止してから1秒後に「25:00」と描画されていること");
  });
});

テスト結果は以下のようになりました。
残り時間のリセットと、休憩から作業に切り替える処理がまだ実装されていないことが分かります。
image.png

App.tsxを編集し、停止ボタンをクリックした時の残り時間のリセットと、休憩から作業に切り替える処理を実装します。
onButtonClick関数を以下のように書き換えました。

App.tsx(一部)
  const onButtonClick = () => {
    setState((state) => {
      if (state.isTimerOn) {
        return {
          ...state,
          timeLeft: TIMER_LENGTH.work,
          timerMode: "work",
          isTimerOn: false,
        };
      }
      timerCountInterval = window.setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: true };
    });
  };

テストを通過しました。
image.png

リファクタリングする箇所は特に見つからなかったので、次に進みます。

停止してから1秒後に「25:00」と描画されていることのテストを書きます。

App.test.tsx(一部)
describe("停止ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => {

...

    test("停止してから1秒後に「25:00」と描画されていること", () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
  describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => {

...

    test("停止してから1秒後に「25:00」と描画されていること", () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
});

テスト結果は以下のようになりました。
image.png

App.tsxを編集し、停止ボタンをクリックしたときにタイマーのカウントが止まるようにします。
onButtonClick関数に、clearInterval()を入れます。

App.tsx(一部)
  const onButtonClick = () => {
    setState((state) => {
      clearInterval(timerCountInterval);
      if (state.isTimerOn) {
        return {
          ...state,
          timeLeft: TIMER_LENGTH.work,
          timerMode: "work",
          isTimerOn: false,
        };
      }
      timerCountInterval = window.setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: true };
    });
  };

テストを通過しました。
image.png

リファクタリングする箇所は特にありません。

最終的なコード

App.tsx
import React, { useEffect, useState } from "react";

/** タイマーの長さ */
const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const;
type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH];

/** タイマーモード */
type TimerMode = "work" | "break";

interface State {
  timeLeft: number;
  isTimerOn: boolean;
  timerMode: TimerMode;
}

/** タイマーのカウントのsetIntervalのID */
let timerCountInterval = 0;

/**
 * 秒の数値をMM:SS形式の文字列に変換します。
 * @param {number} second 秒
 * @returns MM:SS形式の文字列
 */
const secondToMMSS = (second: number) => {
  const MM =
    second >= 10 * 60
      ? Math.floor(second / 60).toString()
      : second >= 1 * 60
      ? "0" + Math.floor(second / 60).toString()
      : "00";
  const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60);
  return MM + ":" + SS;
};

const App = () => {
  const [state, setState] = useState<State>({
    timeLeft: TIMER_LENGTH.work,
    isTimerOn: false,
    timerMode: "work",
  });

  useEffect(() => {
    return () => {
      clearInterval(timerCountInterval);
    };
  }, []);

  const onButtonClick = () => {
    setState((state) => {
      clearInterval(timerCountInterval);
      if (state.isTimerOn) {
        return {
          ...state,
          timeLeft: TIMER_LENGTH.work,
          timerMode: "work",
          isTimerOn: false,
        };
      }
      timerCountInterval = window.setInterval(() => {
        timerCount();
      }, 1000);
      return { ...state, isTimerOn: true };
    });
  };

  const timerCount = () => {
    setState((state) => {
      if (state.timeLeft <= 0) {
        state = toggleTimerMode(state);
      }
      return { ...state, timeLeft: state.timeLeft - 1 };
    });
  };

  const toggleTimerMode = (state: State): State => {
    const timeLeft =
      state.timerMode === "work" ? TIMER_LENGTH.break : TIMER_LENGTH.work;
    const timerMode = state.timerMode === "work" ? "break" : "work";
    return {
      ...state,
      timeLeft: timeLeft,
      timerMode: timerMode,
    };
  };

  return (
    <>
      <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div>
      <button data-testid="timerButton" onClick={onButtonClick}>
        {state.isTimerOn ? "停止" : "開始"}
      </button>
      <div data-testid="timerMode">
        {state.timerMode === "work" ? "作業" : "休憩"}
      </div>
    </>
  );
};

export default App;
App.test.tsx
import React from "react";
import { act, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";

describe("初期表示", () => {
  test("「25:00」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timeLeft").textContent).toEqual("25:00");
  });
  test("「開始」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timerButton").textContent).toEqual("開始");
  });
  test("「作業」が描画されていること", () => {
    const { getByTestId } = render(<App />);
    expect(getByTestId("timerMode").textContent).toEqual("作業");
  });
});

describe("開始ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押した直後の表示のテスト", () => {
    test("「25:00」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「停止」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerButton").textContent).toEqual("停止");
    });
    test("「作業」が描画されていること", () => {
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });
  describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => {
    test("「25:00」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(999);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
  describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => {
    test("「24:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:59");
    });
  });
  describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => {
    test("「24:58」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:58");
    });
  });
  describe("開始ボタンを押してから25分後の表示のテスト", () => {
    test("「00:00」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(25 * 60 * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("00:00");
    });
    test("「作業」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(25 * 60 * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });
  describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => {
    test("「04:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 1) * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("04:59");
    });
    test("「休憩」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 1) * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("休憩");
    });
  });
  describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => {
    test("「24:59」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("24:59");
    });
    test("「作業」が描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000);
      });
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
  });
});

describe("停止ボタンを押した後の表示のテスト", () => {
  describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => {
    test("「25:00」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「作業」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
    test("停止してから1秒後に「25:00」と描画されていること", () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(2 * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
  describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => {
    test("「25:00」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
    test("「作業」と描画されていること", async () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      expect(getByTestId("timerMode").textContent).toEqual("作業");
    });
    test("停止してから1秒後に「25:00」と描画されていること", () => {
      jest.useFakeTimers();
      const { getByTestId } = render(<App />);
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime((25 * 60 + 2) * 1000);
      });
      userEvent.click(getByTestId("timerButton"));
      act(() => {
        jest.advanceTimersByTime(1000);
      });
      expect(getByTestId("timeLeft").textContent).toEqual("25:00");
    });
  });
});

最終的なテスト結果はこのようになりました。
image.png

途中経過と最終的なコードをGitHubにも上げています。

最後に

今回作ったテストコードは完璧ではありません。
例えば、タイマーの開始と停止が何度も行われた場合にタイマーが正常に作動するかは分かりませんし、タイマーのカウントの表示が常に正しく表示されているかも分かりません。
重要度が高いものをテストしています。

また、テスト駆動開発を実践するにあたって、以下のことが重要だと感じました。

  1. 要件定義を厳密にする
  2. 要件をうまくテストケースに落とし込む

実は今回のテスト駆動開発に挑戦する前に、同じポモドーロタイマーを、実装を先に書いてからテストコードを書く方法で作っています。
テスト駆動開発は、以前に似たようなテストコードを作った経験がないと本当に難しいと思いました。

18
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?