こんばんは。2回目の投稿です。
前回、以下の投稿をさせていただきました。
create-react-native-appから本格的な開発に移行するためのTipsその1
初投稿にも関わらず多くの閲覧と幾つかの反応を頂けまして、恐縮するばかりです。
下手な事書けない
はじめに
前回の記事の続きを投稿しようかと考えていましたが、
(予想はしていたものの)テストの部分が重かったので
React Nativeのテスト全般についての記事を書こうかと思いました。
React Nativeといいつつ、View以外の部分はReactとおんなじです。
reduxやredux-sagaの知識が前提としてある程度必要になってきます。
参考:
Redux入門【ダイジェスト版】10分で理解するReduxの基礎
redux-sagaで非同期処理と戦う
環境・使用したモジュール
macで開発しています。
- node: v10.5.0(nodebrew経由)
- npm: v6.1.0
- watchman: 4.9.0
create-react-native-appで初期の構築を行っています。
create-react-native-appに組み込まれているため、テスティングフレームワークはjestです。
使用する段階で詳しく触れますが、enzymeなどのテストユーティリティも使用します。
また、別記事を記載しようと思いますが、firebaseを使用しているため、バックエンドは存在しません。
テストの実行方法
create-react-native-appで環境開発をした場合、
デフォルトでpackage.jsonのscripts下が以下のようになっているハズです。
"scripts": {
"test": "jest",
},
この場合、
npm test
というコマンドでテストの実行が可能です。
また、jestはルート以下で__tests__ディレクトリ配下にあるファイルはテストファイルとみなします。
アプリケーションの分割とディレクトリ構成
基本的にreactとreduxのベストプラクティスに従っておけば良いと思っています。
A Better File Structure For React/Redux Applications
個人的React周りベストプラクティス⑤ - Reduxディレクトリ構成編
一方で「お前が作るアプリはそんな大層な構成が必要なほど巨大なのか??ん???」
っていう類の批判もある模様です。割と的を射た指摘だと個人的には思います。
11 mistakes I’ve made during React Native / Redux app development
今回は勉強の意味合いも兼ねているので前者寄りの以下のような構成になっています。
├─App.js
├─package.json
├─enzymeSetup.js
├─.env
├─.babelrc
├─.watchmanconfig
├─.eslintrc
├─src
├─actions
│ ├─hoge/index.js
│ ├─fuga/index.js
│ └─\_\_tests\_\_
│ ├─hoge.js
│ ├─fuga.js
├─components
│ ├─hoge/index.js
│ ├─fuga/index.js
│ └─\_\_tests\_\_
│ ├─eventhandler
│ ├─hoge.js
│ ├─fuga.js
│ └─snapshot
│ ├─hoge.js
│ ├─fuga.js
│ └─\_\_snapshots\_\_
│ ├─hoge.snap
│ ├─fuga.snap
// 以下はactions下と概ね似たような構成
├─containers
├─daos
├─reducers
├─sagas
以降はログインフォームと認証処理部分の一部をテストする体で
以下に示す機能の役割と確認したい事(テスト観点)と実装方法を見ていきます。
- components
- containers
- actions
- sagas
- daos
- reducers
components(View)
役割
- ユーザーに表示されるViewの描画
- ユーザーの操作を受けてイベントを発生させる
確認したいこと
- デザインの意図しない変更がないか
- ボタンクリックなどのイベントが意図通り発生しているか
実装方法
まずテスト対象となるファイル
components/signIn/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { KeyboardAvoidingView, Button } from 'react-native';
export default class SignInScreen extends React.Component {
state = {
email: '',
password: '',
};
static propTypes = {
signIn: PropTypes.func.isRequired,
}
render() {
// airbnbのESLintがpropsとstateについてこの書き方を勧めてくるがまだ慣れない
const { signIn, moveSignUpPage } = this.props;
const { email, password } = this.state;
return (
<KeyboardAvoidingView style={styles.container}>
/*
TextInputなど
*/
<Button
id="signIn"
title="サインイン"
onPress={() => signIn({ email, password })}
/>
</KeyboardAvoidingView>
);
}
}
スナップショットテスト
Snapshot Testing
詳しくは上記に記載のある通りですが、
コンポネントをレンダリングした状態でスナップショットを保存し、前回保存したスナップショットと比較し、差異があればテスト失敗とします。
意図せずViewの構成が変更されてしまった場合に、早くその事を察知できます。
react-test-rendererとjest-mockという二つのモジュールをテスト用に使用しています。
package.jsonのtransformIgnorePatternsやunmockedModulePathPatternsを設定しないと動かない場合があります。
components/__tests__/snapshot/signIn.js
import React from 'react';
import renderer from 'react-test-renderer';
import jest from 'jest-mock';
import SignIn from '../../signIn';
const mockFn = jest.fn();
describe('signIn snapShot', () => {
it('SignIn画面のスナップショットテスト', () => {
const rendered = renderer.create(<SignIn signIn={mockFn} />).toJSON();
expect(rendered).toMatchSnapshot();
});
});
テストを実行したファイルと同階層に__snapshot__というフォルダが掘られ、そこに撮影したスナップショットが保管されます。
components/__tests__/eventhandler/signIn.js
イベントのテスト
airbnbが開発したenzymeを使用します。
最初に渡したモック関数がボタンクリック(onPress)時に呼び出されているかどうかをテストします。
ShallowWrapper.simulateという関数もあるようで、そちらを勧めるReactの記事も多かったのですが
onPressには対応していないようでした。(やり方が間違っていただけかも)
ちょっとハマりポイントでした。
import React from 'react';
import jest from 'jest-mock';
import { shallow } from 'enzyme';
import SignIn from '../../signIn';
const mockFn = jest.fn();
const wrapper = shallow(<SignIn signIn={mockFn} />);
describe('signIn eventhandler', () => {
it('signInボタンがonPressされた時にイベントを発行すること', () => {
const signInButton = wrapper.find('#signIn');
signInButton.props().onPress();
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith({
email: '',
password: '',
});
});
});
containers
役割
- reactの世界(view)とreduxの世界(logic)を繋ぐ
確認すべきこと
- 渡したstateが正しく渡されているかということ
- propsとして渡されたハンドラが意図したアクションを発行していること
実装方法
ほぼ以下の記事の通りの実装です。
Container Component のテストを良い感じに書く
isLoginについては記事書く用に作ったstateです。(コンポネント側で使っていない)
containers/signIn/index.js
import { connect } from 'react-redux';
import { signIn } from '../../actions/authentication';
import SignIn from '../../components/signIn';
function mapStateToProps(state) {
return {
isLogin: state.signIn.isLogin,
};
}
function mapDispatchToProps(dispatch) {
return {
signIn: authInfo => dispatch(signIn(authInfo)),
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(SignIn);
テスト側についてはcomponents同様、enzymeのシャローレンダリングを使用しているほか
redux-mock-storeを使用しています。
container/__tests__/siginIn.js
import React from 'react';
import { shallow } from 'enzyme';
import configureMockStore from 'redux-mock-store';
import { SIGN_IN } from '../../actions/authentication';
import SignIn from '../signIn';
const mockStore = configureMockStore();
const state = {
signIn: {
isLogin: false,
},
};
const emptyUserInfo = {
email: '',
password: '',
};
describe('SignIn Container', () => {
const wrapper = shallow(<SignIn />, { context: { store: mockStore(state) } });
it('isLoginがConponentに渡されていること', () => {
expect(wrapper.props().isLogin).toEqual(state.signIn.isLogin);
});
it('signIn()を実行するとSIGN_INのアクションを発行すること。', () => {
expect(wrapper.props().signIn(emptyUserInfo)).toEqual({
type: SIGN_IN
payload: emptyUserInfo
});
});
});
actions
役割
- Acrionの発行
確認すべきこと
- 期待通りのActionを発行しているかどうか
実装方法
actions/authentication.js
import { createAction } from 'redux-actions';
export const SIGN_IN = 'AUTHENTICATION-SIGN_IN';
export const signIn = createAction(SIGN_IN);
actions/__tests__/authentication.js
import { signIn } from '../authentication';
describe('authentication actions', () => {
it('SIGN_INアクションを生成すること', () => {
const expectedAction = { type: 'AUTHENTICATION-SIGN_IN' };
expect(signIn()).toEqual(expectedAction);
});
});
sagas(非同期処理)
役割
- Actionを受け取ってサーバー通信などの非同期処理を行う
確認すべきこと
- サーバー(今回はfirebase)から受け取った状態を適切にputしているか
実装方法
signInWithEmailAndPasswordはredux-saga-firebaseを参考に自前で実装した関数です。
sagas/authentication.js
import { takeEvery, call, put } from 'redux-saga/effects';
import { SIGN_IN, setUserUid, setErrorInfo } from '../actions/authentication';
import { signInWithEmailAndPassword } from '../daos/authentication';
export function* signIn(action) {
try {
const authInfo = action.payload;
const data = yield call(signInWithEmailAndPassword, authInfo);
yield put(setUserUid(data.user.uid));
} catch (e) {
yield put(setErrorInfo({
errorCode: e.code,
errorMessage: e.message,
}));
}
}
export default function* authentication() {
yield takeEvery(SIGN_IN, signIn);
}
redux-saga-test-planというモジュールを使用しています。
ちょっとここのテストだけ振る舞い駆動っぽくなってしまっている感が否めない
firebaseInitは自前で実装したfirebaseに設定を読み込ませる関数です。
sagas/__tests__/authentication.js
import { expectSaga } from 'redux-saga-test-plan';
import { TEST_USER_EMAIL, TEST_USER_PASS, TEST_USER_TOKEN } from 'react-native-dotenv';
import { firebaseInit } from '../../daos/firebase';
import { signIn } from '../authentication';
import { SIGN_IN, SET_USER_UID, SET_ERROR_INFO } from '../../actions/authentication';
describe('authentication saga', () => {
firebaseInit();
const testUserInfo = { email: TEST_USER_EMAIL, password: TEST_USER_PASS };
const wrongEmailUserInfo = { email: 'dummy@nobady.com', password: TEST_USER_PASS };
it('signInにSIGN_INアクションを渡すとUserTokenの更新を行うアクションを発行すること', () => expectSaga(signIn, {
type: SIGN_IN,
payload: testUserInfo,
}).put({
type: SET_USER_UID,
payload: TEST_USER_TOKEN,
}).run(false));
it('存在しないemailでSignInを実行するとFirebase規定のエラーメッセージを登録するアクションを発行すること', () => expectSaga(signIn, {
type: SIGN_IN,
payload: wrongEmailUserInfo,
}).put({
type: SET_ERROR_INFO,
payload: {
errorCode: 'auth/user-not-found',
errorMessage: 'There is no user record corresponding to this identifier. The user may have been deleted.',
},
}).run(false));
});
daos(ApiCall)
役割
- firebaseのAPIをcallする
確認すべきこと
- firebaseのAPIをcallしているかどうか
実装方法
先述の通り、redux-saga-firebaseを参考に自前で実装した関数です。
データベースアクセス全般をこの層で管理したかったので、あまりモジュールに頼りたくなかった意図があります。
(今のところは完全に車輪の再発明...)
daos/authentication.js
import { call } from 'redux-saga/effects';
import Firebase from './firebase';
export default function* signInWithEmailAndPassword(authInfo) {
const auth = Firebase.auth();
return yield call([auth, auth.signInWithEmailAndPassword], authInfo.email, authInfo.password);
}
daos/__tests__/authentication.js
import { call } from 'redux-saga/effects';
import { TEST_USER_EMAIL, TEST_USER_PASS } from 'react-native-dotenv';
import Firebase, { firebaseInit } from '../firebase';
import { signInWithEmailAndPassword } from '../authentication';
describe('authentication saga', () => {
firebaseInit();
const payload = {
email: TEST_USER_EMAIL,
password: TEST_USER_PASS,
};
it('signInWithEmailAndPasswordがFirebaseのメソッドをcallしていること', () => {
const auth = Firebase.auth();
const ganerator = signInWithEmailAndPassword(payload);
expect(ganerator.next().value).toEqual(
call([auth, auth.signInWithEmailAndPassword], payload.email, payload.password),
);
});
});
reducers
役割
- アクションを受け取って中身の値に応じてstoreの値を書き換える
確認すべきこと
- store(state)が意図通りに書き換えられていること
実装方法
reducers/authentication.js
import { handleActions } from 'redux-actions';
import { SET_USER_UID, SET_ERROR_INFO } from '../actions/authentication';
const defaultState = {
userToken: '',
errorCode: '',
errorMessage: '',
};
export default handleActions({
[SET_USER_UID]: (state, action) => ({
...state,
userToken: action.payload,
}),
[SET_ERROR_INFO]: (state, action) => ({
...state,
errorCode: action.payload.errorCode,
errorMessage: action.payload.errorMessage,
}),
}, defaultState);
reducers/__tests__/authentication.js
import authentication from '../authentication';
import { SET_USER_UID, SET_ERROR_INFO } from '../../actions/authentication';
const testUserToken = 'testToken';
const testErrorCode = 'testErrorCode';
const testErrorMessage = 'testErrorMessage';
describe('authentication reducer', () => {
it('何も渡さない場合にinitialStateを返すこと', () => {
expect(authentication(undefined, {})).toEqual({
userToken: '',
errorCode: '',
errorMessage: '',
});
});
it('SET_USER_UIDアクションを渡すと、userTokenを書き換えること', () => {
expect(authentication(undefined, {
type: SET_USER_UID,
payload: testUserToken,
})).toEqual({
userToken: testUserToken,
errorCode: '',
errorMessage: '',
});
});
it('SET_ERROR_INFOアクションを渡すと、errorCodeとerrorMessageを書き換えること', () => {
expect(authentication(undefined, {
type: SET_ERROR_INFO,
payload: {
errorCode: testErrorCode,
errorMessage: testErrorMessage,
},
})).toEqual({
userToken: '',
errorCode: testErrorCode,
errorMessage: testErrorMessage,
});
});
});
カバレッジを見る
jestは非常に優秀でカバレッジの確認も簡単に行えます。
以下の記事の後半に詳細の記載があります。
Facebook 製 JavaScript テストツール Jest を使ってテストする ( Babel, TypeScript のサンプル付き )
jest --coverage
というコマンドでカバレッジを見る事ができます。(コンソールに表示&HTMLとして詳細出力)
以下は実際に私が作りかけているアプリのカバレッジです。
テストケース等はまだちゃんと考慮していない & 一部削除予定のソースがある段階でこのカバレッジなので
そのあたりをしっかり行えば、本投稿もタイトル詐欺にはならずに済むのではないでしょうか
なお、htmlの方は各ファイルの詳細な情報を見る事が可能です。
網羅しきれていない処理は以下のように赤く表示されます。
これによって
「ボタンの試験はしていたけど、入力フォームのテスト忘れてた!」なんて事に気付くことが出来るのです。
おわりに
試行錯誤しながら、なんとかひとしきりテスト出来る状態にはなりましたが
私自身まだまだ勉強不足なため、間違っている箇所等があるかもしれません。
ご意見・ご指摘等ありましたらコメントをいただけますと幸いです。