🦦

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

2022/01/14に公開

テスト駆動開発(TDD)

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

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

テストの種類

1.ユニットテスト

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

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

2.インテグレーションテスト

複数のユニットが調和して動作することを検証するテストです。
実際のケースですと、Reactのコンポーネントが、ReduxStoreのStateを読み込んでいる場合やdispatchを使いStateを更新している場合は、ReduxStoreにアクセスしコンポーネントをテストします。

テストフレームワークやライブラリ

1.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();
  });
});

2.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', async () => {
  it('Should update input value correctly', () => {
    render(<RenderInput />);
    // inputのelementを取得
    const inputValue = screen.getByPlaceholderText('Enter');
    // userがinputフォームに文字を入力した状態をシミュレート
    await 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イベントなども発火させ、シミュレートする事ができます。
詳細は、公式のドキュメントを確認して下さい。

テストユーティリティ

ReactTestUtilsはお好みのテストフレームワークでReactコンポーネントをテストしやすくするものです。

import ReactTestUtils from 'react-dom/test-utils';

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);
  })
})

3.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);

(補足)Redux Toolkitのテスト

1.ユニットテスト

Reducerのテスト

下記のようなSliceファイルのテストを想定します。

customCounterSlice.tsx
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";

import axios from "axios";

const sleep = (msec: number) => {
  function callback(){
    console.log('hoge')
  }
  setTimeout(callback, msec)  
}

export const fetchDummy = createAsyncThunk("fetch/dummy", async(num: number) => {
  await sleep(2000);
  return num;
})

export const fetchJSON = createAsyncThunk("fetch/api", async() => {
  const res = await axios.get("https://jsonplaceholder.typicode.com/posts/1");
  const { title } = res.data;
  return title;
})

type Props = {
  value: number,
  title: string | null
}

const initialState: Props = {
  value: 0,
  title: ""
};

export const customCounterSlice = createSlice({
  name: "customCounter",
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmout: (state, action: PayloadAction<number>) => {
      state.value = 100 * action.payload;
    }
  },
  extraReducers: builder => {
    builder
      .addCase(fetchDummy.fulfilled, (state, action: PayloadAction<number>) => {
        state.value = 100 + action.payload;
      })
      .addCase(fetchDummy.rejected, (state) => {
        state.value = 100;
      });
    builder
      .addCase(fetchJSON.fulfilled, (state, action: PayloadAction<string>) => {
        state.title = action.payload;
      })
  }
})

export const {increment, decrement, incrementByAmout} = customCounterSlice.actions;

export default customCounterSlice.reducer;

テストの観点としては2つです。

  1. reducerにinitialStateとactionを渡し、新しいstateが予想通りの値かどうかの判定
  2. 非同期関数のステータスに応じて、initialStateと payloadを設定し、新しいstateが予想通りの値かどうかの判定
customCounterSlice.test.tsx
// reducerをimport
import reducer, {increment, decrement, incrementByAmout} from "../features/customCounter/customCounterSlice";

describe("Reducer of ReduxToolkit", () => {
  describe("increment action", () => {
    // テスト用のstateを定義する
    let initialState = {
      value: 1,
      title: ""
    };
    // reducerにactionとstateを渡し、予想通りの値か判定する
    it("Should increment by 1", () => {
      // テスト用のactionを定義
      const action = { type: increment.type };
      // importしたreducerに定義したinitialStateとactionを渡し、新しいstateを代入する
      const state = reducer(initialState, action);
      expect(state.value).toEqual(2);
    });
  });
  describe("incrementByAmount action", () => {
    let initialState = {
      value: 1,
      title: ""
    };
    it("Should incrementByAmount by 100 * payload value with mode 1", () => {
      // payloadの値も設定
      const action = {type: incrementByAmout.type, payload: 3 };
      const state = reducer(initialState, action);
      expect(state.value).toEqual(300);
    });
  });
})
extraReducer.test.tsx
// 非同期関数もimport
import reducer, { fetchDummy } from "../features/customCounter/customCounterSlice";

describe("extraReducers", () => {
  const initialState = {
    value: 0,
    title: ""
  };
  it("Should output 100 + payload when fulfiled", () => {
    // 非同期関数の接続が成功した場合
    const action = { type: fetchDummy.fulfilled.type, payload: 5};
    const state = reducer(initialState, action);
    expect(state.value).toEqual(105);
  })
})

2.インテグレーションテスト

Reduxとコンポーネントのインテグレーションテスト

Component.tsx
import { useState } from 'react';
import { useSelector, AppDispatch } from './app/store';
import { useDispatch} from "react-redux";
import { fetchDummy, fetchJSON, increment, decrement, incrementByAmout } from './features/customCounter/customCounterSlice';
import './App.css';

function Component() {
  const [number, setNumber] = useState<number>(0);
  const { title, value } = useSelector(state => state.customCounter)
  const dispatch: AppDispatch = useDispatch();
  return (
    <div>
      <span data-testid="count-value">現在のカウント:{value}</span>
      <span>タイトル:{title}</span>
      <button onClick={() => dispatch(increment())}>
        +
      </button>
      <button onClick={() => dispatch(decrement())}>
        -
      </button>
      <button onClick={() => dispatch(incrementByAmout(number | 0))}>
        amount
      </button>
      <input type="text" value={number} onChange={(e) => setNumber(Number(e.target.value)) }/>
      <button onClick={() => dispatch(fetchDummy(5))}>
        Fetch Dummy
      </button>
      <span></span>
      <button onClick={() => dispatch(fetchJSON())}>
        Fetch JSON
      </button>
    </div>
  );
}

export default Component;

テストの観点としては2つです。

  1. コンポーネントでactionを実行し、Reduxのstateから取得したコンポーネント内の値が予想通りかどうかの判定
  2. コンポーネントで非同期関数を実行し、Reduxのstateから取得したコンポーネント内の値が予想通りかどうかの判定
ReduxIntegration.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { Provider } from "react-redux";
// storeを読み込んでいるコンポーネント
import Component from '../Component';
// test用のRedux Storeを新たに定義する為
import { configureStore } from "@reduxjs/toolkit";
import customCounterReducer from "../features/customCounter/customCounterSlice";

describe("Redux Integration Test", () => {
  // test用のstoreを定義
  let store:any;
  // testが走る毎に、test用のstoreを作る
  beforeEach(() => {
    store = configureStore({
      reducer: {
        customCounter: customCounterReducer,
      }
    })
  });
  // プラスボタンを3回押した時に、予測どおりのvalue値になっているかのテスト
  it("Should display value with increment by 1 per click", async () => {
    // Providerコンポーネントで、Reduxを使いたいコンポーネントをwrapする必要があります
    render(
      <Provider store={store}>
        <Component/>
      </Provider>
    );
    await userEvent.click(screen.getByText("+"));
    await userEvent.click(screen.getByText("+"));
    await userEvent.click(screen.getByText("+"));
    expect(await screen.getByTestId("count-value")).toHaveTextContent("3");
  });
  it("Should display value with incrementByAmout by ", async () => {
    render(
      <Provider store={store}>
        <App/>
      </Provider>
    );
    await userEvent.type(screen.getByRole("textbox") ,  "15");
    await userEvent.click(screen.getByText("amount"));
    expect(await screen.getByTestId("count-value")).toHaveTextContent("15");
  });
  it("Should display value with 100 + payload", async () => {
    render(
      <Provider store={store}>
        <Component/>
      </Provider>
    );
    await userEvent.click(screen.getByText("Fetch Dummy"));
   // 非同期の結果を待つ必要があるので、メソッドは「findByTestId」を使用する
    expect(await screen.findByTestId("count-value")).toHaveTextContent("105");
  })
});

Sliceの中の非同期関数とコンポーネントとのインテグレーションテスト

テストの観点としては1つです。

  • コンポーネントでAPIに接続する非同期関数を実行し、Reduxのstateから取得したコンポーネント内の値が予想通りかどうかの判定
ReduxAsync.test.tsx
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { setupServer } from "msw/node";

import { Provider } from "react-redux";
// storeを読み込んでいるコンポーネント
import Component from '../Component';
// test用のRedux Storeを新たに定義する為
import { configureStore } from "@reduxjs/toolkit";
import customCounterReducer from "../features/customCounter/customCounterSlice";

// 非同期関数の結果をmswでmockする
// serverの定義をする
const server = setupServer(
  rest.get("https://jsonplaceholder.typicode.com/posts/1", (req, res, ctx) => {
    // statusと実際返されるjsonを指定できる
    return res(ctx.status(200), ctx.json({title: "Bred dummy"}));
  })
);

beforeAll(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  cleanup();
})
afterAll(() => server.close());

describe("Redux Async API Mocking", () => {
  // test用のstoreを定義
  let store:any;
  // testが走る毎に、test用のstoreを作る
  beforeEach(() => {
    store = configureStore({
      reducer: {
        customCounter: customCounterReducer,
      }
    })
  });
  it("[Fetch success] Should display title in h3 tag", async() => {
    // Providerコンポーネントで、Reduxを使いたいコンポーネントをwrapする必要があります
    render(
      <Provider store={store}>
        <Component />
      </Provider>
    );
    await userEvent.click(screen.getByText("Fetch JSON"));
    expect(await screen.findByText("タイトル:Bred dummy")).toBeInTheDocument();
  });
  it("[Fetch rejected] Should display error title", async() => {
    server.use(
      rest.get("https://jsonplaceholder.typicode.com/posts/1", (req, res, ctx) => {
        // statusと実際返されるjsonを指定できる
        return res(ctx.status(404));
      })
    );
    render(
      <Provider store={store}>
        <App/>
      </Provider>
    );
    await userEvent.click(screen.getByText("Fetch JSON"));
    expect(await screen.findByText("タイトル:404 error")).toBeInTheDocument();
  })
})

Discussion