LoginSignup
18
22

More than 1 year has passed since last update.

【React】Jestを現場で活かすために基礎から学びました

Posted at

はじめに

現場で開発するプロジェクトが大きくなるにつれて、細々としたバグ修正を行うことが増えてきました。
そのため、テストのカバー率を上げることが急務になっているのですが、Reactのテストについて私が勉強不足だったため、テストを含めた実装に時間がかかりチームの開発速度を落としてしまいました。
このままではアカンということで、Reactのテスト(Jest)について基礎から学び、見返しそうなポイントをメモとして記事にまとめることにしました。

同期関数のテスト

以下のように、searchinputへ入力した文字がdb内のデータに含まれていれば、最大3つまで返すような関数を考えます。

script.js
const googleSearch = (searchInput, db) => {
  const matches = db.filter((website) => {
    return website.includes(searchInput);
  });
  return matches.length > 3 ? matches.slice(0, 3) : matches;
};

module.exports = googleSearch;

script.jsと同じ階層に、テスト用のファイルであるscript.test.jsを作成します。
テストでは様々な入力に対して、正しい結果が返ってくるかどうかをテストします。

ファイルの中でテストする関数googleSearchを読込み、検索データのモックをdbMockとして作成します。
各テストをitの中に記述していき、関連しているテスト(入力に対して正しい結果が返ってくるか)をdescribeとして1つにまとめます。

テストでは、expect(googleSearch('testtest', dbMock)).toEqual([]);のように、expect(~)で期待される結果に対して、toEqual(~)で指定した結果が一致するかどうかを確かめています。

script.test.js
//テストする関数を読み込む
const googleSearch = require('./script');

//dbのデータモックをつくる
const dbMock = ['dog.com', 'cheesepuff.com', 'disney.com', 'dogpictures.com'];

//関連するテストをブロックにまとめる
describe('googleSearch', () => {
  //入力に対して正しい結果が返ってくるかどうか
  it('is searching google', () => {
    //検索結果0(空)のケース
    expect(googleSearch('testtest', dbMock)).toEqual([]);
    //検索結果が2のケース
    expect(googleSearch('dog', dbMock)).toEqual(['dog.com', 'dogpictures.com']);
  });

  //入力がundefined, nullの場合に正しい結果(空)が返ってくるかどうか
  it('work with undefined and null input', () => {
    //入力がundefinedのケース
    expect(googleSearch(undefined, dbMock)).toEqual([]);
    //入力がnullのケース
    expect(googleSearch(null, dbMock)).toEqual([]);
  });

  //入力に対して4つのデータが該当する場合にちゃんと3つのデータが返ってくるかどうか
  it('does not return more than 3 matches', () => {
    expect(googleSearch('.com', dbMock).length).toEqual(3);
  });
});

テストはnpm run testで実行することができます。

実際に実行してみるとテストは以下のように成功(passed)します。
Test Suitesはdescribe(テストブロック)の数、Testsはit(テスト単体)を表しています。
スクリーンショット 2021-05-28 7.40.15.png

また、試しに3つ目のテストをtoEqual(4)にしてわざと失敗させると、以下のようにテストが失敗(failed)したことと失敗した内容を教えてくれます。
スクリーンショット 2021-05-28 7.44.18.png

ちなみに、package.jsonscript"test": "jest --watch *.js"を追記すると、npm testを一回実行するだけで、テストファイルを修正するたびにテストを再実行するようになります。

package.json
  "scripts": {
    "test": "jest --watch *.js"
  },

非同期関数のテスト

API(The Star Wars API)からデータを取得して、countとresultsをオブジェクトとして返すような関数をテストします。

script2.js
const getPeoplePromise = (fetch) => {
  return fetch('http://swapi.py4e.com/api/people')
    .then((response) => response.json())
    .then((data) => {
      return {
        count: data.count,
        results: data.results,
      };
    });
};

同期関数と同じような感じでテストを作成して実行すると、想定した値が返ってきているにも関わらずテストが失敗してしまいます。

script2.test.js
const fetch = require('node-fetch');
const swapi = require('./script2');

it('call swapi to get people', () => {
  swapi.getPeople(fetch).then((data) => {
    expect(data.count).toEqual(87);
  });
});

スクリーンショット 2021-05-29 15.34.01.png

これは非同期処理内のexpect(data.count).toEqual(87);が実行される前に、テストが終了してしまうことが原因です。
そのため、非同期処理でテストを行う際にはこのような問題を防ぐための工夫が必要となります。

doneの使用

1つ目はdoneを使用するケースです。
テスト関数の第一引数にdoneを渡し、テストが全て終了したらdone()を呼ぶようにします。
このようにすることで、doneが呼ばれるまでexpect(data.count).toEqual(87);の実行を待機するようになります。

it('call swapi to get people', (done) => {
  swapi.getPeople(fetch).then((data) => {
    expect(data.count).toEqual(87);
    done();
  });
});

expect.assertions + return Promise

2つ目がテスト関数でPromiseを返し、expect.assertionsで想定した数のアサーション(expect~)が呼ばれているかを確認するケースです。
以下のような記述になります。

it('call swapi to get people with a promise', () => {
  expect.assertions(1);
  return swapi.getPeoplePromise(fetch).then((data) => {
    expect(data.count).toEqual(87);
  });
});

Enzymeを使ったテスト

EnzymeはReactでの単体テストの記述を簡単にしてくれるairbnb製のテストツールです。
https://enzymejs.github.io/enzyme/

テスト内でコンポーネントをレンダリングする際に、ひもづいた子コンポーネントを無視してくれるshallowレンダリングという機能があり、コンポーネント単体でテストを行えるようにしてくれるのが特長です。

Snapshotテスト

Snapshotテストとは、一般的にあるプログラムの出力を以前の出力と比較し、両者に差分があるかをテストする手法のことをいいます。
ReactにおけるSnapshotテストはUIが予期せず変更されていないかを確かめるのに非常に有用な手法となります。

例として、以下のコンポーネントCard.jsのSnapshotを作成します。

Card.js
import React from 'react';

const Card = ({ name, email, id }) => {
  return (
    <div className="tc grow bg-light-green br3 pa3 ma2 dib bw2 shadow-5">
      <img alt="robots" src={`https://robohash.org/${id}?size=200x200`} />
      <div>
        <h2>{name}</h2>
        <p>{email}</p>
      </div>
    </div>
  );
};

export default Card;

以下のようにテストを記述して実行すると、テストファイルと同じ階層に__snapshots__フォルダが作られ、フォルダ内にCard.test.js.snapが作られます。

Card.test.js
import React from 'react';
import { shallow } from 'enzyme';
import Card from './Card';

it('renders without crashing', () => {
  expect(shallow(<Card/>)).toMatchSnapshot();
});
Card.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders without crashing 1`] = `
<div
  className="tc grow bg-light-green br3 pa3 ma2 dib bw2 shadow-5"
>
  <img
    alt="robots"
    src="https://robohash.org/undefined?size=200x200"
  />
  <div>
    <h2 />
    <p />
  </div>
</div>
`;

Card.jsの中身を一部修正して再度テストを実行すると、以下のように失敗します。
この変更が予期したものでなければファイルを元に戻し、予期したものであればuを押すことでSnapshotを更新することができます。
スクリーンショット 2021-05-29 17.10.57.png

また、npm test -- --coverageを実行すると、単体テストのカバレッジ(カバー率)を確認することができます。
スクリーンショット 2021-05-29 18.09.14.png

Reduxのテスト

ReduxのReducerとActionのテストについても簡単にまとめました。

Reducerのテスト

以下のようなReducerのテストを考えます。

reducers.js
const initialStateSearch = {
  searchField: ''
}

export const searchRobots = (state=initialStateSearch, action={}) => {
  switch (action.type) {
    case CHANGE_SEARCHFIELD:
      return Object.assign({}, state, {searchField: action.payload})
    default:
      return state
  }
}

ReducerにStateとActionを与えて、想定したStateが返ってくるかどうかをテストします。
undefinedのStateと空のActionがReducerに渡された場合、初期状態のState(initialStateSearch)がそのまま返ってきます。
また、Action(CHANGE_SEARCHFIELD)がReducerに渡された場合、{ searchField: 'abc' }が返ってきます。

reducers.test.js
const initialStateSearch = {
  searchField: '',
};
describe('searchRobots reducer', () => {
  //StateがundefinedでActionが空の場合
  it('should return the initial state', () => {
    expect(reducers.searchRobots(undefined, {})).toEqual({
      searchField: '',
    });
  });
  //StateがinitialStateSearchでActionがCHANGE_SEARCHFIELDの場合
  it('should handle CHANGE_SEARCHFIELD', () => {
    expect(
      reducers.searchRobots(initialStateSearch, {
        type: types.CHANGE_SEARCHFIELD,
        payload: 'abc',
      })
    ).toEqual({
      searchField: 'abc',
    });
  });
});

Action Creatorのテスト

Action Creatorのテストを同期/非同期のケースでまとめました。

同期的なAction Creatorのテスト

オブジェクトのActionを返す同期的なAction Creatorのテストを考えます。

action.js
export const setSearchField = (text) => ({ type: CHANGE_SEARCHFIELD, payload: text })

Action Creatorの引数textとActionの中身expectedActionをそれぞれつくり、expect(actions.setSearchField(text)).toEqual(expectedAction)でアサーションを行うことができます。

action.test.js
describe('actions', () => {
  it('should create an action to search', () => {
    const text = 'Finish docs';
    const expectedAction = {
      type: types.CHANGE_SEARCHFIELD,
      payload: text,
    };
    expect(actions.setSearchField(text)).toEqual(expectedAction);
  });
});

非同期的なAction Creatorのテスト

dispatchを返す非同期的なAction Creatorのテストを考えます。

action.js
export const requestRobots = () => (dispatch) => {
  dispatch({ type: REQUEST_ROBOTS_PENDING })
  apiCall('https://jsonplaceholder.typicode.com/users')
    .then(data => dispatch({ type: REQUEST_ROBOTS_SUCCESS, payload: data }))
    .catch(error => dispatch({ type: REQUEST_ROBOTS_FAILED, payload: error }))
}

redux-mock-storeというライブラリのconfigureMockStoreでstoreのモック(mockStore)を作成します。
非同期のAction Creatorをテストするため、mockStoreを作成する際にはconst mockStore = configureMockStore([thunkMiddleware])のようにthunkMiddlewareをラッピングしてあげる必要があります。

action.test.js
import * as actions from './actions';
import * as types from './constants';
import configureMockStore from 'redux-mock-store';
import thunkMiddleware from 'redux-thunk';

export const mockStore = configureMockStore([thunkMiddleware]);

describe('Fetch robots action PENDING', () => {
  it('should create a Pending action on request Robots', () => {
    const store = mockStore(); //storeを作成する
    store.dispatch(actions.requestRobots()); //Action CreatorをDispatch
    const action = store.getActions(); //dispatchしたactionを取り出せる
    expect(action[0]).toEqual({ type: 'REQUEST_ROBOTS_PENDING' });
  });
});

const store = mockStore()で作成したstoreの中身は以下のようになっています。

      { getState: [Function: getState],
        getActions: [Function: getActions],
        dispatch: [Function],
        clearActions: [Function: clearActions],
        subscribe: [Function: subscribe],
        replaceReducer: [Function: replaceReducer] }

store.dispatch(actions.requestRobots())でAction Creator(requestRobots)をDispatchすると、返ってきたActionがgetActionsに以下のように配列として保存されます。

[ { type: 'REQUEST_ROBOTS_PENDING' } ]

最後に返ってきたActionに対してexpect(action[0]).toEqual({ type: 'REQUEST_ROBOTS_PENDING' })のようにアサーションを行っています。

参考資料

18
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
22