React・実務の道具立て

テストを書く(Vitest + Testing Library)

Vitestとtesting-libraryを入れて、カードとフォームのテストを実際に書いて走らせます。「ユーザーと同じ探し方」で書く理由、落ちたテストの読み方、そして何をテストしないかまで扱います。

「動いていたはずの機能が、別の修正で壊れる」——これを機械に見張らせるのがテストです。

実務では、テストが書けるかどうかがレビューの通りやすさに直結します。この回で実際に書いて、走らせます

何を入れるか

ViteのプロジェクトではVitestが標準的な選択です(Viteの設定をそのまま使えるため)。それに、Reactの部品を操作するためのTesting Libraryを足します。

npm install --save-exact -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
パッケージ役目
vitestテストを走らせる本体
@testing-library/reactコンポーネントを描画して、要素を探す
@testing-library/user-eventクリック・入力をユーザーに近い形で起こす
@testing-library/jest-domtoBeInTheDocument()のような読みやすい検証を足す
jsdomブラウザの代わり(Node上にDOMを用意する)

設定はvite.configに足すだけです。

import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/setupTests.ts',
  },
})
import '@testing-library/jest-dom/vitest'

package.json"test": "vitest"を足せば準備完了です。

最初の1本

BirdCardのテストを書きます。ファイル名はテスト対象と同じ場所に.test.tsxで置くのが分かりやすい形です。

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import BirdCard from './BirdCard.tsx'
import type { Bird } from '../types.ts'

const bird: Bird = {
  id: 1,
  name: 'シマエナガ',
  emoji: '🐦',
  area: '北海道',
  rare: 3,
  memo: '雪のようにまっ白な小鳥。',
}

describe('BirdCard', () => {
  it('名前・すんでいる場所・レアバッジが出る', () => {
    render(
      <MemoryRouter>
        <BirdCard bird={bird} isFavorite={false} onToggleFavorite={() => {}} />
      </MemoryRouter>,
    )

    expect(screen.getByRole('heading', { name: 'シマエナガ' })).toBeInTheDocument()
    expect(screen.getByText('北海道')).toBeInTheDocument()
    expect(screen.getByText('レア')).toBeInTheDocument()
  })
})

3つのことをしています。

  • render(…)——コンポーネントを描画する
  • screen.getByRole(…)——ユーザーと同じ探し方で要素を見つける
  • expect(…).toBeInTheDocument()——あることを確かめる

getByRoleで探す

要素の探し方はいくつかありますが、優先順位があります

探し方使うとき
getByRole('button', { name: '…' })第一候補。 ユーザーと同じ探し方
getByLabelText('名前')フォームの入力欄
getByText('…')上2つで届かない表示テキスト
getByTestId('…')最後の手段data-testidを足す)

getByRoleが推奨なのは、スクリーンリーダーと同じ見つけ方だからです。ここで見つからないなら、支援技術からも見つからない可能性が高い——テストがアクセシビリティの検査を兼ねます

操作をテストする

押したときに何が起きるかを見ます。関数が呼ばれたかを確かめるのが基本形です。

it('星を押すとidつきでonToggleFavoriteが呼ばれる', async () => {
  const onToggleFavorite = vi.fn()   // 呼ばれたか記録する偽の関数

  render(
    <MemoryRouter>
      <BirdCard bird={bird} isFavorite={false} onToggleFavorite={onToggleFavorite} />
    </MemoryRouter>,
  )

  await userEvent.click(
    screen.getByRole('button', { name: 'シマエナガをお気に入りにする' }),
  )

  expect(onToggleFavorite).toHaveBeenCalledWith(1)
})
  • vi.fn()——呼ばれた回数と引数を覚えてくれる偽の関数
  • await userEvent.click(…)——awaitを忘れると、押す前に検証が走ります

フォームも同じ書き方で検証できます。

it('名前が空なら追加されず、エラーが出る', async () => {
  const onAdd = vi.fn()
  render(<AddBirdForm onAdd={onAdd} />)

  await userEvent.click(screen.getByRole('button', { name: 'ずかんに追加' }))

  expect(screen.getByText('名前を入れてください')).toBeInTheDocument()
  expect(onAdd).not.toHaveBeenCalled()
})

入力チェックのような「動くけれど目で確認しにくい」ものほど、テストの価値が高いです。

走らせる

npx vitest run
 Test Files  2 passed (2)
      Tests  5 passed (5)
   Duration  1.29s

npx vitestrunなし)だと見張りモードになり、保存するたびに関係するテストだけ走ります。開発中はこちらが快適です。

落ちたテストの読み方

わざと期待値を間違えると、こう出ます。

 FAIL  src/components/AddBirdForm.test.tsx > AddBirdForm > 入力した値がonAddに渡る
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…} ]

Received:
  1st vi.fn() call:
  [
-   ObjectContaining {
-     "area": "うみのがけ",
+   {
+     "area": "がけ",
+     "emoji": "🐧",
      "name": "ペンギン",
+     "rare": 1,
    },
  ]

読み方は-+だけです。

  • -——テストが期待したもの
  • +——実際に来たもの

上の例なら「areaうみのがけのはずががけだった」。どちらが正しいかを考えて、テストかコードのどちらかを直します(テスト側が間違っていることも普通にあります)。

何をテストしないか

全部テストしようとすると破綻します。

テストするテストしない
入力チェック・条件による出し分け見た目・余白・色(変わっても壊れていない)
押したときに何が呼ばれるかライブラリ自体の動作(Routerが動くか、など)
バグが出て直した箇所(再発防止)単なるgetter・定数
データの変換・計算実装の内部(stateの名前など)

公式ドキュメントも見てみる

testing-library.com外部サイトReact Testing Library | Testing LibraryReact Testing Library公式。冒頭の「The more your tests resemble the way your software is used, the more confidence they can give you」がこの回の考え方そのものです(英語)

このレッスンのまとめ

  1. ViteのプロジェクトならVitestTesting Libraryが標準的な組み合わせ
  2. 設定はvite.configtestを足すだけ(jsdomsetupFiles
  3. renderscreen.getByRoleexpectが基本の3手
  4. 探し方はgetByRoleが第一候補data-testidは最後の手段
  5. aria-labelを付けておくとテストからも狙える——アクセシビリティと同じ投資
  6. 操作はawait userEvent.click(…)、呼ばれたかはvi.fn()で確かめる
  7. 落ちたら-(期待)と+(実際)だけ読む
  8. 見た目やライブラリの動作はテストしない。ふるまいだけ書く

次は、本物のブラウザで通しで試すテストです。ここまでのテストでは届かない範囲を埋めます。

やってみよう:テストを2本書いて走らせる

実際に書いて、走らせるところまでやります。

  1. テスト用のパッケージを入れる(上のコマンド)
  2. vite.config.tstestの設定を足し、src/setupTests.tsを作る
  3. package.jsonscripts"test": "vitest"を足す
  4. src/components/BirdCard.test.tsxを作り、表示のテスト星を押すテストを書く
  5. npx vitest run2本とも通るのを確認する
  6. わざと期待値を1文字変えて、落ちたときの表示を読む
  7. 元に戻して、もう一度通す
npx vitest run

6番を必ずやってください。落ちたテストを読めることが、テストを書けることと同じくらい大事です。-+の意味さえ分かれば、あとは慣れです。

余力があれば、AddBirdFormの「名前が空ならエラー」も書いてみてください。手で毎回試していた確認が、1秒で終わるようになります。

よくある質問

個人開発でもテストは必要ですか?
小さいうちは無くても回ります。ただ、手で確認する項目が10を超えたあたりから、変更のたびの確認が現実的でなくなります。書く価値が出るのはその頃です。
getByTestIdではなくgetByRoleを使うのはなぜですか?
ユーザーと同じ探し方だからです。役割と名前で探せるということは、スクリーンリーダーからも同じように見つかるということで、テストが壊れにくく、アクセシビリティの確認も兼ねられます。
何をテストすればいいですか?
壊れたら困る動きです。入力チェック、条件による出し分け、押したときに何が呼ばれるか。見た目の細かい部分やライブラリの動作そのものは対象にしません。
fireEventとuserEventはどちらを使いますか?
userEventです。実際の操作に近い順序でイベントを起こしてくれるので、クリックや入力の検証が現実に近くなります。