【React Testing Library】コンポーネントのユニットテストにふれる
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 \
jest-environment-jsdom \
@testing-library/react \
@testing-library/jest-dom \
@types/testing-library__jest-dom \
@types/jest \
@types/node
ここでインストールしたパッケージは以下のようなものです。
-
jest
: JavaScriptのテストフレームワーク。 -
jest-environment-jsdom
: Jestでブラウザのような環境をシミュレートするための環境設定。(※バージョン互換性を保つために、jestとjest-environment-jsdomのバージョンは一致させておきましょう) -
@testing-library/react
: Reactコンポーネントのテストを支援するライブラリ。 -
@testing-library/jest-dom
: DOMのカスタムマッチャー(例えば、toBeInTheDocument()
など)を追加するライブラリ。 -
@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 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/jest
とnextJest
関数
next/jest
というのはNext.jsアプリケーション用にJestの設定を簡単に行えるようにする公式のラッパーで、これを利用すると、通常のJestの設定に加えて、Next.jsに特化した設定や環境が自動で適用されるようになります。つまり、Next.js特有のモジュールの解決やNext.js特有の機能であるnext/image
やnext/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にrole
やaria-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.json
のtypes
フィールドに指定した際に、TypeScriptがプロジェクト内で自動的に認識する型定義ファイルのスコープが変わってしまうためです。
今回の場合、tsconfig.json
のtypes
フィールドに指定されていない型定義やグローバル型定義が認識されなくなり、.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