はじめに
美容室に行った時に本棚にナルトが置いてあって読んだんですけど、なんか懐かしくなっちゃって帰り道こっそり薄暗い中でナルト走りしました。
はい、create-react-appで作ったプロジェクトをテストし始めました。折角なんでまとめながらやります。
セットアップ
srcフォルダの一個下の階層に__Tests__フォルダを作ります。このフォルダにテストファイルを放り込んでおくことでtestコマンドによってテストが実行されます。他の方法
次に__Tests__フォルダと同じ階層に”setupTests.js”を作り編集します。
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
そして、パッケージをインストール
npm i --save-dev enzyme enzyme-adapter-react-16
実行
”npm test”コマンドでjestを実行しましょう。ターミナルがテスト画面?になります。ファイルを保存すると追加したテストたちが自動で実行されていきます。僕は初回実行時に下記のエラーが出ました。
エラー
(FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-22)
create-react-appのnpm testを実行するとjestは監視モードで起動するそうです。監視ツールがないとこのエラーが出るみたいです。解決策は簡単でwatchmanという監視ツールをインストールするだけです。
解決策
brew install watchman
react&reduxプロジェクトをテストする
react&reduxプロジェクトのテストは主にaction、reducer、componentにわけてテストするみたいです。redux公式
actionのテスト
アクションの役割はアクションタイプと場合によってはステートを変更したい値を含んだオブジェクトを生成することです。そのため、まずそれぞれのアクションによって生成されるべきオブジェクトを定義します。次に、アクションを実行します。そして、定義したオブジェクトと実行したアクションによって返されるオブジェクトが同じならテストをパスします。
export const changeName = (name) => ({
type: CHANGE_NAME,
name,
});
こんなアクションがあったとして、
import actions from './action.js';
describe('アクションたちのテスト', () => {
test('名前を変更するアクションが生成されること', () => {
const name = '名前';
const expected = {
type: CHANGE_NAME,
name,
};
expect(actions.changeName(name)).toEqual(expected);
});
});
こんな感じ
非同期処理を含むactionのテスト
非同期処理を含むactionのテストはreduxストアをテストファイルで擬似的に作り、redux-thunkなどのミドルウェアをテストでも使えるようにします。HTTPリクエストのモックはfetchを使っている場合はfetch-mockをredux公式では使用しています。しかし、axiosを使用している場合は別のプラグインが必要です。fetch-mockでaxiosのモックをすると下記のようなエラーが出ます。
connect ECONNREFUSED 127.0.0.1:80
axiosをモックするためには"axios-mock-adapter"を使います。fetch-mockと使い方は似ています。
const requestData = () => ({
type: types.REQUEST_DATA,
});
const receiveDataSuccess = (allShareData) => ({
type: types.RECEIVE_DATA_SUCCESS,
allShareData,
});
const receiveDataFailed = () => ({
type: types.RECEIVE_DATA_FAILED,
});
export const getAllShare = () => (dispatch) => {
dispatch(requestData());
return axios.get(`${process.env.REACT_APP_PROXY}/api/share`)
.then((response) => {
console.log(response);
const products = response.data;
dispatch(receiveDataSuccess(products));
})
.catch((err) => {
console.error(new Error(err));
dispatch(receiveDataFailed());
});
};
こんなんがあったとして
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import * as actions from '../../actions/index';
import * as types from '../../constants/actionTypes';
import products from '../../mockApi/products';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const mock = new MockAdapter(axios);
describe('get products actions', () => {
afterEach(() => {
mock.restore();
});
test('async action success', () => {
mock.onGet(`${process.env.REACT_APP_PROXY}/api/share`).reply(200, [...products]);
const expected = [
{ type: types.REQUEST_DATA },
{ type: types.RECEIVE_DATA_SUCCESS, allShareData: products },
];
const store = mockStore();
return store.dispatch(actions.getAllShare()).then(() => {
// console.log(JSON.stringify(store.getActions()));
expect(store.getActions()).toEqual(expected);
});
});
});
axios-mock-adapterによって返されるレスポンスに注意してください。コンソールで確認すると.replyの第二引数で指定したデータが"data"プロパティにあり、ちゃんとステータスコードとかもオブジェクトに入っています。
redux-mock-storeで作成したストアには、非同期処理でディスパッチされたアクションたちが配列で格納されていきます。あとは期待されるアクションの形と比べて同じならおっけみたいな感じになっています。
reducerのテスト
reducerの役割はまず受け取ったアクションの値を状態に適用します。そして、新しい状態を返します。
ダミーのアクションと必要なステートを用意して、reducerの引数に渡します。期待された通りのステートが帰ってきたらテストをパスします。(actionCreaterを突っ込む例などもありましたが、redux公式のサンプル通りに今回はやりました。)
import {
CHANGE_PRODUCTNAME,
} from './actionTypes';
const initialState = {
shareForm: {
productName: '',
hoge: ''.
hana: '',
hoji: '',
hoji: '',
initializeForm: '',
},
};
const shareFormReducer = (state = initialState.shareForm, action) => {
switch (action.type) {
case CHANGE_PRODUCTNAME:
return {
...state,
productName: action.productName,
};
default:
return state;
}
};
export default shareFormReducer;
こんなんがあって
import reducer from './reducer.js';
import {
CHANGE_PRODUCTNAME,
} from './actionTypes';
describe('shareForm reducer', () => {
test('初期値を返すこと', () => {
const state = undefined;
const action = {};
const result = reducer(state, action);
const expected = {
productName: '',
hoge: ''.
hana: '',
hoji: '',
hoji: '',
initializeForm: '',
};
expect(result).toEqual(expected);
});
test('CHANGE_PRODUCTNAMEが正しく処理されること', () => {
const state = {
productName: '',
};
const action = {
type: CHANGE_PRODUCTNAME,
productName: 'プロダクト名',
};
const result = reducer(state, action);
const expected = {
productName: action.productName,
};
expect(result).toEqual(expected);
});
});
ポイントは必ず初期値を返すかどうかのテストが必要です。あと、必要な分の(アクションからみて変更されることが期待される)ステートだけ用意することです。redux公式
component
react-reduxでconnect()されているコンポーネント(connected-component)は、connect()から切り離してテストするみたいです。
Containerフォルダーとか作って、exportされたコンポーネントをconnnect()している方は心配ないと思いますが、componentとmapStatePropsとかを同じファイルに書いている方は切り離します。
redux公式から引用
import { connect } from 'react-redux'
class App extends Component {
/* ... */
}
export default connect(mapStateToProps)(App)
コンポーネントもexportする
import { connect } from 'react-redux'
// Use named export for unconnected component (for tests)
export class App extends Component {
/* ... */
}
// Use default export for the connected component (for app)
export default connect(mapStateToProps)(App)
これをimportするには
import ConnectedApp, { App } from './App'
切り離したら各コンポーネントが正しく表示されるかテストしていきます。
これはよくありそうな、データをフェッチしているときはローディング画面を表示して〜、みたいなコンポーネントです。
import React from 'react';
import Grid from '@material-ui/core/Grid';
import DetailedCard from './DetailedCard';
import key from '../../utils/listKeyGenerator';
const RentScreen = ({
isFetching,
dataArray,
...rest
}) => (
<div>
{
isFetching
? <h2>Now Loading...</h2>
: (
<div>
<Grid item xs={12}>
<Grid container justify="center" spacing={2}>
{dataArray.map((share) => (
<Grid key={key()} item>
<DetailedCard
productName={share.productName}
img={share.productImg}
description={share.description}
period={share.period}
shippingArea={share.shippingArea}
days={share.days}
id={share.id}
name={share.name}
avatar={share.avatar}
comment={share.comment}
rest={rest}
/>
</Grid>
))}
</Grid>
</Grid>
</div>
)
}
</div>
);
export default RentScreen;
この場合はローディング中の画面とローディング後の画面が表示されているか確認します。また、スナップショットがなければ作成し、あれば以前のスナップショットと比較して正しくレンダリングされているかテストします。
import React from 'react';
import { shallow } from 'enzyme';
import RentScreen from '../../components/RentScreen/index';
import DetailCard from '../../components/RentScreen/DetailedCard';
import products from '../../mockApi/products';
describe('<RentScrren />', () => {
it('<DetailCard />を表示していること', () => {
const wrapper = shallow(<RentScreen
isFetching={false}
shareInformationsArray={products}
/>);
expect(wrapper.find(DetailCard).length).toBe(7);
expect(wrapper.debug()).toMatchSnapshot();
});
it('ロード画面を表示すること', () => {
const wrapper = shallow(<RentScreen
isFetching
shareInformationsArray={products}
/>);
expect(wrapper.find('h2').length).toBe(1);
expect(wrapper.debug()).toMatchSnapshot();
});
});
よく分からないことがありました。shallow関数で返ってくるオブジェクトをコンソールで出力すると空のオブジェクトが返ってきているように見えるのですが、各プロパティはちゃんと使えます。そして、スナップショットテストをするときに”expect(wrapper.debug())”、こんな感じにしなくてはなりません。(参考にしたものではwrapperだけで出来ていました)
enzymeのバージョンの問題なのかjestのバージョンの問題なのかよくわかりませんでした。
でもまあ、ある時点でレンダリングされたものと比較することができればスナップショットの役割は果たせるのかなと妥協しました。
まとめ
まだテストは全て終わっていないのですが、とりあえず一通りやってみたため一区切りつけます。コンポーネントのテスト方法は色々なパターンが出てくると思うでまたまとめたいと思っています。それでは!ε=ε=ε=ε=ε=ε=┌( ̄ー ̄)┘(ナルト走り)