[React]JestとReact Testing Libraryでフォームをテストしてみた

## はじめに
最近Reactのテストに挑戦しているところで、JestとReact Testing Libraryを使ってテストを書いてみて、少し躓いてしまったので、備忘録として残そうと思います...
触りはじめて間もないため、間違い・認識違いあればご指摘いただけると助かります！

## 環境
下記のDocker開発環境にて行います。
https://qiita.com/mkthrkw/items/30115c9ac54c2204faef

## テスト対象のコンポーネント
よくある react-hook-form と zod を使ったログイン画面のフォームで
別ファイルへカスタムフックとしてロジックを切り出しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/6fe5f35d-1fe8-b740-9228-ec3ca57b0359.png)


```typescript: Form.tsx
'use client';

import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod';
import { AuthSchemaType, authSchema } from "../schema";
import { useLoginAction } from "../hooks/useLoginAction";

export function LoginForm() {

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<AuthSchemaType>(
    {
      mode: 'onBlur',
      resolver: zodResolver(authSchema)
    }
  );

  const { onSubmit } = useLoginAction();

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)} className="card-body">
        <div className="form-control">
          <label className="label">
            <span className="label-text">Email</span>
          </label>
          <input
            {...register("email")}
            placeholder="email"
            className="input input-bordered w-full text-base-content"
          />
          {errors.email && <p className="text-error text-xs mt-1">{errors.email.message}</p>}
        </div>
        <div className="form-control">
          <label className="label">
            <span className="label-text">Password</span>
          </label>
          <input
            {...register("password")}
            type="password"
            placeholder="password"
            className="input input-bordered w-full text-base-content"
          />
          {errors.password && <p className="text-error text-xs mt-1">{errors.password.message}</p>}
        </div>
        <div className="form-control">
          <label className="label cursor-pointer justify-end gap-4">
            <span className="label-text">Remember me</span>
            <input
              {...register("rememberMe")}
              type="checkbox"
              className="toggle toggle-primary"
            />
          </label>
        </div>
        <div className="form-control mt-6">
          <button className="btn btn-secondary" disabled={isSubmitting}>Login</button>
        </div>
      </form>
    </>
  );
}
```

## テスト全体
先に全体になります。
```typescript:Form.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { LoginForm } from "../components/Form";
import '@testing-library/jest-dom';

const mockOnSubmit = jest.fn();
jest.mock('../hooks/useLoginAction', () => ({
  useLoginAction: jest.fn(() => ({
    onSubmit: mockOnSubmit,  // useLoginActionから返されるonSubmitはmockOnSubmit
  })),
}));

describe('Form test', () => {

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

  const setUp = () => {
    render(<LoginForm />);
    const emailInput = screen.getByPlaceholderText('email');
    const passwordInput = screen.getByPlaceholderText('password');
    const rememberMeInput = screen.getByLabelText('Remember me');
    const submitButton = screen.getByRole('button',{name:'Login'});
    return { emailInput, passwordInput, rememberMeInput, submitButton };
  };

  test('各要素が正しく読み込まれる', () => {
    const { emailInput, passwordInput, rememberMeInput, submitButton } = setUp();
    expect(emailInput).toBeInTheDocument();
    expect(emailInput).toHaveValue('');
    expect(passwordInput).toBeInTheDocument();
    expect(passwordInput).toHaveValue('');
    expect(rememberMeInput).toBeInTheDocument();
    expect(rememberMeInput).not.toBeChecked();
    expect(submitButton).toBeInTheDocument();
    expect(submitButton).not.toBeDisabled();
  });

  test('空のフィールドでフォームを送信した場合にバリデーションエラーが表示される', async () => {
    const { submitButton } = setUp();
    fireEvent.click(submitButton);
    await waitFor(() => {
      expect(screen.getByText('メールアドレスの形式で入力してください')).toBeInTheDocument();
      expect(screen.getByText('8文字以上で入力してください')).toBeInTheDocument();
    });
  });

  test('無効なメールアドレスを入力した時にバリデーションエラーが表示される', async () => {
    const { emailInput } = setUp();
    fireEvent.change(emailInput, { target: { value: '無効なメールアドレス' } });
    fireEvent.blur(emailInput);
    await waitFor(() => {
      expect(screen.getByText('メールアドレスの形式で入力してください')).toBeInTheDocument();
    });
  });

  test('無効なパスワードを入力した時にバリデーションエラーが表示される', async () => {
    const { passwordInput } = setUp();
    fireEvent.change(passwordInput, { target: { value: 'pw' } });
    fireEvent.blur(passwordInput);
    await waitFor(() => {
      expect(screen.getByText('8文字以上で入力してください')).toBeInTheDocument();
    });
  });

  test('データを入力してボタンを押し、onSubmitで入力データを処理', async () => {
    const { emailInput, passwordInput, rememberMeInput, submitButton } = setUp();
    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(rememberMeInput);
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(screen.queryByText('メールアドレスの形式で入力してください')).not.toBeInTheDocument();
      expect(screen.queryByText('8文字以上で入力してください')).not.toBeInTheDocument();
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
        rememberMe: true,
      },expect.any(Object));
    });
  });

});
```

## テストを書いてみる
JESTやTesting Libraryの導入に関しては割愛します！
まずは書きながら調べてみようということでサンプルを見ながら。


### 前準備1

コピペばっかりだと、コンポーネント変更した際の修正大変なので、setUp関数を用意しました。
renderでログインフォームを読み込んで、getByで要素を取得。
「get」、「find」、「query」とクエリがあるようですが、存在しているべき要素の取得なのでgetByを使いました。
存在しないことを確認、みたいなパターンはqueryを使うようです。

https://testing-library.com/docs/queries/about/#types-of-queries

```typescript
  const setUp = () => {
    render(<LoginForm />);
    const emailInput = screen.getByPlaceholderText('email');
    const passwordInput = screen.getByPlaceholderText('password');
    const rememberMeInput = screen.getByLabelText('Remember me');
    const submitButton = screen.getByRole('button',{name:'Login'});
    return { emailInput, passwordInput, rememberMeInput, submitButton };
  };
```

### 前準備2

カスタムフック自体は別でテストするので、```useLoginAction```はMockを使います。

https://jestjs.io/ja/docs/jest-object#jestmockmodulename-factory-options

jest.mockオブジェクトを使います。
```typescript
jest.mock(moduleName, factory, options);
// moduleName: モックするモジュールやファイルのパスを指定します。相対パスや、importやrequireで指定するようなパスです。
// factory（任意）: モックモジュールの「挙動」を定義する関数です。この関数が指定されない場合、モジュール全体（すべてのエクスポート）がjest.fn()で置き換えられます。
// options（任意）: 特別なオプションを指定する場合に使います。多くの場合、省略可能です。
```

まずはmockOnSubmitとしてモック関数を定義します。
第2引数のfactoryでuseLoginActionから返されるonSubmit関数はmockOnSubmitとなるように定義します。
これで、mockOnSubmitでフォームからの呼び出し履歴や引数、返り値などを追跡できるようになりました。

```typescript
const mockOnSubmit = jest.fn();
jest.mock('../hooks/useLoginAction', () => ({
  useLoginAction: jest.fn(() => ({
    onSubmit: mockOnSubmit,  // useLoginActionから返されるonSubmitはmockOnSubmit
  })),
}));
```



### 要素読み込みテスト

フォームが問題なく読み込み出来るか、下記4つの初期状態をチェック！

①Emailフィールド：メールアドレス形式じゃないとダメ！
②Passwordフィールド：パスワードは8文字以上で！
③Remember me：チェックを入れてリフレッシュトークンを使うか選択
④Loginボタン：フォーム送信ボタン

ここはすんなり！

```typescript
  test('各要素が正しく読み込まれる', () => {
    const { emailInput, passwordInput, rememberMeInput, submitButton } = setUp();
    expect(emailInput).toBeInTheDocument();
    expect(emailInput).toHaveValue('');
    expect(passwordInput).toBeInTheDocument();
    expect(passwordInput).toHaveValue('');
    expect(rememberMeInput).toBeInTheDocument();
    expect(rememberMeInput).not.toBeChecked();
    expect(submitButton).toBeInTheDocument();
    expect(submitButton).not.toBeDisabled();
  });
```


### フィールド空でのバリデーションエラーテスト
読み込んですぐ、フィールド空のままフォーム送信した時のバリデーションエラーテスト。
別ファイルに分けている```authSchema```に設定したメッセージがしっかり表示！

```typescript
  test('空のフィールドでフォームを送信した場合にバリデーションエラーが表示される', async () => {
    const { submitButton } = setUp();
    fireEvent.click(submitButton);
    await waitFor(() => {
      expect(screen.getByText('メールアドレスの形式で入力してください')).toBeInTheDocument();
      expect(screen.getByText('8文字以上で入力してください')).toBeInTheDocument();
    });
  });
});
```

### 無効な値でのバリデーションエラーテスト
ここでfireEventが出てきます。
changeで値を変えたり、clickしたり各イベントを発火させることが出来ます。

https://testing-library.com/docs/dom-testing-library/api-events#fireevent

バリデーションタイミングはonBlurに設定しているので、値を入力してBlurを発火させています。
ここも難なくPass！

```typescript
  test('無効なメールアドレスを入力した時にバリデーションエラーが表示される', async () => {
    const { emailInput } = setUp();
    fireEvent.change(emailInput, { target: { value: '無効なメールアドレス' } });
    fireEvent.blur(emailInput);
    await waitFor(() => {
      expect(screen.getByText('メールアドレスの形式で入力してください')).toBeInTheDocument();
    });
  });

  test('無効なパスワードを入力した時にバリデーションエラーが表示される', async () => {
    const { passwordInput } = setUp();
    fireEvent.change(passwordInput, { target: { value: 'pw' } });
    fireEvent.blur(passwordInput);
    await waitFor(() => {
      expect(screen.getByText('8文字以上で入力してください')).toBeInTheDocument();
    });
  });uiij
```

### 有効な値でのフォーム送信テスト

メールアドレス、パスワードに有効な値を入力して、Loginボタンを押して想定通りの動作をするかのチェックです。
フォーム送信処理は、```react-hook-form```の```handleSubmit```を通してカスタムフック```onSubmit```にinputValueを渡して実行しています。
モック作成済みのため、mockOnSubmitが呼び出されます。
mockOnSubmitから正しい引数でフォーム送信されたかをチェックします。

```typescript:
  test('データを入力してボタンを押し、onSubmitで入力データを処理', async () => {
    const { emailInput, passwordInput, rememberMeInput, submitButton } = setUp();
    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(rememberMeInput);
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(screen.queryByText('メールアドレスの形式で入力してください')).not.toBeInTheDocument();
      expect(screen.queryByText('8文字以上で入力してください')).not.toBeInTheDocument();
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
        rememberMe: true,
      },expect.any(Object));
    });
  });
```


ここで実は少しハマってしまいました。
最初は下記でテストを実行したのですが何故か失敗...
```typescript
expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
        rememberMe: true,
      });
```

mockの作成方法が間違っているのかと思い、右往左往しましたが一向に原因分からず。
```console.log(mockOnSubmit.mock.calls);```で引数を調べてみたところ、inputのデータとは別にイベントオブジェクトがフォームから送信されていました。

https://www.react-hook-form.com/api/useform/handlesubmit/

普段第二引数を省略していたので、すっかり見落としていました。
改めてしっかり理解して使わなきゃなと反省...

引数2つ目に```expect.any(Object)```を追加。


## さいごに
今回はコンポーネントありきのテストとなりましたが、本来は必要な内容をテストに書き、実装が問題ないかを確認するためのものなので、思ったようにテストを書いていけるように今後もゴリゴリ書いていきたいと思います。


## 参考

https://jestjs.io/ja/

https://testing-library.com/

https://www.react-hook-form.com/api/useform/