【Jest / React Testing Library】フォームのテストをすっきり書く

フォームのテストって、項目数の多さや、複雑な仕様のために、テストケースが多くなってどんどん見にくくなることってありませんか🥲
そんな時にちょっと役に立つ、フォームのテストをすっきり書く方法を紹介します。

テストするフォーム(React Hook Form + Yup)

テスト対象のフォームは以下です。
React Hook FormとYupを使用したフォームですが、テスト内容・書き方は他ライブラリでも変わらないと思います。

import { yupResolver } from '@hookform/resolvers/yup'
import { useForm } from 'react-hook-form'
import * as yup from 'yup'

type MyFormValues = { email: string; gender: 'male' | 'female' | 'other' }

export const MyForm = () => {
  const mySchema = yup.object().shape({
    email: yup.string().required('メールアドレスを入力してください'),
    gender: yup.string().nullable().required('性別を選択してください'),
  })

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<MyFormValues>({
    resolver: yupResolver(mySchema),
  })

  const onSubmit = (values: MyFormValues) => {
    // 通常であればAPIなどを呼び出しますが、今回はサンプルのためアラートを出します
    window.alert(values)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input id="email" {...register('email')} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>
      <div>
        <label>
          <input {...register('gender')} type="radio" value="male" />
          男性
        </label>
        <label>
          <input {...register('gender')} type="radio" value="female" />
          女性
        </label>
        <label>
          <input {...register('gender')} type="radio" value="other" />
          その他
        </label>
        {errors.gender && <span>{errors.gender.message}</span>}
      </div>
      <button type="submit">送信</button>
    </form>
  )
}

通常のテスト

通常のテストはこんな感じだと思います↓

import { render, screen, waitFor } from '@testing-library/react'

// userEventのsetup関数
// https://de-milestones.com/%e3%80%90testing-library%e3%80%91userevent-v14%e3%81%ae%e7%a0%b4%e5%a3%8a%e7%9a%84%e5%a4%89%e6%9b%b4%ef%bc%88breaking-change%ef%bc%89/#toc6
import { setup } from '~/lib/userEvent'

import { MyForm } from './MyForm'

describe('MyForm', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  test('送信時、window.alertに値が渡される', async () => {
    window.alert = jest.fn()

    const { user } = setup(<MyForm />)

    await user.type(
      screen.getByRole('textbox', { name: 'メールアドレス' }),
      'sample@example.com'
    )

    await user.click(screen.getByRole('radio', { name: 'その他' }))

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

    await waitFor(() => {
      expect(window.alert).toHaveBeenCalledWith({
        email: 'sample@example.com',
        gender: 'other',
      })
    })
  })

  test('メールアドレスが未入力の場合、エラーメッセージが表示される', async () => {
    const { user } = setup(<MyForm />)

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

    await waitFor(() => {
      expect(
        screen.getByText('メールアドレスを入力してください')
      ).toBeInTheDocument()
    })
  })

  test('性別が未選択の場合、エラーメッセージが表示される', async () => {
    const { user } = setup(<MyForm />)

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

    await waitFor(() => {
      expect(screen.getByText('性別を選択してください')).toBeInTheDocument()
    })
  })
})

スッキリめのテスト

通常テストと違う点は、繰り返し行う操作用の関数作成を行いコンポーネントの読み込みと併せて共通化することです。

import { render, screen, waitFor } from '@testing-library/react'

// userEventのsetup関数
// https://de-milestones.com/%e3%80%90testing-library%e3%80%91userevent-v14%e3%81%ae%e7%a0%b4%e5%a3%8a%e7%9a%84%e5%a4%89%e6%9b%b4%ef%bc%88breaking-change%ef%bc%89/#toc6
import { setup } from '~/lib/userEvent'

import { MyForm } from './MyForm'

describe('MyForm', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  const setupForm = () => {
    const { user } = setup(<MyForm />)

    const submitForm = async () => {
      await user.click(screen.getByRole('button', { name: '送信' }))
    }

    const typeEmail = async (value: string) => {
      await user.type(
        screen.getByRole('textbox', { name: 'メールアドレス' }),
        value
      )
    }

    const selectGender = async (label: string) => {
      await user.click(screen.getByRole('radio', { name: label }))
    }

    return { submitForm, typeEmail, selectGender }
  }

  test('送信時、window.alertに値が渡される', async () => {
    window.alert = jest.fn()

    const { typeEmail, selectGender, submitForm } = setupForm()

    await typeEmail('sample@example.com')
    await selectGender('その他')
    await submitForm()

    await waitFor(() => {
      expect(window.alert).toHaveBeenCalledWith({
        email: 'sample@example.com',
        gender: 'other',
      })
    })
  })

  test('メールアドレスが未入力の場合、エラーメッセージが表示される', async () => {
    const { submitForm } = setupForm()

    await submitForm()

    await waitFor(() => {
      expect(
        screen.getByText('メールアドレスを入力してください')
      ).toBeInTheDocument()
    })
  })

  test('性別が未選択の場合、エラーメッセージが表示される', async () => {
    const { submitForm } = setupForm()

    await submitForm()

    await waitFor(() => {
      expect(screen.getByText('性別を選択してください')).toBeInTheDocument()
    })
  })
})

各テスト内でのユーザー操作が、以下のように簡潔になり、何を操作しているのかが一目でわかるようになりました。

await typeEmail('sample@example.com')
await selectGender('その他')
await submitForm()

特にテストケースが多くなってくると、この分かりやすさが大きなメリットに感じられるはずです。

フォームに限らず、同じ処理が多かったり、可読性が悪く感じられる場合は、処理を共通化させたり切り出すことを検討するといいかと思います👀

タイトルとURLをコピーしました