💯

【React Testing Library】コンポーネントのユニットテストにふれる

2024/09/28に公開

React Testing Libraryを使ったコンポーネントのユニットテスト

コンポーネントの見た目や振る舞いが正しく機能しているかどうかは、ユニットテストによって検証できます。Reactにおいては、React Testing Library というツールを利用して、コンポーネントのユニットテストを実行できます。

今回は、React Testing Libraryの導入手順と、input要素を対象にしたユニットテストの実装方法について学習した内容をまとめていきます。

テスト環境構築

以下では、Next.jsのプロジェクトにテスト実行環境を追加していきます。
なお本記事の各ライブラリのバージョンは以下の通りとなります。

  • Next.js: 14.2.5
  • React: 18.0.0
  • Jest: 29.7.0
  • @testing-library/react: 16.0.1
  • @testing-library/jest-dom: 6.5.0
  • jest-environment-jsdom: 29.7.0
  • @types/testing-library__jest-dom: 6.0.0
  • @types/jest: 29.5.13
  • @types/node: 20.0.0

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

以下のコマンドを実行し、必要なパッケージをインストールします。

npm install --save-dev jest \
    @testing-library/react \
    @testing-library/jest-dom \
    jest-environment-jsdom \
    @types/testing-library__jest-dom \
    @types/jest \
    @types/node

ここでインストールしたパッケージは以下のようなものです。

  • jest: JavaScriptのテストフレームワーク。
  • @testing-library/react: Reactコンポーネントのテストを支援するライブラリ。
  • @testing-library/jest-dom: DOMのカスタムマッチャー(例えば、toBeInTheDocument()など)を追加するライブラリ。
  • jest-environment-jsdom: Jestでブラウザのような環境をシミュレートするための環境設定。(※バージョン互換性を保つために、jestとjest-environment-jsdomのバージョンは一致させておきましょう)
  • @types/testing-library__jest-dom: @testing-library/jest-dom型定義に必要なファイル
  • @types/jest: Jestの型定義に必要なファイル
  • @types/node: Node.jsの型定義に必要なファイル

2. 初期設定のためのファイルの準備

パッケージがインストールできたらプロジェクトルートにjest.setup.jsというファイルを作成します。Jestのテスト環境で必要な初期設定をこのファイルに書いていきます。
ここでは以下のように記述します。

import '@testing-library/jest-dom'

この設定によって、jest-domが提供するカスタムマッチャー(toBeInTheDocument()など)が利用できるようになります。

3. カスタマイズ設定のためのファイルの準備

同じくプロジェクトルートにjest.config.jsを作成し、以下を記述します。このファイルではJestのカスタマイズ設定を記述していきます。

const { TestEnvironment } = require('jest-environment-jsdom');
const nextJest = require('next/jest');

const createJestConfig = nextJest({ dir: './' })

const customJestConfig = {
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  TestEnvironment: 'jsdom',
}

module.exports = createJestConfig(customJestConfig)

このファイルについて少し解説します。

  • next/jestnextJest関数
    next/jestというのはNext.jsアプリケーション用にJestの設定を簡単に行えるようにする公式のラッパーで、これを利用すると、通常のJestの設定に加えて、Next.jsに特化した設定や環境が自動で適用されるようになります。つまり、Next.js特有のモジュールの解決やNext.js特有の機能であるnext/imagenext/linkなどのテスト、そしてサーバーサイドレンダリングを考慮したテストが適切に実行できるようになります。
    そして、このnext/jestから初期化したnextJest関数のオプションとして、プロジェクトのルートディレクトリを指定すると、そのプロジェクトのNext.jsアプリケーションの設定ファイル(next.config.js など)やページのディレクトリ構造が解釈され、適切なJestの設定が行われるようになります。

  • createJestConfig とその中身
    nextJest関数にdirオプションを渡すと、createJestConfigという関数が生成されます。この関数に、追加のJest設定(customJestConfig)を渡すことで、プロジェクトに最適なJestの設定が完成します。customJestConfigの中身としては以下の通りです。

    • testPathIgnorePatterns: テストを実行しないパスを指定しています。.nextディレクトリとnode_modulesディレクトリ内のファイルを無視するようにしています。
    • setupFilesAfterEnv: テストの前にjest.setup.jsファイルを読み込む設定です。
    • TestEnvironment: jsdomを指定することで、ブラウザ環境をシミュレートしてテストを実行します。

4. 型定義ファイルを読み込む

インポートした型定義ファイルがTypeScriptに認識されるようにtsconfig.jsonで設定を行なっておきます。

{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "types": ["@testing-library/jest-dom", "jest", "node"], // ←追加
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

typesの部分を追記しています。それ以外はデフォルトの状態です。typesには配列型式で先ほどインポートした型定義に必要なファイルを追加しています。

5. スクリプト追加

そして最後にpackage.jsonにテスト実行のためのスクリプトを追加します。

{
	...
	"scripts": {
		...
		"test": "jest"
	},
}

これでnpm run testを実行すればjestが起動し、テストを実行してくれるようになります。

試しにnpm run testを実行してみると…

... 
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
In /Users/.../next-sample
  xxx files checked.
  testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches
  testPathIgnorePatterns: /node_modules/, /.next/, /Users/.../next-sample/.next/, /Users/.../next-sample/node_modules/ - 35 matches
  testRegex:  - 0 matches
Pattern:  - 0 matches

テストが走りました!この段階ではまだテストファイルが用意されていないので、No tests foundとなっていますね。

なお、テスト実行のスクリプトについて、今回は単純に"test": "jest"と書きましたが、以下のようにwatchオプションを足しておくと、ソースコードの変更を監視して自動でテストを再実行してくれるようになります。
他にもカバレッジレポートの生成といったオプションもここで設定できます。生成されたカバレッジレポートはcoverageフォルダにHTML形式で出力されます。レポートを確認することで、テストがどのコードをカバーしているかを視覚的に確認できます。(本記事での説明は割愛させていただきます)

"scripts": {
   // ...
  "test": "jest --watch",  // 変更を監視して自動で再実行
  "test:coverage": "jest --coverage"  // カバレッジレポートの生成
}

では次からは、いよいよテストファイルの作成を行なっていきます。

テストファイルの作成

Input要素に何も入力されていない場合のテスト

今回は簡単なテキスト入力ができるInputコンポーネントのテストファイルを作成してみます。
元となるコンポーネントファイルがまだ存在しないので、components/Input/index.tsxファイルを用意し、以下のようにコンポーネントを作成します。

import { useState } from 'react'

export type InputProps = JSX.IntrinsicElements['input'] & {
  label: string
}

export const Input = (props: InputProps) => {
  const { label, ...rest } = props

  const [ text, setText ] = useState('')

  const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }

  const resetInputField =() => {
    setText('')
  }

  return (
    <div>
      <label htmlFor={props.id}>{label}</label>
      <input {...rest} type="text" value={text} onChange={onInputChange} />
      <button onClick={resetInputField}>Reset</button>
    </div>
  )
}

前回Storybookについて触れてみたので、Storybookを使ってこのコンポーネントを表示してみます。
今回用意したコンポーネントはこんな感じになっています。

onClick時の動作などは元となるコンポーネントで実装されているので、テキスト入力やResetボタンクリックによる入力内容のクリアなども問題なく行えます。

ではこのコンポーネントに対するテストファイルを作成します。

同じディレクトリにindex.spec.tsxを作成します。テストファイルの命名規則としては、<ファイル名>.spec.tsxまたは<ファイル名>.text.tsxで終了するファイル名にする必要があります。

テストコードの中身は以下のようになります。

import { render, screen, RenderResult } from '@testing-library/react'
import { Input } from './index'

describe('Input', () => {
  let renderResult: RenderResult

  beforeEach(() => {
    renderResult = render(<Input id="username" label="Username" />)
  })

  afterEach(() => {
    renderResult.unmount()
  })

  it('should empty in input on initial render', () => {
    const inputNode = screen.getByLabelText('Username') as HTMLInputElement
    expect(inputNode).toHaveValue('')
  })
})

テストコードの構成についてそれぞれ確認していきます。

  • describe
    テストケースをまとめるための関数です。第一引数にテストしたいコンポーネント名をとり、第二引数にテストケースを含めた実行関数を定義します。

  • beforeEach
    テストケースの実行前にコンポーネントを描画する処理を実装します。描画したコンポーネントは変数renderResultにセットしています。今回はlabelにUsernameを持つInpoutコンポーネントを描画しています。

  • afterEach
    テストケースの実行後に描画していたコンポーネントを解放する処理を実装します。

  • it
    テストケース自体を定義する関数です。第一引数にテストケース実行時に表示されるメッセージを書き、第二引数にテストを実行する関数を定義します。テストケースの内部ではtoHaveValueなどのマッチャが@testing-library/jest-domのインポートしたことによって利用可能になっています。ここでは、screen.getByLabelText関数によってlabelの文字列がUsername(上記のStorybookの例ではテキスト入力(しなさい)の部分)のコンポーネントを取得し、そのコンポーネントが初期状態、つまり入力されている値が’’であることを検証しています。

npm run testコマンドを実行すると、テストが実行されます。

npm run test
> next-sample@0.1.0 test
> jest

 PASS  components/Input/index.spec.tsx
  Input
    ✓ should empty in input on initial render (14 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.568 s
Ran all test suites.

もしlabelを持たないInputコンポーネントを指定してテストを行いたい場合は、以下のように描画用のコンポーネントにaria-label属性を指定しておけば、今回のテストコードと同じ内容でコンポーネントを取得し、テストを実行することができます。
components/Input/index.tsxにおいて、labelタグを消去すると、テストは失敗します。

import { useState } from 'react'

export type InputProps = JSX.IntrinsicElements['input'] & {
  label: string
}

export const Input = (props: InputProps) => {
  const { label, ...rest } = props

  const [ text, setText ] = useState('')

  const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }

  const resetInputField =() => {
    setText('')
  }

  return (
    <div>
     {/* 以下のラベルタグの文字列を指定してテスト用コンポーネントを取得しているためこれがないとテストは失敗します */}
      {/* <label htmlFor={props.id}>{label}</label>*/} 
      <input {...rest} type="text" value={text} onChange={onInputChange} />
      <button onClick={resetInputField}>Reset</button>
    </div>
  )
}
npm run test

> next-sample@0.1.0 test
> jest

 FAIL  components/Input/index.spec.tsx
  Input
    ✕ should empty in input on initial render (14 ms)

  ● Input › should empty in input on initial render

    TestingLibraryElementError: Unable to find a label with the text of: Username
    ...

labelタグの代わりにinputタグ要素にaria-labelを指定し、その値にpropsからlabelに対応する値が行き渡るように設定します。

import { useState } from 'react'

export type InputProps = JSX.IntrinsicElements['input'] & {
  label: string
}

export const Input = (props: InputProps) => {
  const { label, ...rest } = props

  const [ text, setText ] = useState('')

  const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }

  const resetInputField =() => {
    setText('')
  }

  return (
    <div>
      <input {...rest} type="text" value={text} onChange={onInputChange} aria-label={label} />
      <button onClick={resetInputField}>Reset</button>
    </div>
  )
}

こうすることで、テストは成功します。

npm run test

> next-sample@0.1.0 test
> jest

 PASS  components/Input/index.spec.tsx
  Input
    ✓ should empty in input on initial render (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.696 s, estimated 1 s
Ran all test suites.

Input要素に文字が入力された場合のテスト

描画されている要素への文字入力を再現したい場合はfireEventという関数を使用します。
fireEvent関数は第一引数にinput要素のDOMを、第二引数にオブジェクトの中に入力する文字を指定します。

テストケースとしては、取得したDOMに対してfireEvent関数を実行し、そのDOM要素を操作した後、マッチャを用いて文字が期待した通りに入力されているかどうかを検証するという流れになります。

以下がテストコード例です。

import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { Input } from './index'

describe('Input', () => {
  
  // ...

  it('should show input text', () => {
    const inputText = 'テストです'
    const inputNode = screen.getByLabelText('Username') as HTMLInputElement

    fireEvent.change(inputNode, { target: { value: inputText } })

    expect(inputNode).toHaveValue(inputText)
  })
})

Resetボタンがクリックされると、Input要素に入力された文字がクリアされるかどうかのテスト

ボタンクリック時の挙動をテストする場合も同様にfireEvent関数を使用します。
流れとしてはテストケースの中で、Input要素に文字を入力した後、ResetボタンのDOMに対してイベントを発行させ、Input要素に入力した文字がクリアされていることを確認するというふうになります。

ただしResetボタンのDOMを取得する際、button要素はlabelを持たないので、getByLabelText関数ではなく、getByRoleという関数を使用します。

getByRole関数はDOMにrolearia-labelなどのロールが設定されている場合、ロールに応じてマッチするDOMを取得するための関数で、button要素にはデフォルトでbuttonというroleが暗黙的に設定されているので、getByRole(’button’, { name: ボタンで表示しているテキスト })とすると特定のbutton要素を取得することができます。

button要素が取得できたあとは、clickイベントを発行し、その後、input要素には何も入力されていないということを検証すれば今回のテストケースは完成です。

import { render, screen, RenderResult, fireEvent } from '@testing-library/react'
import { Input } from './index'

describe('Input', () => {

	// ...

  it('should reset when user clicks Reset button', () => {
    const inputText = 'テストです'
    const inputNode = screen.getByLabelText('Username') as HTMLInputElement
    fireEvent.change(inputNode, { target: { value: inputText } })

    const buttonNode = screen.getByRole('button', { name: 'Reset', }) as HTMLButtonElement
    fireEvent.click(buttonNode)

    expect(inputNode).toHaveValue('')
  })
})

最後にテストを走らせておきます。

npm run test

> next-sample@0.1.0 test
> jest

 PASS  components/Input/index.spec.tsx
  Input
    ✓ should empty in input on initial render (14 ms)
    ✓ should show input text (5 ms)
    ✓ should reset when user clicks Reset button (20 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.839 s, estimated 1 s
Ran all test suites.

今回用意したテストケースは3つとも無事成功です!
以上が、ReactのTesting Libraryを導入し、実際にテストを走らせてみるまでの流れとなります。

今回実装する中で、Storybookも並行して導入していたことにより、TypeScriptの部分でエラーと遭遇したので、それについて最後に触れておきたいと思います。

Storybookと併用する際の注意点

エラーとの遭遇

testing-libraryの型ファイルをインポートすると、Storybookコンポーネントでドキュメント生成のためにインポートしたmdxファイルに対して、モジュール './StyledButton.mdx' またはそれに対応する型宣言が見つかりません。のコンパイルエラーが出ました。
コンポーネント名.stories.tsxファイルの以下の部分です。

import MDXDocument from './コンポーネント名.mdx'

このエラーが出る理由としては、インポートした型定義ファイルをtsconfig.jsontypesフィールドに指定した際に、TypeScriptがプロジェクト内で自動的に認識する型定義ファイルのスコープが変わってしまうためです。
今回の場合、tsconfig.jsontypesフィールドに指定されていない型定義やグローバル型定義が認識されなくなり、.mdxファイルに対する型が認識されなくなってしまいました。

対策

そのため、mdx用の型宣言を追加し、今回のエラーに対応します。
srcまたはプロジェクトのルートディレクトリにglobal.d.tsというファイルを作成して、以下のようにmdxファイルの型を宣言します。

global.d.tsファイルは、TypeScriptのプロジェクト全体で利用されるグローバルな型定義を追加するためのファイルで、このファイルを使うことで、特定の型がプロジェクト全体で認識されるようになります。

TypeScriptは、通常のJavaScriptやTypeScriptファイル以外のファイル(.mdx.svgなど)を扱う際に、そのファイル形式に対する型定義がないとコンパイルエラーを発生させます。そのため、このglobal.d.tsファイルにTypeScriptが認識できないファイル形式の型定義を追加することでこのエラーを回避することができます。

global.d.tsファイルにmdxファイルの型を以下のように追加します。

declare module '*.mdx' {
  let MDXComponent: (props: any) => JSX.Element;
  export default MDXComponent;
}

このファイルの構成としては、まず**declare module '*.mdx'で、**TypeScriptに対して、すべてのmdxファイルを一つのモジュールとして扱うことを宣言しています。*.mdxとすることで、プロジェクト内のすべてのファイルにこの設定を適用することができます。
設定の内容としては、let ~ の部分でMDXComponentがこれがReactコンポーネントを返すよう定義し、それをエクスポートするというものです。

この設定により、import MDXDocument from './コンポーネント名.mdx' と記述すると、mdxファイルをReactコンポーネントとしてインポートできるようになり、発生していたエラーは解消しました。

長くなりましたが、今回は以上となります。
次回は非同期コンポーネントのユニットテストについてまとめてみたいと思います。
お読みいただきありがとうございました。

Discussion