はじめに
フロントエンド開発の自動テストで必ず出てくるのがUIコンポーネントのテストかと思います。
特に共通化され様々な画面で使用されるコンポーネントは書き換えが入るケースが多いです。
書き換えが入った際に既存の画面に影響が出ていないかを全ての画面で都度チェックするのは大変な労力となります。
これまで通りのDOMのレンダリングができているか?onClick
やonChange
などのイベントの挙動が変わっていないかを担保する意味でUIコンポーネントの自動テストを入れておいた方がいい場面は多々あるかと思います。
ReactではReact Testing Libraryがその代表的なツールとしてよく使われています。
React Testing Libraryの導入方法、簡単な使い方を通してUIコンポーネントの自動テストをご紹介していきます。
React Testing Libraryとは
React Testing Libraryは、Reactコンポーネントをテストするためのライブラリです。
ユーザー視点からテストを行うことに重点を置いており、コンポーネントの実装詳細に依存せず、実際の使用シナリオに近い形でテストを行うことができます。
このライブラリのゴールは、テストがアプリケーションのコード変更に過度に影響されないようにすることで、メンテナンスがしやすく、本番環境により近いテストを書くことです。
具体的には、React Testing Libraryは次のような特徴を持っています:
-
ユーザーインタラクションを重視:ユーザーがアプリケーションを操作するようにテストを記述できるため、ボタンのクリックや入力操作を通して、UIが期待通りに反応するかを検証できます。
-
実装の詳細に依存しない:DOMにレンダリングされる要素やテキストを基準にテストを行うため、実装が少々変更されてもテストが壊れにくくなります。
-
アクセシビリティ対応:getByRoleなどのクエリがあり、アクセシビリティを考慮したテストも書きやすくなっています。これにより、実際のユーザーが使用する方法に近い形でDOM要素を取得できます。
-
Jestと組み合わせやすい:Jestをはじめとするテスティングフレームワークと併用することで、Reactアプリケーション全体のテストカバレッジを向上させることができます。
今回のゴール
React Testing Libraryの公式ドキュメントを参考に導入及び簡単なテストコードを書いていきます。
最終的にはコンポーネントでユーザーの操作(イベント)を受け取るインタラクティブなUIコンポーネントテストを実装します。
describe('Search', () => {
it('renders Search component', () => {
render(<Search />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Next.js' },
})
waitFor(() =>
expect(
screen.getByText(/Searches for JavaScript/)
).toBeInTheDocument()
)
})
})
React Testing Libraryの導入に向けた準備
React Testing Libraryの導入していくわけですが、Reactのコンポーネントがそもそも動く環境が必要となってきます。
一つずつライブラリをインストールして行ってもいいですがなるべく開発現場に近い環境をということでNext.jsをセットアップしていきます。
適当なフォルダを切ってcreate-next-app
でセットアップします。
mkdir react-testing-library-ui-testing
cd react-testing-library-ui-testing
npx create-next-app@14.2.2 .
セットアップが終わりましたらNext.jsがローカルで起動できることを確認しておきます。
npm run dev
Jestのセットアップ
React Testing Library自体はJestだけでなくVitestなどでも動くようですが、今回はJestを使っていきます。
必要なパッケージをまとめてインストールします。
npm install --save-dev jest ts-jest ts-node @types/jest jest-environment-jsdom
インストールが終わったところでJestの動作確認をしていきます。
Jestの設定
まずはjest.config.ts
ファイルを作成し設定していきます。
import type {Config} from 'jest';
const config: Config = {
clearMocks: true,
coverageProvider: "v8",
preset: "ts-jest",
testEnvironment: "jest-environment-jsdom",
};
export default config;
package.json
に実行用のコマンドを追記します。
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test": "jest"
},
"dependencies": {
サンプルのテストコード
簡単な関数とその関数のテストコードを書いていきます。
utils
フォルダを作成し以下のファイルを書いていきます。
export function sum(a: number, b: number) {
return a + b;
}
同じフォルダにテストコードを作成します。
import { sum } from './sum';
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
Jestの動作確認
下記のコマンドでテストが実行されます。
npm test
動作確認が終わりましたら上記2ファイルは削除していただいて問題ありません。
React Testing Libraryの導入
ようやく本題のReact Testing Libraryに入っていきます。
まずは必要なパッケージをインストールしていきます。
npm install --save-dev @testing-library/react @testing-library/dom @testing-library/jest-dom
JestでJSXを扱えるための設定
今回テストするコンポーネントの作成
components
フォルダを作成しApp
コンポーネントを作ります。
export function App() {
return <div>Hello Next.js</div>;
}
コンポーネントをテストするテストコード
ここで先ほどインストールしたパッケージを使って、コンポーネントをテストするテストコードを書いていきます。
テストを書く前にコンポーネントを正しくレンダリングできることをまず確認しようと思います。
import { render, screen } from '@testing-library/react';
import { App } from './app'
describe('App', () => {
it('renders App component', () => {
render(<App />)
screen.debug()
})
})
コンポーネントをレンダリングした後に、screen.debug()
というメソッドを使用しています。
テストコードを書く時に便利なメソッドでしてレンダリングした内容を確認することができます。
自分が実装したコンポーネントのテストであればどのような内容がレンダリングされるかは自明なので使う必要もないですが、
他の方が書いたコンポーネントにテストを入れる際では使うこともあるかもしれません。
このテストを実行すると下記のように表示されます。
div
タグに囲まれた、Hello Next.js
がレンダリングされていることが確認できました。
レンダリングで文字が表示されることが確認できたので、実際のテストコードを書いてみます。
import { render, screen } from '@testing-library/react'
import { App } from './app'
+ import '@testing-library/jest-dom'
describe('App', () => {
it('renders App component', () => {
render(<App />)
- screen.debug()
+ expect(screen.getByText('Hello Next.js')).toBeInTheDocument()
})
})
非常に簡単なテストではありますが、Hello Next.js
文字がレンダリングされることを確認するテストとなります。
toBeInTheDocument()
を使うので、@testing-library/jest-dom
のインポートも行います。
再度テストを実施すると成功することを確認できます。
イベントを伴うテスト
次はユーザーがテキスト入力するコンポーネントを作成し、入力した内容が正しくレンダリングされているかをテストしてみたいと思います。
新しくSearch
コンポーネントを作成します。
import { useState } from "react";
export function Search() {
const [search, setSearch] = useState('');
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearch(event.target.value);
}
return (
<div>
<Input value={search} onChange={handleChange}>
Search:
</Input>
<p>Searches for {search ? search : '...'}</p>
</div>
);
}
function Input({
value,
onChange,
children
}: {
value: string,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
children: React.ReactNode
}) {
return (
<div>
<label htmlFor="search">{children}</label>
<input
id="search"
type="text"
value={value}
onChange={onChange}
/>
</div>
)
}
useState
を使い入力した値を管理し表示させます。
テストコードを書いていきますがまずは一度レンダリングしてみてDOM構造を確認してみます。
import { render, screen } from '@testing-library/react'
import { Search } from './search'
import '@testing-library/jest-dom'
describe('Search', () => {
it('renders Search component', () => {
render(<Search />)
screen.debug()
})
})
入力用のinput
タグ、実際に入力された内容が表示されるp
タグがレンダリングされていることがわかります。
まだ何も入力していないのでSearches for
の後は...
と表示されています。
既にお気づきかと思いますが、実コードでは登場しているuseState
やhandleChange
関数といったものはレンダリング結果には登場しません。
また実コードでは2つのコンポーネントに分けていましたがレンダリング時点では1つのDOMとして表示されています。
React Testing Libraryは、Reactコンポーネントを人間のように操作するために使用します。
人間が見ているのは、ReactコンポーネントからレンダリングされたHTMLなので、2つの個別のReactコンポーネントではなく、このHTML構造を出力として見ているわけです。
レンダリングでコンポーネントの内容が確認できたので、実際にテキストを入力してみます。
テキストの入力やボタンの押下などはReact Testing LibraryのfireEvent
を使って実装します。
- import { render, screen } from '@testing-library/react'
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Search } from './search'
import '@testing-library/jest-dom'
describe('Search', () => {
it('renders Search component', () => {
render(<Search />)
+ fireEvent.change(screen.getByRole('textbox'), {
+ target: { value: 'Next.js' },
+ })
+ waitFor(() =>
screen.debug()
+ )
})
})
fireEvent関数は要素(ここではtextboxの役割による入力フィールド)とイベント(ここでは値 "Next.js"を持つイベント)を受け取ります。
入力後にレンダリングを行い入力内容が反映されているか確認してみます。
こちらがそのテスト結果となります。
Next.js
というテキストがinput
タグのvalue
に反映され、Search for
の後に表示されていることがわかります。
レンダリング結果を表示する前にwaitFor
を追加しています。
waitFor が必要なのは、fireEvent.change を使って検索ボックスの値を変更した後に、非同期にレンダリングが更新されることがあるからです。
具体的には、screen.getByText(/Searches for JavaScript/) のような要素が描画されるのが、fireEventの直後とは限らず、少し遅れて更新される可能性があるためです。
目視でコンポーネントが正しくレンダリングできていることを確認したので実際のテストらしく、
Next.js
というテキストを入力すると、Search for Next.js
と表示されることをテストします。
describe('Search', () => {
it('renders Search component', () => {
render(<Search />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Next.js' },
})
waitFor(() =>
- screen.debug()
+ expect(
+ screen.getByText(/Searches for JavaScript/)
+ ).toBeInTheDocument()
+ )
})
})
このようにユーザーがコンポーネントを扱った際の挙動についても、React Testing Libraryはテストを行うことが可能となっています。
セットアップファイルの作成
これまでのテストコードでtoBeInTheDocument()
を使う際に、@testing-library/jest-dom
のインポートを行っていました。
コンポーネントテストをしていると、@testing-library/jest-dom
の各種メソッドを使うケースは非常に多いです。
- toBeInTheDocument()
要素がDOMに存在するかを検証します。
expect(screen.getByText('Hello')).toBeInTheDocument()
- toHaveTextContent()
要素が特定のテキストを含んでいるかを確認します。
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
- toBeVisible()
要素がユーザーに見えているかを検証します(CSSのdisplay: noneやvisibility: hiddenなどが適用されていないことをチェック)。
expect(screen.getByText('Click me')).toBeVisible();
- toHaveAttribute()
要素が特定の属性を持っているか、または属性に特定の値が設定されているかを確認します。
expect(screen.getByRole('button')).toHaveAttribute('disabled');
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
- toHaveClass()
要素が特定のクラスを持っているかを検証します。
expect(screen.getByTestId('my-element')).toHaveClass('active');
- toHaveStyle()
要素に特定のスタイルが適用されているかを確認します。
expect(screen.getByText('Hello')).toHaveStyle('color: red');
- toContainElement()
特定の親要素が、指定した要素を含んでいるかどうかを検証します。
expect(container).toContainElement(screen.getByText('Child Element'));
- toBeEmptyDOMElement()
要素が空(子要素やテキストがない)かを確認します。
expect(screen.getByTestId('empty-div')).toBeEmptyDOMElement();
- toHaveFocus()
要素が現在フォーカスされているかを確認します。
expect(screen.getByRole('textbox')).toHaveFocus();
- toBeDisabled() / toBeEnabled()
要素が無効化されているか(disabled 属性があるか)を確認します。
expect(screen.getByRole('button')).toBeDisabled();
- toHaveValue()
inputやtextareaなどの要素に特定の値が設定されているかを確認します。
expect(screen.getByRole('textbox')).toHaveValue('Next.js');
そのため、テストコードを書いているとかなりの確率で@testing-library/jest-dom
のインポートをする必要が出てきます。
そのためテスト実施前に必ずインポートされるようにJestのセットアップファイルを作成しあらかじめ@testing-library/jest-dom
のインポートを済ませておく方法をご紹介します。
プロジェクトの直下にjest.setup.ts
ファイルを作成しインポートをします。
import '@testing-library/jest-dom'
セットアップファイルを作成したら、jest.config.ts
ファイルに1行追加します。
import type {Config} from 'jest';
const config: Config = {
clearMocks: true,
coverageProvider: "v8",
preset: "ts-jest",
testEnvironment: "jest-environment-jsdom",
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx'}}],
},
+ setupFilesAfterEnv: ['./jest.setup.ts'],
};
export default config;
これで設定は完了です。
最後に動作確認として、@testing-library/jest-dom
のインポートしている行をテストコードから消してテストを実施します。
import { render, screen } from '@testing-library/react'
import { App } from './app'
- import '@testing-library/jest-dom'
describe('App', () => {
it('renders App component', () => {
render(<App />)
expect(screen.getByText('Hello Next.js')).toBeInTheDocument();
})
})
さいごに
今回はReact Testing Libraryの導入から実際のコンポーネントを使ってUIコンポーネントテストの実装を行いました。
またユーザーの操作(イベント)を受け取った挙動についてのテストも実装しコンポーネントが正しく機能を提供できているかも確認できるようになりました。
共通コンポーネントは様々な画面で使用されるケースがあり、少しの変更が大きな影響を与えるケースがあります。
UIコンポーネントテストを実装し安定した保守・運用ができる土台となれば幸いです。