以下のリポジトリを参考にしてください
github.com
記事が長くなってしまうので、プロダクションのコードは折りたたみしておきます。
使用ライブラリ
- Jest
- Enzyme
- jest-serializer-enzyme(enzymeでsnapshotとるため)
- redux-mock-store
- redux-saga-test-plan
enzymeですが、snapshotに対応してないため react-test-rendererが最近の流れかもしれません。(むしろ推奨?)
facebook.github.io
github.com
react-router
github.com
react-router@4とreact-router-redux@5を使っています。
Routes.js
各パスをルーティングする部分です。
親がこのファイルをimportします。
import Routes from '../Routes';
const Root = () => (
<Provider store={store}>
<ConnectedRouter history={history}>
<Routes />
</ConnectedRouter>
</Provider>
);
このようにファイルを分離することによりテストをしやすくします。
import React from 'react';
import { Switch, Route } from 'react-router';
import App from './components/App';
import Login from './containers/Login';
const Router = () => (
<App>
<Switch>
<Route path="/login" component={Login} />
<Route><div>hello!</div></Route> {/* root */}
</Switch>
</App>
);
export default Router;
テストコード
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router';
import configureStore from 'redux-mock-store';
import { render } from 'enzyme';
import Routes from '../../src/renderer/routes';
import rootReducer from '../../src/renderer/reducers';
describe('routes', () => {
const createDOM = (path = '/') => {
const state = createStore(rootReducer).getState();
const store = configureStore()(state);
return render(
<Provider store={store}>
<MemoryRouter initialEntries={[path]}>
<Routes />
</MemoryRouter>
</Provider>
);
};
it('should render the root page', () => {
expect(createDOM()).toMatchSnapshot();
});
it('should render the login page', () => {
expect(createDOM('/login')).toMatchSnapshot();
});
});
4系から入ったMemoryRouter
を使い、テストをします。
基本的に、スナップショットで比較するだけで自分はいいと思っています。(もちろん確認でwrapper内に対してfind
, contains
等してもいいとは思います)
react-router-reduxを使っている場合、storeに入れるrouteのlocationのkey
がテスト毎ごとに異なるので、mount
ではなくrender
を使います。
Redux-saga
github.com
非同期処理をmiddlewareで行うために使います。
Root
各ファイル(e.g. ページ毎)のforkを一括で行います。
import { effects } from 'redux-saga';
import auth from './auth';
import error from './error';
import type { Effect } from 'redux-saga';
function *rootSaga(): Generator<Effect, void, *> {
yield [
effects.fork(auth),
effects.fork(error)
];
}
export default rootSaga;
テストコード
import rootSaga from '../../../src/renderer/sagas';
describe('root saga', () => {
it('should register sagas', () => {
const tasks = 2;
expect(rootSaga().next().value.length).toEqual(tasks);
});
});
昔は、rootSaga()._invoke().value.length
で取れていまいたが、今は取れません。
ここでは登録されているタスク数(各ファイル数)の個数を確認します。
Partial
それぞれのファイル内のタスクのビジネスロジックが書かれます。
このファイルのrootでそれぞれのactionとタスクを紐付けし、その処理を追えた後再度アクションを発行します。
サンプルコードなので冗長な部分もあります
import { takeEvery, effects } from 'redux-saga';
import { getMail } from './selectors';
import type { Effect } from 'redux-saga';
import type { Login } from '../types/actions/auth';
const {
put,
select
} = effects;
function *login(action: Login): Generator<Effect, void, *> {
try {
const mail = yield select(getMail);
if (action.mail !== mail) {
yield put({
type : 'LOGIN_SUCCESS',
payload: {
mail: action.mail
}
});
}
else {
throw {
code: 'ERROR_LOGIN'
};
}
} catch (e) {
yield put({
type : 'ERROR',
error: e
});
}
}
function *logout(): Generator<Effect, void, *> {
try {
const mail = yield select(getMail);
if (mail !== '') {
yield put({ type: 'LOGOUT_SUCCESS' });
}
else {
throw {
code: 'ERROR_LOGOUT'
};
}
} catch (e) {
yield put({
type : 'ERROR',
error: e
});
}
}
export default function *authProcess(): Generator<Effect, void, *> {
yield takeEvery('LOGIN', login);
yield takeEvery('LOGOUT', logout);
}
テストコード
import { expectSaga } from 'redux-saga-test-plan';
import auth from '../../../src/renderer/sagas/auth';
describe('auth saga', () => {
const storeState = {
auth: {
mail: 'a@b.com'
}
};
it('should take on the LOGIN action', () => {
return expectSaga(auth)
.withState(storeState)
.put({
type : 'LOGIN_SUCCESS',
payload: {
mail: '--a@b.com'
}
})
.dispatch({
type: 'LOGIN',
mail: '--a@b.com'
})
.run();
});
it('should fail on the LOGIN action', () => {
return expectSaga(auth)
.withState(storeState)
.put({
type : 'ERROR',
error: {
code: 'ERROR_LOGIN'
}
})
.dispatch({
type: 'LOGIN',
mail: 'a@b.com'
})
.run();
});
it('should take on the LOGOUT action', () => {
return expectSaga(auth)
.withState(storeState)
.put({ type: 'LOGOUT_SUCCESS' })
.dispatch({ type: 'LOGOUT' })
.run();
});
it('should fail on the LOGOUT action', () => {
return expectSaga(auth)
.withState({
auth: {
mail: ''
}
})
.put({
type : 'ERROR',
error: {
code: 'ERROR_LOGOUT'
}
})
.dispatch({ type: 'LOGOUT' })
.run();
});
});
redux-saga-test-planを使ってテストしていきます。
基本的にdispatch
して、putでyield
された結果(reducerへ渡す部分)を期待します。
自分はエラー系を全部sagaとして一枚挟んでいます。
template-electron/error.js at master · my-dish/template-electron · GitHub
Selectors
saga内で状態がほしいときに取得する関数を定義します。
import type { State } from '../types/states';
export const getMail = (state: State): string => state.auth.mail;
テストコード
import { createStore } from 'redux';
import rootReducer from '../../../src/renderer/reducers';
import * as selectors from '../../../src/renderer/sagas/selectors';
describe('selectors', () => {
const storeState = createStore(rootReducer).getState();
it('should get auth.email', () => {
expect(selectors.getMail(storeState)).toEqual('');
});
});
自分はselectorsに関してはredux-saga-test-planを使いません。
selectorsは単純な関数であるので、stateだけしっかりしていればいいかなと思っているからです。
さいごに
storeでredux-devtools-extensionとかを使っているとテストできない箇所が出てしまうのどうにかならないかな。。。
今月の28日、Node学園 26時限目やりますので是非きてくださいね!(募集は26から)
react, vue, angular, jsconf and Node Collaboratos Summit, webpack3(多分...), etc...の話が聞ける予感する 😁
nodejs.connpass.com