【Next.js】フロントエンド開発で欠かせないReactのUIコンポーネントのテストをReact Testing Libraryで実装

2023/09/20に公開

はじめに

フロントエンド開発の自動テストで必ず出てくるのがUIコンポーネントのテストかと思います。

特に共通化され様々な画面で使用されるコンポーネントは書き換えが入るケースが多いです。

書き換えが入った際に既存の画面に影響が出ていないかを全ての画面で都度チェックするのは大変な労力となります。

これまで通りのDOMのレンダリングができているか?onClickonChangeなどのイベントの挙動が変わっていないかを担保する意味で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アプリケーション全体のテストカバレッジを向上させることができます。

https://testing-library.com/docs/

今回のゴール

React Testing Libraryの公式ドキュメントを参考に導入及び簡単なテストコードを書いていきます。

https://testing-library.com/docs/react-testing-library/intro/

https://www.robinwieruch.de/react-testing-library/

最終的にはコンポーネントでユーザーの操作(イベント)を受け取るインタラクティブなUIコンポーネントテストを実装します。

search.test.tsx
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 .

Image from Gyazo

セットアップが終わりましたら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ファイルを作成し設定していきます。

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に実行用のコマンドを追記します。

package.json
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
-     "lint": "next lint"
+     "lint": "next lint",
+     "test": "jest"
  },
  "dependencies": {

サンプルのテストコード

簡単な関数とその関数のテストコードを書いていきます。

utilsフォルダを作成し以下のファイルを書いていきます。

sum.ts
export function sum(a: number, b: number) {
  return a + b;
}

同じフォルダにテストコードを作成します。

sum.test.ts
import { sum } from './sum';

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3)
})

Jestの動作確認

下記のコマンドでテストが実行されます。

npm test

Image from Gyazo

動作確認が終わりましたら上記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コンポーネントを作ります。

app.tsx
export function App() {
  return <div>Hello Next.js</div>;
}

コンポーネントをテストするテストコード

ここで先ほどインストールしたパッケージを使って、コンポーネントをテストするテストコードを書いていきます。

テストを書く前にコンポーネントを正しくレンダリングできることをまず確認しようと思います。

app.test.tsx
import { render, screen } from '@testing-library/react';
import { App } from './app'

describe('App', () => {
  it('renders App component', () => {
    render(<App />)

    screen.debug()
  })
})

コンポーネントをレンダリングした後に、screen.debug()というメソッドを使用しています。
テストコードを書く時に便利なメソッドでしてレンダリングした内容を確認することができます。

https://testing-library.com/docs/dom-testing-library/api-debugging/#screendebug

自分が実装したコンポーネントのテストであればどのような内容がレンダリングされるかは自明なので使う必要もないですが、

他の方が書いたコンポーネントにテストを入れる際では使うこともあるかもしれません。

このテストを実行すると下記のように表示されます。

Image from Gyazo

divタグに囲まれた、Hello Next.jsがレンダリングされていることが確認できました。

レンダリングで文字が表示されることが確認できたので、実際のテストコードを書いてみます。

app.test.tsx
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のインポートも行います。

再度テストを実施すると成功することを確認できます。

Image from Gyazo

イベントを伴うテスト

次はユーザーがテキスト入力するコンポーネントを作成し、入力した内容が正しくレンダリングされているかをテストしてみたいと思います。

新しくSearchコンポーネントを作成します。

search.tsx
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構造を確認してみます。

search.test.tsx
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()
  })
})

Image from Gyazo

入力用のinputタグ、実際に入力された内容が表示されるpタグがレンダリングされていることがわかります。
まだ何も入力していないのでSearches forの後は...と表示されています。

既にお気づきかと思いますが、実コードでは登場しているuseStatehandleChange関数といったものはレンダリング結果には登場しません。

また実コードでは2つのコンポーネントに分けていましたがレンダリング時点では1つのDOMとして表示されています。

React Testing Libraryは、Reactコンポーネントを人間のように操作するために使用します。

人間が見ているのは、ReactコンポーネントからレンダリングされたHTMLなので、2つの個別のReactコンポーネントではなく、このHTML構造を出力として見ているわけです。

レンダリングでコンポーネントの内容が確認できたので、実際にテキストを入力してみます。

テキストの入力やボタンの押下などはReact Testing LibraryのfireEventを使って実装します。

search.test.tsx
- 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"を持つイベント)を受け取ります。

入力後にレンダリングを行い入力内容が反映されているか確認してみます。

こちらがそのテスト結果となります。

Image from Gyazo

Next.jsというテキストがinputタグのvalueに反映され、Search forの後に表示されていることがわかります。

レンダリング結果を表示する前にwaitForを追加しています。

waitFor が必要なのは、fireEvent.change を使って検索ボックスの値を変更した後に、非同期にレンダリングが更新されることがあるからです。

具体的には、screen.getByText(/Searches for JavaScript/) のような要素が描画されるのが、fireEventの直後とは限らず、少し遅れて更新される可能性があるためです。

目視でコンポーネントが正しくレンダリングできていることを確認したので実際のテストらしく、
Next.jsというテキストを入力すると、Search for Next.jsと表示されることをテストします。

search.test.tsx
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の各種メソッドを使うケースは非常に多いです。

  1. toBeInTheDocument()
    要素がDOMに存在するかを検証します。
expect(screen.getByText('Hello')).toBeInTheDocument()
  1. toHaveTextContent()
    要素が特定のテキストを含んでいるかを確認します。
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
  1. toBeVisible()
    要素がユーザーに見えているかを検証します(CSSのdisplay: noneやvisibility: hiddenなどが適用されていないことをチェック)。
expect(screen.getByText('Click me')).toBeVisible();
  1. toHaveAttribute()
    要素が特定の属性を持っているか、または属性に特定の値が設定されているかを確認します。
expect(screen.getByRole('button')).toHaveAttribute('disabled');
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
  1. toHaveClass()
    要素が特定のクラスを持っているかを検証します。
expect(screen.getByTestId('my-element')).toHaveClass('active');
  1. toHaveStyle()
    要素に特定のスタイルが適用されているかを確認します。
expect(screen.getByText('Hello')).toHaveStyle('color: red');
  1. toContainElement()
    特定の親要素が、指定した要素を含んでいるかどうかを検証します。
expect(container).toContainElement(screen.getByText('Child Element'));
  1. toBeEmptyDOMElement()
    要素が空(子要素やテキストがない)かを確認します。
expect(screen.getByTestId('empty-div')).toBeEmptyDOMElement();
  1. toHaveFocus()
    要素が現在フォーカスされているかを確認します。
expect(screen.getByRole('textbox')).toHaveFocus();
  1. toBeDisabled() / toBeEnabled()
    要素が無効化されているか(disabled 属性があるか)を確認します。
expect(screen.getByRole('button')).toBeDisabled();
  1. toHaveValue()
    inputやtextareaなどの要素に特定の値が設定されているかを確認します。
expect(screen.getByRole('textbox')).toHaveValue('Next.js');

そのため、テストコードを書いているとかなりの確率で@testing-library/jest-domのインポートをする必要が出てきます。

そのためテスト実施前に必ずインポートされるようにJestのセットアップファイルを作成しあらかじめ@testing-library/jest-domのインポートを済ませておく方法をご紹介します。

プロジェクトの直下にjest.setup.tsファイルを作成しインポートをします。

jest.setup.ts
import '@testing-library/jest-dom'

セットアップファイルを作成したら、jest.config.tsファイルに1行追加します。

jest.config.ts
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のインポートしている行をテストコードから消してテストを実施します。

app.tsx
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コンポーネントテストを実装し安定した保守・運用ができる土台となれば幸いです。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ