🦦

Reactのテストについてまとめてみた

8 min read

テスト駆動開発(TDD)

テスト駆動開発とは、実装よりもテストを先行させる開発手法です。
テストを開発プロセスの中心に捉えることが最大の特徴です。
TDDを実践するには以下の手順に従う必要があります。

  1. 初めにテストを書く
  2. テストを実行して失敗する事を確認する
  3. アプリケーションのコードを書いてテストが成功する事を確認する
  4. リファクタリングによりアプリケーションのコード及びテストを改善する

アプリケーションのコードをテスト可能な単位に分割して、ユニットテストを記述しておくことで、頻繁に変更を加えてもコードベースの品質は保証されます。
ユニットテストは、Reactのコンポーネント単体で行います。
テスト対象のコンポーネントが、親からpropsや関数を渡されている場合は、切り離します。
mockを使い、擬似的なpropsや関数を受け取るようにし、単体でテストを行います。

ユニットテストは、JestReact-Testing-Libraryのツールを使った手法が、一般的かと思います。(2021/12/30時点)

Jest

JavaScriptのテストフレームワークでは、Jestを使う事が推奨されています。
Jestは、テストファイルでそれぞれのメソッドとオブジェクトをグローバル環境に配置します。
Create React Appを使って作成されたプロジェクトではデフォルトでJestが含まれています。

期待値の確認

まずは、関数が意図どおり動くかテストするためのコードを書いてみましょう。

test(it)

test関数の先頭の引数はテスト名です。2番目の引数はテストのコードを含む関数です。
3番目の引数はテストが完了しなかった時のタイムアウトを指定します。省略された場合のデフォルトのタイムアウト値は2秒です。
it関数も同様です。

test('Multiplies by two', () => {
  expect();
});

次にテスト対象となる関数のスタブ(ダミーのインターフェース)を実装します。

export function timesTwo() {/* --- */}

テストではexpect関数を使ってアサーションを記述します。アサーションとは、テストの実行結果が期待されたものと同じか検証するためのコードを意味します。
以下のアサーションでは、timesTwo関数に4を渡して呼び出すことで8が返却される事を期待しています。

import { timesTwo } from './functions';

test('Multiplies by two', () => {
  expect(timesTwo(4)).toBe(8);
});

expect

expectは値をテストしたい時に使用する関数です。expect関数は値を受け取ると、その値が正しいかテストするためのマッチャー(matcher)を含んだオブジェクトを返します。
主なマッチャー関数は以下の通りです。

  • toBe(value)
    プリミティブ値を比較したり、オブジェクトインスタンスの参照IDを確認したりする際に使用します。
  • toEqual(value)
    toEqualは、オブジェクトや配列をテストする際に使用します。

その他のマッチャー関数は、公式のリファレンスを参照して下さい。

describe

describeは、いくつかの関連するテストをまとめたブロックを作成します。
複数のテストをdescribeにまとめることで、テスト結果がグループごとに出力されます。
テストの数が増えてくると、describeでテストをグループに分ける事で管理が容易になります。

describe('my beverage', () => {
  test('is delicious', () => {
    expect(myBeverage.delicious).toBeTruthy();
  });

  test('is not sour', () => {
    expect(myBeverage.sour).toBeFalsy();
  });
});

React-Testing-Library

レンダリングされたコンポーネントをアサートし、操作したいのであれば、react-testing-library(以下「RTL」という)の利用が推奨されています。RTLは、Reactにおけるテストのベストプラクティスを集めたプロジェクトです。
Create React Appを使っている場合、RTLはデフォルトで含まれています。

cleanup

DOMをunmount, cleanupします。
呼び出すタイミングとしては、各テスト(testやit)直後毎になり、test関数を複数記述する際には、cleanupをした方が良いです。
テスト間の副作用を排除し、より正確なテストの実行ができます。
記述としては以下のようになります。

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

afterEach(() => cleanup());

コンポーネントのレンダリング

RTLでReactコンポーネントのレンダリングをテストします。

App.js
import React from 'react';

const title = 'Hello React';

export default function App() {
  return <div>{title}</div>;
}

上記のAppという名の関数コンポーネントをインポートして利用し、App.test.jsファイルでテストしていきます。

App.test.js
import React from 'react';
import { render } from '@testing-library/react';

import App from './App';

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

RTLのrender関数は、任意のJSXを受け取ってレンダリングします。その後、テスト内でReactコンポーネントにアクセスできるようになります。その確認のため、RTLのdebug関数を利用します。

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

import App from './App';

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

    screen.debug();
  });
});

コマンドラインでテストを実行すると、以下のようにAppコンポーネントのHTMLを確認できます。

<body>
  <div>
    <div>
      Hello React
    </div>
  </div>
</body>

イベントのテスト

コンポーネントでのイベントの処理が正しく動作するかテストする必要があります。

RenderInput.js
export function RenderInput = () => {
  const [input, setInput] = useState('');
  const updateValue = (e) => {
    setInput(e.target.value)
  }
  return (
    <div>
      <input 
	type='text'
	placeholder='Enter'
	value={input}
	onChange={updateValue
            />
    </div>
  );
}

RenderInputコンポーネントを描画し、inputタグを取得します。
userがinputフォームに文字を入力した状態をシミュレートし、想定される入力値になるかテストをします。

Checkbox.test.js
import React from "react";
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import RenderInput from './RenderInput';

describe('Input form onChange event', () => {
  it('Should update input value correctly', () => {
    render(<RenderInput />);
    // inputのelementを取得
    const inputValue = screen.getByPlaceholderText('Enter');
    // userがinputフォームに文字を入力した状態をシミュレート
    userEvent.type(inputValue, 'test');
    expect(inputValue.value).toBe('test');
  })
});

getByRole

指定されたロールを持つ要素を検索します。
各HTMLタグのロールはこちらを参考にして下さい。

expect(screen.getByRole(expectedRole)).toBeTruthy();

queryBy*

要素が表示されていないことを確認したい場合に利用します。

expect(screen.queryByRole(element)).tobeNull();

userEvent

userEventはclickイベントだけでなく、change、keyDown、keyPressやkeyUpイベントなども発火させ、シミュレートする事ができます。
詳細は、公式のドキュメントを確認して下さい。

非同期のテスト

Mock Service Worker(以下、msw)

ネットワークレベルでAPIリクエストをインターセプトして、Mockのデータを返すためのライブラリです。
mswを利用することで、APIリクエストを含む処理のテストなどを実行できます。

  • setupServer()
    リクエスト遮断層を設定する関数です。
    インターセプト用のサーバーを定義できます。
  • rest.get()
    REST APIのリクエストをモック化できます。
    第一引数にapiのエンドポイントURLを指定します。
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer (
    // 第一引数にapiのエンドポイントURLを
  rest.get("https://jsonplaceholder.typicode.com/users/1", (req, res, ctx) => {
    // アロー関数に引数を3つ指定できる 
    // req→ エンドポイントURLのパラメーターにアクセスできる
    // res→ getメソッドのresponse
    // ctx→ jsonのオブジェクトの内容を定義できる
    return res(ctx.status(200), ctx.json({ username: 'Tom' }));
    // responseを具体的に作る
    // 成功した場合のstatus 
  })
);

// テストの前にモックサーバを起動する
beforeAll(() => server.listen());

// テスト毎後にサーバーのリセットとクリーンアップ
afterEach(() => {
  server.resetHandlers();
  cleanup();
})

// 全てのテスト後にモックサーバをcloseする
afterAll(() => server.close());

findBy*

取得結果がブラウザに表示されるまで待ち、要素を取得します。
各HTMLタグのロールはこちらを参考にして下さい。

expect(await screen.findByRole(element)).toHaveTextContent(string);

テストユーティリティ

act

ブラウザでのReactの動作により近い状態で実行をし、テストをします。
アサーション用のコンポーネントを準備するために、コンポーネントをレンダーして更新を実行するコードをラップします。
テストコードを書く上で、beforeEachやafterEachなどの関数と組み合わせるケースが多いです。

  • beforeEach
    テストを実行する前に、関数を実行します。
    beforeEach関数 が describeブロック内に記述された場合は、 各ブロックの最初に beforeEach関数が実行されます。

  • afterEach
    各テストが完了するたびに、関数を実行します。
    afterEach 関数が describe ブロック内に記述された場合は、 afterEach 関数が記述されたブロックのみ、最後に実行されます。

import { act } from 'react-dom/test-utils';

// テストをまとめたブロック
describe('テスト箇所', () => {
  let container = null;

  // セットアップ
  beforeEach(() => {
    // documentにDOM要素を描画する
    container = document.createElement('div');
    document.body.appendChild(container);
  });

  // クリーンアップ
  afterEach(() => {
    // documentからDOM要素を削除する
    unmountComponentAtNode(container);
    container.remove();
    container = null;
  });

  it('テスト内容', () => {
    act(() => {
      // コンポーネントをレンダリングする
    });

    // アサーション
    expect('test').toBe('test');
  });
});
  • 補足
    custom Hooksでのテストにおいて。
    react-hooksライブラリを使っている場合、実行の処理を囲む用途のact()もあります。
import { act, renderHook } from '@testing-library/react-hooks';
import { cleanup} from '@testing-library/react';

afterEach(() => cleanup());

describe('useCounter custom hook', () => {
  it('Should increment by 1', () => {
    // custom HooksのuseCounterをrenderするには、renderHookを使用する
     // 返り値として、resultという属性がある
    const { result } = renderHook(() => useCounter(3));
    // resultの中にcurrentという属性があり、custom Hooksの返り値の現在値が入る
    expect(result.current.count).toBe(3);
    // react-hooksのライブラリを使った場合は、実行の処理をactで全体を囲む必要がある
    act(() => {
    // customHooksのincrementの処理を実行する
      result.current.increment();
    })
    expect(result.current.count).toBe(4);
  })
})

Discussion

ログインするとコメントできます