🐡

MUI + React Hook Form に Vitest を使ってテスト導入

2024/03/23に公開

はじめに

今回のコードはこちら↓
https://github.com/49takaya3989/sample_react_mui_react-hook-form/tree/main/src/MUI%2BRHF_with_controller_and_zod

開発環境

"@hookform/resolvers": "^3.3.4",
"@mui/material": "^5.15.10",
"@mui/x-date-pickers": "^6.19.4",
"react-hook-form": "^7.50.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@vitest/coverage-v8": "^1.3.1",
"vitest": "^1.3.0"

MUI + React Hook Form でフォーム作成

↓こちらの記事で紹介しているので参考にされてください。
https://zenn.dev/takaya39/articles/8b9ce75259a791

Vitest の特徴

  • Viteの設定、トランスフォーマー、リゾルバー、プラグイン。
  • アプリから同じ設定を使用してテストを実行します。
  • テスト用の HMR など、スマートでインスタントなウォッチ モード!
  • Vue、React、Svelte、Lit、Marko などのコンポーネント テスト
  • すぐに使える TypeScript / JSX のサポート
  • ESM が最初、トップレベルが待機
  • Tinypoolを介したワーカーのマルチスレッド化
  • Tinybenchによるベンチマークのサポート
  • フィルタリング、タイムアウト、スイートとテストの同時実行
  • ワークスペースのサポート
  • Jest互換のスナップショット
  • アサーション用のChai組み込み + Jest は互換性のある APIを期待します
  • モック用のTinyspy組み込み
  • DOM モック用のhappy-domまたはjsdom
  • v8またはistanbulによるコード カバレッジ
  • Rustのようなソース内テスト
  • Expect-typeによる型テスト

https://vitest.dev/guide/features.html

Vitestの導入

公式ドキュメントを参考に実施

  1. 必要なパッケージのインストール
  2. package.jsonにscriptの追加
  3. セットアップ
  4. テストコード

必要なパッケージのインストール

npm install -D vitest @testing-library/jest-dom @testing-library/react @testing-library/user-event

@testing-library/jest-dom: domの状態確認などで使用
@testing-library/react: domをレンダリングするのに使用
@testing-library/user-event: 複数のユーザーイベントを書くために使用

package.jsonにscriptの追加

package.json
{
  "scripts": {
    ...
    "test": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

セットアップ

テストに関するライブラリをimportをしなくても使えるようにするために、設定を行う。

vite.config.ts
export default defineConfig({
  test: {
    ・・・
    globals: true,
  },
})
tsconfig.json
{
  "compilerOptions": {
    ・・・
    "types": ["vitest/globals"]
  },
}

テストでdom状態の操作ができる設定を行う。

vite.config.ts
export default defineConfig({
  test: {
    ・・・
    environment: 'jsdom',
    setupFiles: ['[setup.ts が置かれているディレクトリパス]/setup.ts']
  },
})
setup.ts
import '@testing-library/jest-dom';
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

// テストの副作用を防ぎ、各テストが互いに独立して実行するために clean up する
afterEach(() => {
  cleanup();
});

必要最低限の設定はこれでOK!

テストコード

試しに、初期状態の時に送信ボタンが非活性になっているかどうかをテストするテストコードを書いてみる。

index.test.tsx
import {render, screen} from '@testing-library/react'
import MuiRhfWithControllerAndZod from './index';

describe('ボタンの活性非活性', () => {
  it('未入力のとき', async () => {
    render(<MuiRhfWithControllerAndZod />)
    screen.debug();
    expect(screen.getByTestId('formButton')).toBeDisabled();
  });
})

ittestと書いても同じ結果になる

npm run testを叩いてみて、下記のような結果になれば成功!

stdout | src/MUI+RHF_with_controller_and_zod/__test__/index.test.tsx > ボタンの活性非活性 > 未入力のとき
<body>
  ・・・ index.tsxのdomが出力される

 ✓ src/MUI+RHF_with_controller_and_zod/__test__/index.test.tsx (1)
   ✓ ボタンの活性非活性 (1)
     ✓ 未入力のとき

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:42:16
   Duration  1.78s (transform 70ms, setup 202ms, collect 814ms, tests 278ms, environment 340ms, prepare 50ms)


 PASS  Waiting for file changes...
       press h to show help, press q to quit

今回書いてみようと思っているテストケース一覧は下記の通り

  • 初期状態
    • 送信ボタンが非活性
    • バリデーションメッセージが全て非表示
  • 入力バリデーション
    • バリデーションの表示
    • バリデーションの非表示
  • 送信
    • 送信できる
コード
import { render, screen, within } from '@testing-library/react'
import userEvent, { UserEvent } from '@testing-library/user-event'
import MuiRhfWithControllerAndZod from '../index'
import { errorMessage } from '../schema'

describe('初期状態', () => {
  beforeEach(() => {
    render(<MuiRhfWithControllerAndZod />)
  })

  it('送信ボタンが非活性', async () => {
    expect(screen.getByTestId('formButton')).toBeDisabled()
  })

  it('バリデーションメッセージが全て非表示', async () => {
    expect(screen.queryByText(errorMessage.text.min)).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.number.refine.isRequired)
    ).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.number.refine.isPositive)
    ).not.toBeInTheDocument()
    expect(screen.queryByText(errorMessage.select.min)).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.checkbox.refine.isChecked)
    ).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.checkboxes.refine.isAtLeastOne)
    ).not.toBeInTheDocument()
    expect(screen.queryByText(errorMessage.radio.min)).not.toBeInTheDocument()
    expect(screen.queryByText(errorMessage.date.min)).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.date.refine.isFutureDate)
    ).not.toBeInTheDocument()
    expect(
      screen.queryByText(errorMessage.textarea.min)
    ).not.toBeInTheDocument()
  })
})

describe('入力バリデーション', () => {
  describe('テキスト', () => {
    describe('ラベル:テキスト', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('入力後、値を削除してもバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('nullAbleText')

        await user.click(inputEl)
        await user.tab()
        expect(
          screen.queryByText(errorMessage.text.min)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:テキスト(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('入力後、値を削除し、focus を外すと、必須のバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('text')

        await user.click(inputEl)
        await user.tab()
        expect(screen.queryByText(errorMessage.text.min)).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、1文字以上入力し、focus を外すとバリデーションメッセージが非表示になる', async () => {
        const inputEl = screen.getByTestId('text')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.text.min)
        ).toBeInTheDocument()
        await user.type(inputEl, 't')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.text.min)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('数値', () => {
    describe('ラベル:数値', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('focus を外す、もしくは 0 を入力しても、バリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('nullAbleNumber')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isRequired)
        ).not.toBeInTheDocument()

        await user.type(inputEl, '0')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:数値(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('入力後、値を削除し、focus を外すと、必須のバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isRequired)
        ).toBeInTheDocument()
      })
      it('0を入力後、focus を外すと、正の整数用のバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.type(inputEl, '0')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).toBeInTheDocument()
      })
      it('負の整数を入力後、focus を外すと、正の整数用のバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.type(inputEl, '-1')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).toBeInTheDocument()
      })
      it('必須のバリデーションメッセージが表示された状態で、負の整数を入力し、focus を外すと必須のバリデーションメッセージが正の整数用のバリデーションメッセージに変更される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isRequired)
        ).toBeInTheDocument()
        await user.type(inputEl, '0')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).toBeInTheDocument()
      })
      it('必須のバリデーションメッセージが表示された状態で、正の整数を入力し、focus を外すとバリデーションメッセージが非表示される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isRequired)
        ).toBeInTheDocument()
        await user.type(inputEl, '1')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).not.toBeInTheDocument()
      })
      it('正の整数用のバリデーションメッセージが表示された状態で、正の整数を入力し、focus を外すとバリデーションメッセージが非表示される', async () => {
        const inputEl = screen.getByTestId('number')

        await user.type(inputEl, '0')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).toBeInTheDocument()
        await user.type(inputEl, '1')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.number.refine.isPositive)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('セレクト', () => {
    describe('ラベル:セレクト', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('任意の値を選択後、"選択してください"を選択し、 focus を外してもてもバリデーションメッセージが表示されない', async () => {
        // <Select /> のトリガーは combobox
        // 複数ある場合のために within を挟む
        const selectBoxEl = within(
          screen.getByTestId('nullAbleSelect')
        ).getByRole('combobox')

        await user.click(selectBoxEl)
        await user.tab()
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:セレクト(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('任意の値を選択後、"選択してください"を選択し、 focus を外したらバリデーションメッセージが表示される', async () => {
        // <Select /> のトリガーは combobox
        // 複数ある場合のために within を挟む
        const selectBoxEl = within(screen.getByTestId('select')).getByRole(
          'combobox'
        )

        await user.click(selectBoxEl)
        await user.tab()
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).toBeInTheDocument()

        await user.click(selectBoxEl)
        await user.click(
          await screen.getByRole('option', { name: 'セレクト1' })
        )
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).not.toBeInTheDocument()
        await user.click(selectBoxEl)
        await user.click(
          await screen.getByRole('option', { name: '選択してください' })
        )
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、任意の値を選択後、"選択してください"以外の任意の値を選択し、 focus を外したらバリデーションメッセージが表示されない', async () => {
        // <Select /> のトリガーは combobox
        // 複数ある場合のために within を挟む
        const selectBoxEl = within(screen.getByTestId('select')).getByRole(
          'combobox'
        )

        await user.click(selectBoxEl)
        await user.tab()
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).toBeInTheDocument()
        await user.click(selectBoxEl)
        await user.click(
          await screen.getByRole('option', { name: 'セレクト1' })
        )
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.select.min)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('チェックボックス', () => {
    describe('ラベル:チェックボックス', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('2回クリックし、 focus を外してもバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('nullAbleCheckbox')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkbox.refine.isChecked)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:チェックボックス(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('2回クリックし、 focus を外すとバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('checkbox')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkbox.refine.isChecked)
        ).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、1回クリックし、 focus を外したらバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('checkbox')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkbox.refine.isChecked)
        ).toBeInTheDocument()
        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkbox.refine.isChecked)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('複数チェックボックス', () => {
    describe('ラベル:複数チェックボックス', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('任意のチェックボックスを2回クリックし、 focus を外してもバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('nullAbleCheckboxes0')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkboxes.refine.isAtLeastOne)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:複数チェックボックス(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('任意のチェックボックスを2回クリックし、 focus を外してもバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('checkboxes0')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkboxes.refine.isAtLeastOne)
        ).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、任意のチェックスを1回クリックし、 focus を外したらバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('checkboxes0')

        await user.dblClick(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkboxes.refine.isAtLeastOne)
        ).toBeInTheDocument()
        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.checkboxes.refine.isAtLeastOne)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('日程', () => {
    describe('ラベル:日程', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('カレンダーモーダルを開いてから閉じても、バリデーションメッセージが表示されない', async () => {
        const calIcon = within(screen.getByTestId('nullAbleDate')).getByRole(
          'button'
        )

        await user.dblClick(calIcon)
        await expect(
          screen.queryByText(errorMessage.date.refine.isFutureDate)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:日程(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('カレンダーモーダルを開いてから閉じると、バリデーションメッセージが表示される', async () => {
        const calIcon = within(screen.getByTestId('date')).getByRole('button')

        await user.dblClick(calIcon)
        await expect(
          screen.queryByText(errorMessage.date.refine.isFutureDate)
        ).toBeInTheDocument()
      })
      it('過去月を入力したら、バリデーションメッセージが表示される', async () => {
        const inputEl = within(screen.getByTestId('date')).getByRole('textbox')

        await user.type(inputEl, '2024/03/01')
        await expect(
          screen.queryByText(errorMessage.date.refine.isFutureDate)
        ).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、未来月を入力後、バリデーションメッセージが表示されない', async () => {
        const calIcon = within(screen.getByTestId('date')).getByRole('button')
        const inputEl = within(screen.getByTestId('date')).getByRole('textbox')

        await user.dblClick(calIcon)
        await expect(
          screen.queryByText(errorMessage.date.refine.isFutureDate)
        ).toBeInTheDocument()
        await user.type(inputEl, '2024/04/01')
        await expect(
          screen.queryByText(errorMessage.date.refine.isFutureDate)
        ).not.toBeInTheDocument()
      })
    })
  })

  describe('テキストエリア', () => {
    describe('ラベル:テキストエリア', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('一度 focus を当てて、外してもバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('nullAbleTextarea')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.textarea.min)
        ).not.toBeInTheDocument()
      })
    })

    describe('ラベル:テキストエリア(必須)', () => {
      let user: UserEvent

      beforeEach(() => {
        user = userEvent.setup()
        render(<MuiRhfWithControllerAndZod />)
      })

      it('一度 focus を当てて、外したらバリデーションメッセージが表示される', async () => {
        const inputEl = screen.getByTestId('textarea')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.textarea.min)
        ).toBeInTheDocument()
      })
      it('バリデーションメッセージが表示された状態で、任意の値を入力後、 focus を外したらバリデーションメッセージが表示されない', async () => {
        const inputEl = screen.getByTestId('textarea')

        await user.click(inputEl)
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.textarea.min)
        ).toBeInTheDocument()
        await user.type(inputEl, 'test')
        await user.tab()
        await expect(
          screen.queryByText(errorMessage.textarea.min)
        ).not.toBeInTheDocument()
      })
    })
  })
})

describe('送信', () => {
  let user: UserEvent

  beforeEach(() => {
    user = userEvent.setup()
    render(<MuiRhfWithControllerAndZod />)
  })

  it('送信完了後、フォームがリセットされている', async () => {
    const textEl = screen.getByTestId('text')
    const numberEl = screen.getByTestId('number')
    const selectBoxEl = within(screen.getByTestId('select')).getByRole(
      'combobox'
    )
    const checkboxEl = screen.getByTestId('checkbox')
    const multiCheckboxEl = screen.getByTestId('checkboxes0')
    const dateEl = within(screen.getByTestId('date')).getByRole('textbox')
    const radioEl = screen.getByTestId('radio0')
    const textareaEl = screen.getByTestId('textarea')
    const formButtonEl = screen.getByTestId('formButton')

    await user.type(textEl, 'test')
    await user.type(numberEl, '1')
    await user.click(selectBoxEl)
    await user.click(await screen.getByRole('option', { name: 'セレクト1' }))
    await user.click(checkboxEl)
    await user.click(multiCheckboxEl)
    await user.type(dateEl, '2024/04/01')
    await user.click(radioEl)
    await user.type(textareaEl, 'test')
    await user.tab()
    await expect(formButtonEl).not.toBeDisabled()
  })
})

以上!

参考にした記事
https://qiita.com/Adacchi3/items/fc8cf264503aaae8e4d6
https://qiita.com/Yasushi-Mo/items/0a29df164b02bd7d78e6
https://qiita.com/moroball14/items/39e6bca94c3588d78bc8

Discussion