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

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

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

```js: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(~)`で指定した結果が一致するかどうかを確かめています。

```js: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/59f6c999-f92e-96bb-442c-74f85cc219cb.png)

また、試しに3つ目のテストを`toEqual(4)`にしてわざと失敗させると、以下のようにテストが失敗(failed)したことと失敗した内容を教えてくれます。
![スクリーンショット 2021-05-28 7.44.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/ef5245d6-31fc-50b8-363c-98b0cff8cdf0.png)

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

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

#非同期関数のテスト
API(<a href="https://swapi.dev/">The Star Wars API</a>)からデータを取得して、countとresultsをオブジェクトとして返すような関数をテストします。

```js: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,
      };
    });
};
```
同期関数と同じような感じでテストを作成して実行すると、想定した値が返ってきているにも関わらずテストが失敗してしまいます。

```js: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/7c1f8605-8e01-3a27-21db-4d7baeadaf40.png)

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

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

```js
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~)が呼ばれているかを確認するケースです。
以下のような記述になります。

```js
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を作成します。

```js: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`が作られます。

```js: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/7e52654e-0085-3fc1-407d-4da77481318b.png)

また、`npm test -- --coverage`を実行すると、単体テストのカバレッジ(カバー率)を確認することができます。
![スクリーンショット 2021-05-29 18.09.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/20c3b7ee-175b-f4dd-2a57-9319f57483e6.png)

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

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

```js: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' }`が返ってきます。

```js: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のテストを考えます。

```js:action.js
export const setSearchField = (text) => ({ type: CHANGE_SEARCHFIELD, payload: text })
```
Action Creatorの引数`text`とActionの中身`expectedAction`をそれぞれつくり、`expect(actions.setSearchField(text)).toEqual(expectedAction)`でアサーションを行うことができます。

```js: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のテストを考えます。

```js: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`をラッピングしてあげる必要があります。

```js: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' })`のようにアサーションを行っています。

#参考資料

https://github.com/sapegin/jest-cheat-sheet

https://qiita.com/official-events/b9ad63394fa2635dfca9