React-Hook-Formとzodを使ってバリデーションのテストを行う

2023年07月12日公開

React-Hook-Form で作成した入力フォームにバリデーションエラーが表示されることのテストを行います。

開発環境

node: 18.14.0
react: 18.2.0
typescript: 5.1.6
vite: 4.4.2
jest: 29.6.1
testing-library/react: 14.0.0
react-hook-form: 7.45.1
zod: 3.21.4
storybook: 7.0.26

vite-cli から react-ts のテンプレートを使って react 環境を作りました。

コード

https://github.com/diskszk/testing-sandbox/tree/main/src/rhf-zod-validations

仕様

フィールドの最大・最小文字数を定義します。

  • タイトル
    • 必須 ( 最小 1 文字 )
    • 最大 25 文字

入力フォームの実装

import { useForm } from "react-hook-form";

export const CreatePostForm: React.FC = () => {
  const {
    register,
    formState: { errors },
  } = useForm({
    defaultValues: {
      title: "",
    },
  });

  return (
    <div>
      <form>
        <input
          type="text"
          aria-label="タイトル"
          aria-invalid={!!errors.title}
          {...register("title")}
        />
        // バリデーションエラー時に表示するエラーメッセージ
        {errors.title && (
          <p role="alert">
            {errors.title?.message}
          </p>
        )}
      </form>
    </div>
  );
};

この状態だとバリデーションルール、エラーメッセージが定義されていないため、上記の仕様をもとに、 zod でバリデーションを作っていきます。

import z from "zod";

const schema = z
  .object({
    title: z
      .string()
      .min(1, "タイトルは必ず入力してください。")
      .max(25, "タイトルは25文字以内で入力してください。")
  })
  .required()
  .strict();

type Input = z.infer<typeof schema>;

先ほど作成した入力フォームに追加します。
useForm のオプションに {mode: "onBlur"} を追加し、フォーカスが外れたときにバリデーションをかけるようにします。

export const CreatePostForm: React.FC = () => {
  const errorMessageId = useId();

  const {
    register,
    formState: { errors },
  } = useForm<Input>({
    resolver: zodResolver(schema),
    defaultValues: {
      title: "",
    },
    mode: "onBlur", 
  });

  return (
    <div>
      <form>
        <label>
          タイトル:
          <input
            type="text"
            aria-label="タイトル"
            aria-errormessage={errorMessageId}
            aria-invalid={!!errors.title}
            {...register("title")}
          />
          {errors.title && (
            <p role="alert" id={errorMessageId}>
              {errors.title?.message}
            </p>
          )}
        </label>
      </form>
    </div>
  );
};

また、上記 input タグの aria-errormessage と p タグの id 要素に生成した id を含ませることで、input 要素とエラー文言要素を紐づかせ、テストコードで toHaveErrorMessage(errorMessage) を参照できるようにします。こちらの記事を参考にさせていただいております。

https://zenn.dev/takepepe/articles/useid-for-a11y

Story の作成

Storybook を使って stories を作成します。
$ npx storybook@latest init とコマンド入力すると Would you like to install it? と聞かれるので y と入力して、必要なライブラリをインストールします。
すべてインストールが済んだら Storybook の準備は完了です。

import type { Meta, StoryObj } from "@storybook/react";
import { CreatePostForm } from "./CreatePostForm";

const meta: Meta<typeof CreatePostForm> = {
  component: CreatePostForm,
};

export default meta;

type Story = StoryObj<typeof CreatePostForm>;

export const EmptyTitle: Story = {
  play: async ({
    canvasElement,
  }: {
    canvasElement: HTMLElement;
  }): Promise<void> => {
    const canvas = within(canvasElement);

    // タイトル入力欄にフォーカスを当てる
    await canvas.getByRole("textbox", { name: "タイトル" }).focus();

    // 何も入力せずにフォーカスを外す
    await userEvent.tab();
  },
};

テスト作成

Storybook をテストに使うので、次のライブラリを追加します。
$ yarn add -D @storybook/jest @storybook/testing-library

テストファイルを作成します。

import { render, screen, waitFor } from "@testing-library/react";
import { composeStories } from "@storybook/react";
import * as stories from "./CreatePostForm.stories";

test("タイトルが入力されていない場合、エラーメッセージを表示する", async () => {
  const { EmptyTitle } = composeStories(stories);

  const { container } = render(<EmptyTitle />);

  await EmptyTitle.play({ canvasElement: container });

  const title = screen.getByRole("textbox", { name: "タイトル" });

  await waitFor(() => {
    expect(title).toBeInvalid();
  });
  expect(title).toHaveErrorMessage("タイトルは必ず入力してください。");
});

テストの実行結果は Pass となります。

testOK.png

同じ要領で 26 文字入力された場合のテストを書いていきます。

export const OverTitleLength: Story = {
  play: async ({
    canvasElement,
  }: {
    canvasElement: HTMLElement;
  }): Promise<void> => {
    const canvas = within(canvasElement);

    await userEvent.type(
      canvas.getByRole("textbox", { name: "タイトル" }),
      "A".repeat(26)
    );

    await userEvent.tab();
  },
};
test("タイトルに26文字入力された場合、エラーメッセージを表示する", async () => {
  const { OverTitleLength } = composeStories(stories);
  const { container } = render(<OverTitleLength />);

  await OverTitleLength.play({ canvasElement: container });

  const title = screen.getByRole("textbox", { name: "タイトル" });

  await waitFor(() => {
    expect(title).toBeInvalid();
  });
  expect(title).toHaveErrorMessage("タイトルは25文字以内で入力してください。");
});

テストの実行結果は Pass となります。

testOkBoth.png