#はじめに
現場で開発するプロジェクトが大きくなるにつれて、細々としたバグ修正を行うことが増えてきました。
そのため、テストのカバー率を上げることが急務になっているのですが、Reactのテストについて私が勉強不足だったため、テストを含めた実装に時間がかかりチームの開発速度を落としてしまいました。
このままではアカンということで、Reactのテスト(Jest)について基礎から学び、見返しそうなポイントをメモとして記事にまとめることにしました。
#同期関数のテスト
以下のように、searchinput
へ入力した文字がdb
内のデータに含まれていれば、最大3つまで返すような関数を考えます。
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(~)
で指定した結果が一致するかどうかを確かめています。
//テストする関数を読み込む
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(テスト単体)を表しています。
また、試しに3つ目のテストをtoEqual(4)
にしてわざと失敗させると、以下のようにテストが失敗(failed)したことと失敗した内容を教えてくれます。
ちなみに、package.json
のscript
に"test": "jest --watch *.js"
を追記すると、npm test
を一回実行するだけで、テストファイルを修正するたびにテストを再実行するようになります。
"scripts": {
"test": "jest --watch *.js"
},
#非同期関数のテスト
API(The Star Wars API)からデータを取得して、countとresultsをオブジェクトとして返すような関数をテストします。
const getPeoplePromise = (fetch) => {
return fetch('http://swapi.py4e.com/api/people')
.then((response) => response.json())
.then((data) => {
return {
count: data.count,
results: data.results,
};
});
};
同期関数と同じような感じでテストを作成して実行すると、想定した値が返ってきているにも関わらずテストが失敗してしまいます。
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);
});
});
これは非同期処理内の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を作成します。
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
が作られます。
import React from 'react';
import { shallow } from 'enzyme';
import Card from './Card';
it('renders without crashing', () => {
expect(shallow(<Card/>)).toMatchSnapshot();
});
// 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を更新することができます。
また、npm test -- --coverage
を実行すると、単体テストのカバレッジ(カバー率)を確認することができます。
#Reduxのテスト
ReduxのReducerとActionのテストについても簡単にまとめました。
##Reducerのテスト
以下のようなReducerのテストを考えます。
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' }
が返ってきます。
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のテストを考えます。
export const setSearchField = (text) => ({ type: CHANGE_SEARCHFIELD, payload: text })
Action Creatorの引数text
とActionの中身expectedAction
をそれぞれつくり、expect(actions.setSearchField(text)).toEqual(expectedAction)
でアサーションを行うことができます。
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のテストを考えます。
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
をラッピングしてあげる必要があります。
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' })
のようにアサーションを行っています。
#参考資料