🍏

React でつくるフォーム UI の単体テストと TDD

2023/02/13に公開

はじめに

最近「単体テストの考え方/使い方」を読みました。

https://book.mynavi.jp/ec/products/detail/id=134252

普段フロントエンドエンジニアとして実装をしている中で、自分が作っている単体テストについての言語化をサポートしてくれました。

この記事では、フォーム UI の実装を通して、コンポーネントに対する効果的な単体テストについて考察し、具体的なテストの書き進め方の一例を提示してみます。

最終的な成果物

この記事で実装するフォーム UI の最終的な成果物は codesandbox で確認できるようにしています。右のペインの「Tests」をクリックするとテストも実行できます。

https://codesandbox.io/s/tdd-practice-form-ui-y5rvbc?file=/src/UserForm.test.tsx

注意

この記事は「単体テストの考え方/使い方」(以下、本書)の内容に触れていますが、読んでいなくても理解できるように補足をしているつもりです。それぞれのトピックで、文中に本書の該当ページの番号を「(p123)」のように表示しています(いずれも初版のものです)。

react-testing-library を説明なく使います。とくに user-event は v14 を使っています(14 と v13 以前では使い方が大きく異なります)。

作りたいもの

次のようなユーザ登録のためのフォームコンポーネントを実装します。『ユーザ名』と『生年月日』は必須項目で、『生年月日』は yyyy-MM-dd のフォーマットでないといけないという制約もあります。 API を呼び出す前にフロントエンドでこれらのバリデーションを確認し、不適切な値が入力されているときにはアラートを表示します。


実装するフォーム UI のイメージ


不適切な入力に対して表示されるアラート

テストの方針

効果的な単体テストを作るためにテストの方針を定めます。テストを構成する性質とテストの手法という点で考えてみます。

テストを構成する性質

本書では、良い単体テストを構成する4つの性質(4本の柱)を次のように定めています(p96)。

  • 退行に対する保護
  • リファクタリングへの耐性
  • 迅速なフィードバック
  • 保守のしやすさ

退行に対する保護について考えてみます。退行とは、ある時点で実装していた機能が何らかの変更によって機能しなくなることです。
フォーム UI でよく実装する機能に「○○は必須項目で、空欄のまま送信しようとするとアラートを表示する」や「△△は10文字以下の文字列でなければならない」といったバリデーションがあると思います。「□□がチェックされているときだけ、××は入力できる」といった要素同士が関連する複雑なものも含まれます。入力項目の数が増えれば増えるほど動作確認が大変になり、バグを生む可能性が高くなります。

この実装では、バリデーションの退行への保護を重要視したテストを作ることを目指します[1]

テストの手法

本書では、単体テストの手法として次の3つが紹介されています(p168)。

  • 出力値ベース・テスト
  • 状態ベース・テスト
  • コミュニケーション・ベース・テスト

状態ベース・テストは、処理の実行によってテスト対象の状態がどうなっているかを検査します。フロントエンドのテストにおいては、ユーザのインタラクションによって「○○というテキストが表示されていること」や「△△ボタンが disabled になっていること」などを確認することが該当します。
React のテストでは、 testing-library の jest-dom を使って、次のように書くことができます。

// ○○というテキストが表示されていること
expect(screen.getByText("○○")).toBeInTheDocument();
// △△ボタンが disabled になっていること
expect(screen.getByRole("button", { name: "△△" })).toBeDisabled();

コミュニケーション・ベース・テストは、モックを用いてテスト対象と協力者オブジェクトの間のコミュニケーションを検査します。たとえば、「××ボタンをクリックすると、モックのコールバック関数が呼び出され、引数は□□になる」などを確認することができます。
実際の実装では次のようになります。

const mockSubmit = jest.fn();
render(<Hoge submit={mockSubmit} />);

await user.click(screen.getByRole("button"));

expect(mockSubmit).toBeCalledWith("□□");

出力値ベース・テストは、テスト対象に入力値を渡し、その出力を検査します。フロントエンドでは、 React のカスタムフックのテストに使われることがありますが、状態ベース・テストと組み合わせて使われることになります。(純粋な関数に対しても出力値ベース・テストを使いますが、それはフロントエンドに限った話ではないです。)

この記事では、コンポーネントの状態を検査する状態ベース・テストと、モック関数の呼び出された(もしくは呼び出されなかった)ことを確認するコミュニケーション・ベース・テストを中心に実装していきます。

実装

実装は TDD のステップを辿りながらフォーム UI を作っていきます。 TDD のステップとは次のようなものです[2]

  1. 🟥red: 動作しない(もしくはコンパイルすら通らない)テストを書く
  2. 🟩green: そのテストが通るような最短の実装をする
  3. ♻refactoring: テストが通る状態を保ちながら、実装を改善する

また、実装には次のライブラリを使用しています。

この記事の中ではコンポーネントの見た目に対して言及しませんが、実際の実装では storybook などを用いて見た目も確認しながら実装するとよいと思います。

空のフォームコンポーネントを実装

まずは、空のフォームコンポーネントを作ります。この段階では、入力欄は用意せず、ボタンだけ配置しておきます。(以下、 idclass などのアトリビュートは、挙動を再現する上で必要なもの以外は省略しています。)

UserForm.tsx
type UserInfo = {};

type Props = {
  submit: (data: UserInfo) => Promise<void>;
};

export const UserForm: React.FC<Props> = (props) => {
  const { handleSubmit } = useForm<UserInfo>();

  return (
    <form onSubmit={handleSubmit(props.submit)}>
      <div>
        <div>
          <button type="submit">送信</button>
        </div>
      </div>
    </form>
  );
};

入力欄の追加

『ユーザ名』という入力欄を作ります。まずは、次のようなテストを作ります。 実行フェーズでは「『ユーザ名に"たろう"を入力し、送信する」を実行し、確認フェーズで「引数に"たろう"という文字列が含まれるデータが渡される」ことを確認するコミュニケーション・ベース・テストを行っています。
現時点では実装をしていないので、これは意図して落としているテストです。

UserForm.test.tsx(🟥red)
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserForm } from ".";

const mockSubmit = jest.fn();

// 全体に共通する準備 (Arrange)
beforeEach(() => {
  mockSubmit.mockReset();
});

test("データを入力して送信できる", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);
  const user = userEvent.setup();
  const nameInput = screen.getByRole("textbox", { name: "ユーザ名" });
  const submitButton = screen.getByRole("button", { name: "送信" });

  // 実行 (Act)
  await user.type(nameInput, "たろう");
  await user.click(submitButton);

  // 確認 (Assert)
  expect(mockSubmit).toBeCalledWith({ name: "たろう" });
});

このテストが落ちないように、ラベルが『ユーザ名』の入力欄を追加します。

UserForm.tsx(🟩green)
type UserInfo = {
+  name: string;
};

export const UserForm: React.FC<Props> = (props) => {
  const {
+    register,
    handleSubmit
  } = useForm<UserInfo>();

  return (
    <form onSubmit={handleSubmit(props.submit)}>
      <div>
+        <div>
+          <label htmlFor="user_form-name">ユーザ名</label>
+          <input id="user_form-name" {...register("name")} />
+        </div>
        <div>
          <button type="submit">送信</button>
        </div>
      </div>
    </form>
  );
};
AAAパターンの Act について

本書では、 AAAパターンというテストケースの構造に関する設計手法が紹介されています。この手法ではテストを準備(Arrange)、実行(Act)、確認(Assert)の3つのフェーズに分けて、それぞれのフェーズでやるべきことを明確にしています。

本書で、著者は次のように主張しています(p63)。

実行(Act)フェーズのコードが1行を超す場合は注意が必要
通常、実行(Act)フェーズは1行のコードだけで足りるはずです。もし、実行フェースが複数行になるのであれば、このことは、テスト対象システムで公開されているAPIがきちんと設計されていないことを示唆していることになります。

適切なカプセル化によって実行フェーズを1行を超えないほうがいいというこの主張には、筆者も賛成です。一方、コンポーネントのテストにおいてはこの限りではないと思っています。このセクションで実装したテストのように、入力欄に文字列を入力し送信ボタンをクリックするだけで1行を超えてしまいます。

しかしながら、実行フェーズで何でもかんでもしてしまうのは良くないです。あくまでも必要な操作のみに留めるべきです。
筆者は、「実行フェーズには user オブジェクトを使った操作(await user.xxx();の形式のもの)のみにする」というルールを設けるのが良いのではないかと思っています。 user オブジェクトを使った操作はアプリケーションを使うユーザの動きを再現するものなので、それ以外の操作を含む場合はコンポーネントの責務をカプセル化できていない可能性が高いです。

実行フェーズが1行を超えることについて、本書の著者はこのように述べています(p65)。

実行フェーズを常に1行のみにすることは矯正すべきではないと考えています。しかしながら、実行フェーズが複数行になるのであれば、カプセル化が破綻していないことを噛んがらず確認するようにしてください。

入力欄を必須項目に変更

『ユーザ名』を必須項目に変更します。はじめに、次のようなテストを追加します。確認フェーズに注目すると、「submit に渡した関数が呼ばれていないこと」を確認していることがわかります(コミュニケーションがなかったことを確認しているコミュニケーション・ベース・テストです)。
今の実装では入力した文字列に対するバリデーションがないので、このテストは落ちます。

User.test.tsx(🟥red)
test("ユーザ名を入力しないと送信できない", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);
  const user = userEvent.setup();
  const submitButton = screen.getByRole("button", { name: "送信" });

  // 実行 (Act)
  await user.click(submitButton);

  // 確認 (Assert)
  expect(mockSubmit).not.toBeCalled();
});

『ユーザ名』に入力がないときにエラーになるようにします。
react-hook-form を使うと、次のように実装すると入力が空文字列のときにバリデーションエラーになるように設定できます。これでテストも通るようになります。

UserForm.tsx(🟩green)
        <div>
          <label htmlFor="user_form-name">ユーザ名</label>
-          <input id="user_form-name" />
+          <input
+            id="user_form-name"
+            {...register("name", {
+              required: "ユーザ名が入力されていません。"
+            })}
+          />
        </div>

エラーメッセージも表示するようにしましょう。テストを次のように変更します。『ユーザ名』が空文字列の状態で送信しようとすると、「ユーザ名が入力されていません。」というエラーメッセージが出ることを期待する状態ベース・テストです。現時点ではエラーメッセージの表示を実装していないのでテストが落ちます。

UserForm.test.tsx(🟥red)
  // 実行 (Act)
  await user.click(submitButton);

  // 確認 (Assert)
  expect(mockSubmit).not.toBeCalled();
+  const alertTextBox = await screen.queryByRole("alert");
+  expect(alertTextBox).toHaveTextContent("ユーザ名が入力されていません。");
}

バリデーションエラーが発生したときに気付けるように表示しましょう。 react-hook-form では、バリデーションエラーを formState.errors でアクセスできるようになっています。

UserForm.tsx(🟩green)
export const UserForm: React.FC<Props> = (props) => {
-  const { register, handleSubmit } = useForm<UserInfo>();
+  const { register, handleSubmit, formState: { errors } } = useForm<UserInfo>();

  return (
    ...

        <div>
          <label htmlFor="user_form-name">ユーザ名</label>
          <input
            id="user_form-name"
            {...register("name", {
              required: "ユーザ名が入力されていません。"
            })}
          />
+          {errors.name?.message && <p role="alert">{errors.name.message}</p>}
        </div>

これで必須項目が入力できていないときの挙動の実装とテストが完成しました。

必須ではない項目の追加

入力が必須ではない『ニックネーム』という項目を追加しましょう。
まずは次のように、テストに『ニックネーム』の入力を追加してテストが落ちることを確認します。

UserForm.test.ts(🟥red)
test("データを入力して送信できる", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);
  const user = userEvent.setup();
  const nameInput = screen.getByRole("textbox", { name: "ユーザ名" });
+  const nicknameInput = screen.getByRole("textbox", { name: "ニックネーム" });
  const submitButton = screen.getByRole("button", { name: "送信" });

  // 実行 (Act)
  await user.type(nameInput, "たろう");
+  await user.type(nicknameInput, "たろちゃん");
  await user.click(submitButton);

  // 確認 (Assert)
-  expect(mockSubmit).toBeCalledWith({ name: "たろう" });
+  expect(mockSubmit).toBeCalledWith({ name: "たろう", nickname: "たろちゃん" });
});

コンポーネントに『ニックネーム』の入力を追加します。先ほど変更したテストが通るようになります。

UserForm.tsx(🟩green)
          {errors.name?.message && <p role="alert">{errors.name.message}</p>}
        </div>
+        <div>
+          <label htmlFor="user_form-nickname">ニックネーム</label>
+          <input id="user_form-nickname" {...register("nickname")} />
+          {errors.nickname?.message && <p role="alert">{errors.nickname.message}</p>}
+        </div>
        <div>
          <button type="submit">送信</button>

『ニックネーム』が必須項目になっていないことを確認するテストを追加しましょう。ただし、実装上、すでに『ニックネーム』は必須項目ではないので、 green の状態のままです。

UserForm.test.tsx(🟩green)
test("ニックネームは入力しなくても送信できる", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);
  const user = userEvent.setup({);
  const nameInput = screen.getByRole("textbox", { name: "ユーザ名" });
  const submitButton = screen.getByRole("button", { name: "送信" });

  // 実行 (Act)
  await user.type(nameInput, "たろう");
  await user.click(submitButton);

  // 確認 (Assert)
  expect(mockSubmit).toBeCalled();
});
必須ではないことのテストは「取るに足らないテスト」か

あらゆるバリデーションに対して、そのバリデーションがないことをテストしようとすると「1文字以上でも送信できる」「2文字以上でも送信できる」「全角数字を含んでいても送信できる」...と無限にテストを追加しないといけなくなります。これは明らかに無駄です。本書では、退行に対する保護ができないテストを「取るに足らないテスト」と呼んでいます(p117)。

『ニックネーム』の項目では、必須項目ではないことをテストしています。言い換えるとバリデーションがないことをテストしています。このテストが必要かどうかは場合によると思います。
たとえば、必須項目が一つもないフォームコンポーネントなら、そもそも必須項目に対するバリデーションを考慮していません。そのため、誤ってバリデーションを付与してしまい退行が発生するリスクは小さくなるでしょう。このような場合は、必須項目でないことのテストは書かなくても問題ないかもしれません。
今回のように必須と必須でない項目とが混ざっている場合はどうでしょう。必須項目でないことをテストすることで、変更時に誤ったバリデーションを付与してしまうことによる退行を防げるかもしれません。また、テストそのものが「意図して必須のバリデーションを付与していない」ことを明示するドキュメントとしての役割も果たしてくれます。

いずれのケースでも、どの程度までテストを書くかの方針を決めておくと、より大きな効果が得られるテストになると思います。

テストのリファクタリング

TDD のステップにおけるリファクタリングは、プロダクションコードのリファクタリングを指しますが、このステップで行うのはテストコードのリファクタリングです。

この調子で入力項目を増やしていくと、一つ一つのテストコードの保守が大変になります。
たとえば、必須項目の『年齢』『住所』『電話番号』『職業』を追加したとします。「『ニックネーム』は入力しなくても送信できる」というテストは、これらの項目に対しての入力を行わないので落ちるようになります。くわえて、項目の数が増えるのに伴ってテストの数も増えるのでテストの保守が大変になります。

そこで、次のようなヘルパー関数 inputAndSubmitData を導入します。
項目に対するテキストを指定すると、入力欄にそのテキストを入力します。 null を指定すると入力をしません。何も指定しない(undefined となる)とデフォルトの値を入力します。

UserForm.text.tsx(♻refactoring)
type UserFormInput = {
  ユーザ名: string;
  ニックネーム: string;
};

const defaultUserInfoInput: UserFormInput = {
  ユーザ名: "はなこ",
  ニックネーム: "はなちゃん"
} as const;

const inputAndSubmitData = async (user: UserEvent, data: PartialNull<UserFormInput>) => {
  if (data["ユーザ名"] !== null) {
    const value = data["ユーザ名"] || defaultUserInfoInput["ユーザ名"];
    await user.type(screen.getByRole("textbox", { name: "ユーザ名" }), value);
  }

  if (data["ニックネーム"] !== null) {
    const value = data["ニックネーム"] || defaultUserInfoInput["ニックネーム"];
    await user.type(
      screen.getByRole("textbox", { name: "ニックネーム" }),
      value
    );
  }

  await user.click(screen.getByRole("button", { name: "送信" }));
};

次のように使います。

await inputAndSubmitData({}); // すべての項目にデフォルトの値を入力
await inputAndSubmitData({ ユーザ名: "ダニエル" }); // 『ユーザ名』に「ダニエル」を、それ以外の項目にデフォルト値を入力
await inputAndSubmitDatA({ ニックネーム: null }; // 『ニックネーム』を空欄に、それ以外の項目にデフォルト値を入力

実際に「『ニックネーム』は入力しなくても送信できる」のテストを inputAndSubmitData を使って書き換えると次のようになります。これで、項目が増えるたびにこのテストを修正する必要がなくなりました。
もともとのテストは『ニックネーム』ではなく『ユーザ名』を操作していて、ひと目で『ニックネーム』に対するテストであるとは分かりづらかったです。この修正の副産物として、「『ニックネーム』を空欄にする」という意図が伝わりやすくなりました。

UserForm.text.tsx(♻refactoring)
test("ニックネームは入力しなくても送信できる", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);
  const user = userEvent.setup();
-  const nameInput = screen.getByRole("textbox", { name: "ユーザ名" });
-  const submitButton = screen.getByRole("button", { name: "送信" });

  // 実行 (Act)
-  await user.type(nameInput, "たろう");
-  await user.click(submitButton);
+  await inputAndSubmitData(user, { ニックネーム: null });

  // 確認 (Assert)
  expect(mockSubmit).toBeCalled();
});
ヘルパー関数は便利さよりも自明さを優先したい

ヘルパー関数があるおかげでテストが楽になりましたが、ヘルパー関数自体が大きくなり複雑化しないように気を付ける必要があります。
たとえば、「○○に100以上の数字が入力されそうになったら、入力を99にする」のようなロジックをヘルパー関数に実装すると、個々のテストは楽になるかもしれません。しかし、テストを見たときの処理が自明ではなくなります。
非自明なテストは保守のしやすさを下げます。ヘルパー関数は、あくまでもシンプルな処理だけに留めるようしたほうが良いです。

入力値のバリデーションが必要な項目

『生年月日』という項目を追加します。生年月日は yyyy-MM-dd のフォーマットで入力される文字列を想定しています。
まずは『データを入力して送信できる』のテストを『生年月日』に対応させるようにテストを修正します。

UserForm.test.tsx(🟥red)
type UserFormInput = {
  ユーザ名: string;
  ニックネーム: string;
+  生年月日: string;
};

const defaultUserInfoInput: UserFormInput = {
  ユーザ名: "はなこ",
  ニックネーム: "はなちゃん",
+  生年月日: "2000-05-16",
} as const;

const inputAndSubmitData = async (user: UserEvent, data: PartialNull<UserFormInput>) => {
  if (data["ユーザ名"] !== null) {
    const value = data["ユーザ名"] || defaultUserInfoInput["ユーザ名"];
    await user.type(screen.getByRole("textbox", { name: "ユーザ名" }), value);
  }

  if (data["ニックネーム"] !== null) {
    const value = data["ニックネーム"] || defaultUserInfoInput["ニックネーム"];
    await user.type(screen.getByRole("textbox", { name: "ニックネーム" }), value);
  }

+  if (data["生年月日"] !== null) {
+    const value = data["生年月日"] || defaultUserInfoInput["生年月日"];
+    await user.type(screen.getByRole("textbox", { name: "生年月日" }), value);
+  }

  await user.click(screen.getByRole("button", { name: "送信" }));
};

test("データを入力して送信できる", async () => {
  // 準備 (Arrange)
  render(<UserForm submit={mockSubmit} />);

  // 実行 (Act)
  await inputAndSubmitData({
    ユーザ名: "たろう",
    ニックネーム: "たろちゃん",
+    生年月日: "1990-11-25"
  });

  // 確認 (Assert)
  expect(mockSubmit).toBeCalledWith({
    name: "たろう",
    nickname: "たろちゃん",
+    birthday: "1990-11-25"
  });
});

『生年月日』の項目を追加します。次のように実装すると、テストが通るようになります。

UserForm.tsx(🟩green)
          {errors.nickname?.message && (<p role="alert">{errors.nickname.message}</p>)}
        </div>
+        <div>
+          <label htmlFor="user_form-birthday">生年月日</label>
+          <input id="user_form-birthday" {...register("birthday")} />
+          {errors.birthday?.message && (<p role="alert">{errors.birthday.message}</p>)}
+        </div>
        <div>
          <button type="submit">送信</button>
        </div>

不適切なフォーマットの文字列が入力されたときにエラーになるようにします。適切な文字列と不適切な文字列を入力したときのそれぞれのテストケースを追加します。また、生年月日を必須項目としたいので、入力しないと送信できないというチェックも追加しました。[3]
この時点ではバリデーションが一切ないので、「適切な日付を入力すると送信できる」のテストだけが通ります。

UserForm.tsx(🟥red)
describe("生年月日のバリデーション", () => {
  test("入力しないと送信できない", async () => {
    // 準備 (Arrange)
    render(<UserForm submit={mockSubmit} />);
    const user = userEvent.setup();

    // 実行 (Act)
    await inputAndSubmitData(user, { 生年月日: null });

    // 確認 (Assert)
    expect(mockSubmit).not.toBeCalled();
    const alertTextBox = await screen.findByRole("alert");
    expect(alertTextBox).toHaveTextContent("生年月日が入力されていません。");
  });

  test.each([["2023-02-11"], ["1990-01-01"], ["1859-12-31"]])(
    "適切な日付を入力すると送信できる",
    async (inputText) => {
      // 準備 (Arrange)
      render(<UserForm submit={mockSubmit} />);
      const user = userEvent.setup();

      // 実行 (Act)
      await inputAndSubmitData(user, { 生年月日: inputText });

      // 確認 (Assert)
      expect(mockSubmit).toBeCalled();
    }
  );

  test.each([["hello world!"], ["2049-05-32"], ["2021/10/15"]])(
    "不適切な日付を入力すると送信できない",
    async (inputText) => {
      // 準備 (Arrange)
      render(<UserForm submit={mockSubmit} />);
      const user = userEvent.setup();

      // 実行 (Act)
      await inputAndSubmitData(user, { 生年月日: inputText });

      // 確認 (Assert)
      expect(mockSubmit).not.toBeCalled();
      const alertTextBox = await screen.findByRole("alert");
      expect(alertTextBox).toHaveTextContent("yyyy-MM-dd の形式で入力してください。");
    }
  );
});

『生年月日』の項目にバリデーションを追加します。『ユーザ名』の項目と同様に required を設定して必須項目とし、 validate で入力した値を正規表現にマッチするか検査します[4]。バリデーションで落ちた場合は submit に渡した関数が呼ばれずにエラー文が表示されるので、テストが通るようになります。

UserForm.tsx(🟩green)
        <div className="input-unit">
          <label htmlFor="user_form-birthday">生年月日</label>
          <input
            id="user_form-birthday"
            {...register("birthday", {
+              required: "生年月日が入力されていません。",
+              validate: (inputText) =>
+                /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/.test(inputText) ||
+                "yyyy-MM-dd の形式で入力してください。"
            })}
          />
          {errors.birthday?.message && (
            <p role="alert">{errors.birthday.message}</p>
          )}
        </div>

現状のテストでは、入力欄への入力を通して日付のフォーマットの判定ロジックが正しく動いてそうかを状態ベース・テストで検査しています。このようなケースは、判定ロジックだけ分けてテストすることで、コンポーネントのテストを簡潔にすることができます。
判定ロジックを isValidDateText という関数として次のように切り出します。判定ロジックの正しさは、 isValidDateText に対する出力値ベース・テストで検査します。

isValidDateText.ts(♻refactoring)
export const isValidDateText = (text: string) =>
  /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/.test(text);
isValidDateText.test.ts
test.each([["2023-02-11"], ["1990-01-01"], ["1859-12-31"]])(
  "yyyy-MM-dd のフォーマットなら true になる",
  (inputText) => {
    // 実行 (Act)
    const result = isValidDateText(inputText);

    // 確認 (Assert)
    expect(result).toBe(true);
  }
);

test.each([["hello world!"], ["2049-05-32"], ["2021/10/15"]])(
  "yyyy-MM-dd のフォーマット以外なら false になる",
  (inputText) => {
    // 実行 (Act)
    const result = isValidDateText(inputText);

    // 確認 (Assert)
    expect(result).toBe(false);
  }
);

このように判定ロジックの実装を分けてテストもしておくと、コンポーネントのテストでは判定ロジックが適用されていることの確認ができれば十分になります。

UserForm.test.tsx(♻refactoring)
  test("適切な日付を入力すると送信できる", async () => {
    // 準備 (Arrange)
    render(<UserForm submit={mockSubmit} />);
    const user = userEvent.setup();

    // 実行 (Act)
    // 適切な入力の一例だけテストする
    await inputAndSubmitData(user, { 生年月日: "2023-02-11" });

    // 確認 (Assert)
    expect(mockSubmit).toBeCalled();
  });

  test("不適切な日付を入力すると送信できない", async () => {
    // 準備 (Arrange)
    render(<UserForm submit={mockSubmit} />);
    const user = userEvent.setup();


    // 実行 (Act)
    // 不適切な入力の一例だけテストする
    await inputAndSubmitData(user, { 生年月日: "2023-02-32" });

    // 確認 (Assert)
    expect(mockSubmit).not.toBeCalled();
    const alertTextBox = await screen.findByRole("alert");
    expect(alertTextBox).toHaveTextContent(
      "yyyy-MM-dd の形式で入力してください。"
    );
  });

以上で実装は完了しました。

まとめ

フォーム UI の機能とそのテストの実装をし、効果的なテストの作り方について考えました。実務でも似た流れで実装を行っていますが、コンポーネントの仕様変更に対して退行が発生しにくい状況を作ることができているように感じています。

本書を読んで、自分の実施していたテストはどういう意味を持っているのか、どういう点が改善できるかの言語化につながりました。テストに対する解像度が高くなったと思います。
今回実装した例はとてもシンプルでしたが、実際に扱う問題はより複雑です。シチュエーションに合わせてよりよい方法に改善していきたいです。

脚注
  1. 本書では、退行に対する保護と迅速なフィードバックの両立は不可能なので、いいバランスになるよう調整することが推奨されています。 ↩︎

  2. 参考: https://service.shiftinc.jp/column/4654/#ttl1 ↩︎

  3. 空文字列を不適切な日付として扱うことで「不適切な日付を入力すると送信できない」のテストに統合することもできます。 ↩︎

  4. validate 内の正規表現は、簡潔さのために、「2023-02-29」を許容するように月ごとの日数の違いは検査していません。参考: https://www.javadrive.jp/regex-basic/sample/index6.html ↩︎

Discussion