MUI TextField のテストを鳥渡だけ書く

2021.11.22

Material UI の TextField と React Hook Form (RHF) を組み合わせて利用しているのですが, invalidな入力に対して正しいエラーメッセージを出せてるのか私は常に心配です. 一人暮らしする子を見送る親の如く心を痛め続けています. そういった不安を取り除くためにはやはり試練(テスト)を与えるのが手っ取り早いです.

ということでTextField, RHFそしてTesting Libraryを利用してこの不安のタネを吐き出しましょうか. 確認したいことは下記の3つになります.

  • 要素を正しく取得できるか
  • 入力が正しくできること
  • バリデーションが正しく行われているか

environment

  • React: 17.02
  • React Hook Form: 7.19.5
  • @mui/material: 5.1.1,
  • jest: 27.3.1
  • @testing-library/jest-dom 5.15.0
  • @testing-library/react 12.1.2

基本のカタチ

まずはTextFieldに値を入力できることを確認していきます. labelが付与されているTextFieldからHTML input要素を探し出し, 入力ができるかをチェックします. TextFieldはこのような形になりますよね.

<Controller
  name="greeting"
  control={control}
  rules={{}}
  render={({ field }) => (
    <TextField
      id="greeting"
      label="greeting"
      {...field}
      error={Boolean(errors?.greeting)}
      helperText={errors?.greeting?.message}
    />
  )}
/>

アクセシビリティ・ツリーの要素一覧からinput要素を探し出すことができます. あとは fireEvent を呼び出して入力を行い, それに対してアサーションを書いて... アクセシビリティ的な意味合いでも素敵なテストが出来上がります.

/**
 * @jest-environment jsdom
 */
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";

test('greeting', () => {
  render(<Greeting />);
  const input = screen.getByRole("textbox", { name: "greeting" });

  fireEvent.input(textbox, {
    target: {
      value: "test",
    },
  });
  expect(textbox).toHaveValue("test");
  expect(textbox.id).toBe("greeting");
});

少し引っかかるかもしれないのが getByRole の第1引数に適切なロールを渡す部分です.
textboxであることが多いですが, input要素の属性として type=number を渡していると spinbutton になったりします.

なので適切な要素をいちいちチェックする...必要もなく, Testing Libraryでは getByRole で値が見つからない場合には素晴らしいログを出してくれます. ロールとHTML要素のマッピングを返してくれるのです!! 駅徒歩1分くらいの便利さですね.

// ログのサンプル
--------------------------------------------------
  textbox:

  Name "greeting":
  <input
    aria-invalid="false"
    class="MuiOutlinedInput-input MuiInputBase-input css-1t8l2tu-MuiInputBase-input-MuiOutlinedInput-input"
    id="greeting"
    name="greeting"
    type="text"
    value=""
  />

なのでこれをベースに値を書き換えたりロールを調整したりってことができます.

TextFieldにLabelを指定しない場合 w/aria-label

Labelを指定しないと getByRole で要素を拾うのが難しくなるけど, labelを付与するとデザインが...というケースもあります.
そんな場合はinput要素に aria-label を付与しましょう. これだけでテストも楽々かけてボイスオーバーくんも感涙して声を上げます. テストの中身は前節と同じなので省略しますね.

<Controller
  name="greeting"
  control={control}
  rules={{}}
  render={({ field }) => (
    <TextField
      id="greeting"
      inputProps={{
        "aria-label": "greeting",
      }}
      {...field}
      error={Boolean(errors?.greeting)}
      helperText={errors?.greeting?.message}
    />
  )}
/>

TextFieldにLabelを指定しない場合

Testing Libraryのqueriesで拾えない要素がある場合です. その時は Document.querySelector() を経由して要素を拾い上げてテストを書きます.

TextField に対してLabelを指定しない場合を想定してテストを書いていきましょう.
ポイントとしては取得した要素に対してnullチェックを入れることです. というのも container.querySelector はHTML要素またはnullを返すため型チェックで怒られますし, nullの時はテストがうまく通らないものなので, expect関数でnullではないことを確認した上で条件分岐の中でイベントを発火するようにしています.

/**
 * @jest-environment jsdom
 */
import "@testing-library/jest-dom";
import { fireEvent, render } from "@testing-library/react";

test("greeting", () => {
  const { container } = render(<Wrapper />);
  const input = container.querySelector(`input[name="greeting"]`);

  expect(input).not.toBe(null);
  if (input != null) {
    fireEvent.input(input, { target: { value: "test" }});
  });
   expect(input).toHaveValue("test");
}

ちょっと冗長な書き方でアクセシビリティが陰に身を潜めてしまうのでできれば getRole を利用した書き方が望ましいですよね.

バリデーションとエラーメッセージのテスト

TextFieldで「helperTextがメッセージを出しているか」と「errorがtrue(aria-invalid=true)になっているか」をテストしていきます. 基本的な書き方は先ほどと同じですが, アサーションの部分を waitFor で包み込みます. ここだけがちょっと違います.

<Controller
  name="greeting"
  control={control}
  rules={{
    required: '必須項目です',
  }}
  render={({ field }) => (
    <TextField
      id="greeting"
      label="greeting"
      {...field}
      error={Boolean(errors?.greeting)}
      helperText={errors?.greeting?.message}
    />
  )}
/>

例えばTextFieldがこのような場合には入力欄が空文字の時に「必須項目です」とエラーメッセージが出ている必要がありますね. これを検証するためにテストを書いていきましょう.

test("空文字の時にエラーが出る", async () => {
  const textbox = screen.getByRole("spinbutton", { name: "進学予定年" });
  fireEvent.input(textbox, { target: { value: "" } });
  fireEvent.submit(screen.getByRole("button", { name: "submit" }));

  await waitFor(() => {
    expect(
      screen
        .getAllByText("必須項目です")
        .find((x) => x.id === "greeting-helper-text")
    ).toBeInTheDocument();

    expect(textbox).toHaveAttribute("aria-invalid", "true");
  });
});

アサーション部分を waitFor で優しく包み込んだなら, まずはhelperTextが出ているかを確認します. そのために getAllByText でメッセージを拾い出し, 適切なIDが付与されているかで確認します. 最後にaria-invalidがtrueになっていることをチェックしたらバリデーションが正しく動いていることを担保できますね!!!!!

さいごに

テストの書き方1つとっても便利な世の中になりました. いいですね. またよりよ言う方法があったら是非とも教えてください.

references