🐙

Vitest と Testing Library でコンポーネントテストを書く的の留意点

2024/04/22に公開

今までに自分がフロントエンドでコンポーネントテストを書く時、またコードレビューする時に気をつけていることをまとめました。
個人の経験則が多分に含まれてるので、すべてのプロジェクトに当てはまるわけではないですが、参考になれば幸いです。
また、後で気づいたことがあればここに追記して行くかもしれません。

テストケースの名前は日本語で前提条件と期待する結果をセットで具体的な文章にする

  • (例) 「〇〇の時、△△が表示される」「〇〇をクリックすると、△△が□□になる」
  • チームメンバーの母語が日本語であるなら、日本語でテストケースの名前を書く。翻訳ツールを駆使して頑張って英語で書かない。みんなが書くのも読むのも大変になってテストを書くハードルが上がる
  • 例えば 「default」のような状況の説明だけをテストケースの名前に書いて、該当する結果をコードの方でまとめてアサートするのは避ける。コードを読まなくてもテストケースの名前だけでテストの内容が把握できるようにする。詰まるところ、テストケースに書いてある以上のアサートはしない
  • テストケースの名前に具体的な内部処理を含めない。コンポーネントに対するインプット(props の値やユーザー操作)とアウトプット(画面への反映や emit の実行など)のセットとなるようにする
  • 内部実装に依存しないテストケースの名前であれば、実装前に先にテストケースだけを書くといった TDD(Test-Driven Development)のような手順で開発をすることができる
  • テストケースの名前が抽象的、または長くなりすぎる場合は具体的かつ簡潔な内容になるまでテストケースの分割を検討する
コード例
/**  Bad */
test('default', async () => {
  const onClick = vi.fn()
  const { getByRole } = render(<Button label="ボタン" type="primary" onClick={onClick} />)
  const button = getByRole('button')
  expect(button).toHaveTextContent('ボタン')
  expect(button).toHaveClass('primary')
  await fireEvent.click(button)
  expect(onClick).toHaveBeenCalled()
})

/** Good */
test('props.label に渡した文字列がボタンのテキストとして表示される', async () => {
  const { getByRole } = render(<Button label="ボタン" />)
  expect(getByRole('button')).toHaveTextContent('ボタン')
})

test('props.type に渡した文字列が CSS のクラスに適用される', async () => {
  const { getByRole } = render(<Button label="ボタン" type="primary" />)
  expect(getByRole('button')).toHaveClass('primary')
})

test('props.onClick に渡した関数がボタンクリック時に実行される', async () => {
  const onClick = vi.fn()
  const { getByRole } = render(<Button label="ボタン" onClick={onClick} />)
  await fireEvent.click(getByRole('button'))
  expect(onClick).toHaveBeenCalled()
})

コンポーネントの props に定義されたものはもれなくテストケースに含める

  • props に定義されたものは必ずコンポーネントの中で何らかの処理を行うものであるため。テストケースに含めることでコンポーネントの振る舞いをテストすることができる
  • もしテストケースに含めなくて良い props があるのなら、それはそもそもそのコンポーネントに必要のない props と言える
  • 子コンポーネントに props を渡すだけの props で、子コンポーネントで別途テストを書いていたとしても、その props が正しく子コンポーネントに渡されているかをアサートする。子コンポーネントに正しく props が渡すことが親コンポーネントの props としての責務であるため

コンポーネントの中でインタラクションや分岐があるものをはもれなくテストケースに含める

  • JSX (Vue だと template) で条件分岐やループがある箇所はテストケースに追加する
  • useState (Vue だと ref, computed) で出力内容に分岐がある箇所はテストケースに追加する
  • useEffect (Vue だと watch) でコンポーネントに副作用を与えている箇所はテストケースに追加する
  • onClick, onChange (Vue だと @click, @change) などユーザー操作が必要なものを内包している場合はユーザー操作を前提条件したテストケースを追加する
  • Vue で emit している箇所は 意図した形で emit されているかをテストケースに追加する

props が大量にあったりネストの深い Object を props に定義しているコンポーネントは分割が可能か検討する

  • 一つのコンポーネントに大量の props があったり、長大な Object を props で受けているコンポーネントは適切な粒度で分割されていないことの表れと言える
  • 本来コンポーネントに必要のないパラメータまで渡していないか改めて確認する。長大な object はテストを書く際に props のモックを書くのが大変になる
  • コンポーネントにとって不必要な情報が props の中に含まれているとデバッグの際にノイズになるうえ、パフォーマンスの低下にも繋がる
  • 責務を意識してコンポーネントを分割すると自ずと props の数は少なくなる。children (Vue だと slots) を上手く使ってコンポーネントを設計することで props の肥大化を防止でき、再レンダリング時のパフォーマンスも向上する
コード例
/** Bad */
function ArticleList({ data }) {
  return (
    <div className="article-list">
      <p>{data.count}</p>
      {data.articles.map((article) => (
        <ArticleListItem
          key={article.id}
          info={article}
        />
      ))}
    </div>
  )
}

function ArticleListItem({ info }) {
  return (
    <article className={ read: info.read }>
      <div className="outline">
        <h2>{info.title}</h2>
        <p>{info.author}</p>
        <p>{info.publishedAt}</p>
        <p>{info.category}</p>
        {info.tags.length > 0 && (
          <ul>
            {info.tags.map((tag) => (
              <li key={tag}>{tag}</li>
            ))}
          </ul>
        )}
      </div>
      {info.images.length > 0 && (
        <div className="images">
          {info.images.map((image) => (
            <figure key={image.src}>
              <img src={image.src} alt={image.alt} />
              {image.caption && <figcaption>{image.caption}</figcaption>}
            </figure>
          ))}
        </div>
      )}
      {info.content && <div className="content">{info.content}</div>}
    </article>
  )
}
// ArticleList 呼び出し
return <ArticleList data={data} />


/** Good */
function ArticleList({ count, children }) {
  return (
    <div className="article-list">
      <p>{count}</p>
      {children}
    </div>
  )
}

function ArticleListItem({ read, children }) {
  return (
    <article className={ read }>
      {children}
    </article>
  )
}

function ArticleListItemOutline({ title, author, publishedAt, category, tags }) {
  return (
    <div className="outline">
      <h2>{title}</h2>
      <p>{author}</p>
      <p>{publishedAt}</p>
      <p>{category}</p>
      {tags.length > 0 && (
        <ul>
          {tags.map((tag) => (
            <li key={tag}>{tag}</li>
          ))}
        </ul>
      )}
    </div>
  )
}

function ArticleListItemImages({ images }) {
  return (
    <>
      {images.length > 0 && (
        <div className="images">
          {images.map(({ src, alt, caption }) => (
            <figure key={src}>
              <img src={src} alt={alt} />
              <figcaption>{caption}</figcaption>
            </figure>
          ))}
        </div>
      )}
    </>
  )
}

function ArticleListItemContent({ children }) {
  return (
    <div className="content">{children}</div>
  )
}

// ArticleList 呼び出し
return (
  <ArticleList count={data.count}>
    {data.articles.map(({ id, read, title, author, publishedAt, category, tags, images, content }) => (
      <ArticleListItem
        key={id}
        read={read}
      >
        <ArticleListItemOutline
          title={title}
          author={author}
          publishedAt={publishedAt}
          category={category}
          tags={tags}
        />
        <ArticleListItemImages images={images} />
        {content && <ArticleListItemContent>{content}</ArticleListItemContent>}
      </ArticleListItem>
    ))}
  </ArticleList>
)

Store に依存している粒度の大きいコンポーネントは Store 依存を極力しない設計や分割を検討する

  • Router のエンドポイントとなるページコンポーネントや、ビジネスロジックが入り込む Organisms レベルのコンポーネントを除き、Store への依存はなるべく少なくした方がコンポーネントの作りがシンプルになる
  • Store に依存しているコンポーネントをテストする時は Store のモックを定義する必要があるため、テストの書く際に手続きが重たくなる。特に Store のデータが大きかったり、他の Store に依存している場合はそのコストがさらに高くなる
  • テストケースを props (emits) の範囲に収められるとテストを書くコストが下がり、レビューもしやすくなる
  • Store に依存している粒度の大きいページコンポーネントや Organisms コンポーネントは自身の一部を子コンポーネントに分割することで Store 依存を剥がすことでテストしやすい粒度に切り出すことができる
  • 親子コンポーネントのバケツリレーを忌避するあまり Store に依存するコンポーネントが増えると可用性やテスタビリティも下がるので、バケツリレーをあまり目の敵にしない方が良い
  • 粒度が細かく汎用性の高い Molecules や Atoms レベルのコンポーネントは Store に依存してはいけない。Organisms レベルのコンポーネントも可能な限り Store に依存しないようにして、基本はページコンポーネントから props で渡すようにして Store 依存を最小限に抑えておく
  • これを詰めていくと、本来 Store で管理しなくてはいけないものというのは想像以上に少なかったりすることに気づく

スナップショットテストだけでテストしない

  • スナップショットテストはコンポーネントの振る舞いをテストするためのものではない。DOM の構造が意図せずに変わることを検知するためのもの
  • スナップショットテスト自体に文脈や具体性がないため、スナップショットテストだけでは何を担保したいのかが伝わらない
  • フロントエンド開発では DOM の構造が変わる修正がよく起こるため、差分検知が多発するとスナップショットの更新が手間になるうえ、テストを通すために毎回スナップショットを更新するオペレーションが開発者のテストへの意識を曖昧にしがち
  • スナップショットテストだけのテストは手っ取り早く書けるしカバレッジも上がるのでテストを書いた気になるが、実質何もテストしていないに等しい。むしろテストを書いた気になってしまう点においては害悪ですらある
  • スナップショットテストが必要なケースはかなり限定的なので必要最低限の利用に留め、それよりもコンポーネントの振る舞いのテストケースを充実させることが重要
コード例
/** BAD */
test('snapshot', () => {
  const { asFragment } = render(<Button label="ボタン" />)
  expect(asFragment()).toMatchSnapshot()
})

カバレッジを取り、テストケースの漏れがないか確認する

  • vitest run --coverage を実行するとテストカバレッジを取得できる
  • テストケースでカバーされてないファイルの行数が明示されるため、実装したコードに対してテストケースに不足がないかを確認できる
  • 到達不能コードがある場合もテストカバレッジから気付くことができる
  • カバレッジは高いに越したことはないが、テストを書く際の気づきを得る手段でありカバレッジを上げること自体が目的になってはならない
  • カバレッジはあくまでも実装コードに対してのチェックなので、当然ながら実装に落ちていない仕様の考慮漏れなどは気付けない。コードレビュー時にはカバレッジの数値を過信せず、仕様面からのテストケースに不足がないかの確認も合わせて実施する必要がある

DOM API (document.querySelector) よりも Testing Library API(getBy, queryBy, finedBy)を優先して使うようにする

  • document.querySelector は DOM の構造に依存するため、DOM の構造やクラス名が変わるとテストの修正が必要になることが多い
  • Testing Library API(getBy, queryBy, finedBy)は DOM の構造に依存しにくく DOM API を使うよりもテストが壊れにくいという利点がある
  • また、ByRole, ByLabelText, ByAltText など Testing Library API はセマンティックなマークアップに対してのアクセス機能が充実しているため、テスタビリティを上げようとすると、自ずとコンポーネントもセマンティックなマークアップになる。結果としてサービスのアクセシビリティが向上し高い品質のコンポーネントになるという相乗効果がある
コード例
/** Bad */
test('props.label に渡した文字列がボタンのテキストとして表示される', async () => {
  const { container } = render(<Button label="ボタン" />)
  expect(container.querySelector('button')).toHaveTextContent('ボタン')
})

/** Good */
test('props.label に渡した文字列がボタンのテキストとして表示される', async () => {
  const { getByRole } = render(<Button label="ボタン" />)
  expect(getByRole('button')).toHaveTextContent('ボタン')
})

Vitest Matcher よりも Testing Library Matcher を優先して使うようにする

  • Vitest Matcher はコンポーネントのテストを書く際にも使えるが、DOM に特化されておりより抽象度の高い Testing Library Matcher を使うことでコンポーネントのテスト精度を上げることができる
  • Vitest Matcher の toBetoStrictEqual はプリミティブな値に対してアサートができるが、Testing Library Matcher は DOM の状態に対してアサートができる。より具体的な内容で記述できるため、DOM の振る舞いをテストする際には Testing Library Matcher を使った方が何をアサートしているのかが説明的になるため、リーダブルコードの観点からも良い
  • 特にフォーカス状態であることを確認する toHaveFocus や、CSS として可視状態であることを確認する toBeVisible などの Matcher は よりユーザー視点に近いアサートができるので有用
  • Testing Library Matcher の一覧 を確認し、可能な限り Testing Library Matcher を使うようにする
コード例
/** Bad */
test('props.label に渡した文字列がボタンのテキストとして表示される', async () => {
  const { getByRole } = render(<Button label="ボタン" />)
  expect(getByRole('button').textContent).toBe('ボタン')
})

test('クリックをするとチェック状態になる', async () => {
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')

  await fireEvent.click(checkbox)
  expect(checkbox.checked).toBe(true)
})

/** Good */
test('props.label に渡した文字列がボタンのテキストとして表示される', async () => {
  const { getByRole } = render(<Button label="ボタン" />)
  expect(getByRole('button')).toHaveTextContent('ボタン')
})

test('クリックをするとチェック状態になる', async () => {
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')

  await fireEvent.click(checkbox)
  expect(checkbox).toBeChecked()
})

操作や検証の対象となる要素の事前存在確認は行わない

  • テストに慣れていないと、ついつい対象となる要素を事前に存在確認してから操作や検証を行いたくなるが、Testing Library は getBy で要素を取得する際に要素が存在しない場合はエラーをスローしその時点でテストが失敗するため、そもそも事前に対象要素の存在確認を行う必要はない
  • 不要な存在確認を行うことでテストコードが冗長になり、テストの可読性が下がる。またテストの実行速度も遅くなるため、事前存在確認は行わないようにする
コード例
/** Bad */
test('クリックをするとチェック状態になる', async () => {
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')
  expect(checkbox).toBeInTheDocument()
  await fireEvent.click(checkbox)
  expect(checkbox).toBeChecked()
})

/** Good */
test('クリックをするとチェック状態になる', async () => {
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')
  await fireEvent.click(checkbox)
  expect(checkbox).toBeChecked()
})

ByTestId を使うのは最後の手段で、ByRole の方を優先して使うようにする

  • ByRole でアクセスできるということはアクセシビリティを担保できているとも言える
  • そのため、セマンティックなマークアップを心がけていれば、ByTestId の必要性は限りなく低くなる
  • 逆に ByTestId が必要になる場合は、div や span 要素が頻出するマークアップになっている可能性がある。その場合は ByTestId の利用を検討する前にセマンティックなマークアップをして ByRole が使えるようにする
  • ByRole を使うと TypeScriptの 型推論が HTMLElement から HTMLButtonElement など、該当要素の型になるのでプロパティへのアクセスが正確になるというメリットもある
  • 公式のドキュメントにも優先度が明示されている
コード例
/** Bad */
test('props.title に渡した文字列が記事の見出しとして表示される', async () => {
  const { getByTestId } = render(<Article title="記事タイトル" />)
  expect(getByTestId('articleTitle')).toHaveTextContent('記事タイトル')
})

/** Good */
test('props.title に渡した文字列が記事の見出しとして表示される', async () => {
  const { getByRole } = render(<Article title="記事タイトル" />)
  expect(getByRole('heading')).toHaveTextContent('記事タイトル')
})

fireEvent よりも @testing-library/user-event を優先して使うようにする

  • fireEvent は DOM のイベントを発火するための dispatchEvent API の軽量ラッパーで、例えば、fireEvent.click(button) はクリックイベントを直接トリガーする
  • 対して @testing-library/user-event はブラウザ越しのユーザーの操作を模倣し、user.click(button) は、マウスダウンイベント、マウスアップイベント、そしてクリックイベントを順にトリガーする
  • 基本的にはよりユーザーの操作に近い振る舞いをテストするためには @testing-library/user-event を使う方が良い
  • [公式のドキュメントにも fireEvent との違いが説明されている]https://testing-library.com/docs/user-event/intro/#differences-from-fireevent)
  • ただしカスタムイベントを発火する必要がある場合や、特定のイベントごとにアサートを行いたい場合は、引き続き fireEvent を使う必要がある
コード例
/** Bad */
test('クリックをするとチェック状態になる', async () => {
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')
  await fireEvent.click(checkbox)
  expect(checkbox).toBeChecked()
})

/** Good */
test('クリックをするとチェック状態になる', async () => {
  const user = userEvent.setup()
  const { getByRole } = render(<Checkbox label="チェックボックス" />)
  const checkbox = getByRole('checkbox')
  await user.click(checkbox)
  expect(checkbox).toBeChecked()
})

Discussion