これから始めるReact Testing - ①React Componentのテスト

最近、韓国ドラマと個人開発に勤しんでいる大角です。

Webサービスを提供する上でテストを自動化するケースも多いと思いますが、今回はReact Testing Libraryを使った基本的なテストの書き方についてご紹介したいと思います。これからReactでユニットテストを始めたい方などのご参考になれば、幸いです。

テストの内容については3回の記事に分け、今回の記事では最初のReactコンポーネントのテストを取り上げます。

  • React Componentのテスト(今回の記事)
  • カスタムフックのテスト
  • Recoilのテスト

なお、執筆時点での関連するパッケージのバージョンは以下になっています。

  • create-react-app: 5.0.1
  • react: 18.1.0
  • @testing-library/jest-dom: 5.16.4
  • @testing-library/react: 13.2.0
  • @testing-library/user-event: 13.5.0

また、以下の点については取り上げていませんので、予めご了承ください。

  • テストケースの設計
  • Jestの書き方、基礎知識

React Testing Library

React Testing Libraryは、Kent.C.dodds氏が作ったReact向けのテストライブラリです。

testing-library.com

よくEnzymeと比較されるようですが、React Testing Libraryはユーザーの操作と同じような感覚でテストができる点が特徴だと言われています。create-react-appでアプリを作成した際にもReact Testing Libraryが組み込まれていますよね。

たとえば、Appコンポーネント内に”Search:”というテキストが存在するかどうかをテストする場合、以下のようなコードになります。

import { render, screen } from '@testing-library/react';

import App from './App';

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

    expect(screen.getByText('Search:')).toBeInTheDocument();
  });
});

よく利用するもの

React Testing Libraryを使ってテストを書く際に利用する基本的な関数をご紹介します。

コンポーネントの描画

render関数を利用して、Reactコンポーネントを描画することができます。

render(<App />);

もし、propsを変更することによって表示結果が変わることを検証したい場合はrerender関数を利用することもできます。

import {render} from '@testing-library/react';

const {rerender} = render(<NumberDisplay number={1} />);
expect(screen.getByTestId('number-display')).toHaveTextContent('1')

rerender(<NumberDisplay number={2} />);
expect(screen.getByTestId('number-display')).toHaveTextContent('2');

debug関数を利用することで描画結果をコンソールに出力することもできます。

import {render} from '@testing-library/react';

const HelloWorld = () => <h1>Hello World</h1>;
const {debug} = render(<HelloWorld />);
debug();

// <div>
//   <h1>Hello World</h1>
// </div>

https://testing-library.com/docs/react-testing-library/api/#debug

要素の取得

コンポーネントの描画結果から要素を取得できます。

screen.getByText('Search:');

screenオブジェクトには以下のような要素取得関数があります。

  • getByText: 引数に渡されたテキストにマッチする要素を返します。マッチする要素が無かったり、要素が複数存在する場合はエラーを返します。
  • queryByText: 引数に渡されたテキストにマッチする要素を返します。マッチする要素が無い場合はnullを返します。これは、存在しない要素を検出するのに便利です。複数の要素が存在する場合はエラーを返します。
  • findByText: 非同期で引数に渡されたテキストにマッチする要素を返します。要素が見つからないか、タイムアウトした場合はrejectされます。

要素が常に存在する場合はgetByTextで十分ですが、要素が存在しないケースがある場合はqueryByTextを利用したり、要素が非同期で表示される場合はfindByTextを利用するなどで使い分けます。

また、テキストで要素を取得する以外にも、getByRolegetByLabelText など別の情報をもとに要素を取得する関数もあります。

// button要素を取得する
screen.getByRole("button");

// aria-labelをもとに要素を取得する
screen.getByLabelText("submit");

詳細は以下のドキュメントを参照してください。

https://testing-library.com/docs/react-testing-library/cheatsheet/#queries

アクションの実行

fireEventオブジェクトを利用して、ユーザーのアクションを実行することができます。

const mockFunc = jest.fn();
fireEvent.click(screen.getByRole('button'));
expect(mockFunc).toHaveBeenCalled();

fireEventよりもユーザーの挙動を忠実に模倣するためのuserEventもあります。たとえば、userEventを利用してキーをタイプした場合、keyDownなどが文字の数だけ実行されます。

await userEvent.type(screen.getByRole('textbox'), 'JavaScript');

アサーション関数

Jestに元々アサーション関数は用意されていますが、React Testing Libraryは独自のアサーション関数を提供しています。たとえば、特定の要素がドキュメント内に存在するかどうかをチェックする際には toBeInTheDocument のようなアサーション関数を使うことができます。

render(<App />);
expect(screen.getByText('Search:')).toBeInTheDocument();

他にもイベントの実行回数をチェックするためのtoHaveBeenCalledTimesなど様々なアサーションが用意されています。

const onChange = jest.fn();

render(<Select value="item1" onChange={onChange} />);

fireEvent.change(screen.getByRole('select'), {
  target: { value: 'item2' }
});

expect(onChange).toHaveBeenCalledTimes(1);

詳細は以下のドキュメントを参照してください。

https://github.com/testing-library/jest-dom#custom-matchers

サンプルコード

最後にこれまでご紹介した関数などを利用して、カウンターのようなUIコンポーネントをテストするコードを書いてみると、以下のようになります。

// Counter/index.tsx
import { FC } from "react";

type Props = {
  value: number;
  onChange: (value: number) => void;
};

export const Counter: FC<Props> = ({ value, onChange }) => (
  <div>
    <button
      type="button"
      onClick={() => onChange(value - 1)}
      aria-label="decrement"
    >
      -
    </button>
    <output>{value}</output>
    <button
      type="button"
      onClick={() => onChange(value + 1)}
      aria-label="increment"
    >
      +
    </button>
  </div>
);

このカウンターに対して、以下の3つのテストケースを書いてみます。

  • props valueに渡した数値が表示されていること
  • incrementした場合に加算されていること
  • decrementした場合に減算されていること
// Counter/index.test.tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { Counter } from "./index";

describe("Counter", () => {
  test("show value", () => {
    const onChange = jest.fn();
    render(<Counter value={0} onChange={onChange} />);
    expect(screen.getByText("0")).toBeInTheDocument();
  });

  test("decrement value", () => {
    const onChange = jest.fn();
    render(<Counter value={0} onChange={onChange} />);
    fireEvent.click(screen.getByLabelText("decrement"));
    expect(onChange).toHaveBeenCalledTimes(1);
    expect(onChange).toBeCalledWith(-1);
  });

  test("increment value", () => {
    const onChange = jest.fn();
    render(<Counter value={0} onChange={onChange} />);
    fireEvent.click(screen.getByLabelText("increment"));
    expect(onChange).toHaveBeenCalledTimes(1);
    expect(onChange).toBeCalledWith(1);
  });
});

おわりに

いかがでしたでしょうか。この記事では基本的な部分をご紹介しましたが、さらに詳しく知りたい方は公式のチュートリアルが分かりやすくて、オススメです。

www.robinwieruch.de

次回はカスタムフックのテストについて書いてみたいと思います!