React NativeのViewもLogicも非同期処理もjestで全部テストする

こんばんは。2回目の投稿です。

前回、以下の投稿をさせていただきました。
[create-react-native-appから本格的な開発に移行するためのTipsその1](https://qiita.com/to4-yanagi/items/74c62c723e9bcd2883e6)

初投稿にも関わらず多くの閲覧と幾つかの反応を頂けまして、恐縮するばかりです。
~~下手な事書けない~~

## はじめに

前回の記事の続きを投稿しようかと考えていましたが、
（予想はしていたものの）テストの部分が重かったので
React Nativeのテスト全般についての記事を書こうかと思いました。

React Nativeといいつつ、View以外の部分はReactとおんなじです。
[redux](https://redux.js.org/)や[redux-saga](https://github.com/redux-saga/redux-saga/blob/master/README_ja.md)の知識が前提としてある程度必要になってきます。

参考：
[Redux入門【ダイジェスト版】10分で理解するReduxの基礎](https://qiita.com/kiita312/items/49a1f03445b19cf407b7)
[redux-sagaで非同期処理と戦う](https://qiita.com/kuy/items/716affc808ebb3e1e8ac)

## 環境・使用したモジュール

macで開発しています。
- node: v10.5.0(nodebrew経由)
- npm: v6.1.0
- watchman: 4.9.0

[create-react-native-app](https://github.com/react-community/create-react-native-app)で初期の構築を行っています。
create-react-native-appに組み込まれているため、テスティングフレームワークは[jest](https://jestjs.io/)です。
使用する段階で詳しく触れますが、[enzyme](https://github.com/airbnb/enzyme)などのテストユーティリティも使用します。

また、別記事を記載しようと思いますが、[firebase](https://console.firebase.google.com/?hl=ja)を使用しているため、バックエンドは存在しません。

## テストの実行方法

create-react-native-appで環境開発をした場合、
デフォルトでpackage.jsonのscripts下が以下のようになっているハズです。

```
  "scripts": {
    "test": "jest",
  },
```

この場合、

```
npm test
```
というコマンドでテストの実行が可能です。

また、jestはルート以下で\_\_tests\_\_ディレクトリ配下にあるファイルはテストファイルとみなします。

## アプリケーションの分割とディレクトリ構成

基本的にreactとreduxのベストプラクティスに従っておけば良いと思っています。

[A Better File Structure For React/Redux Applications](https://marmelab.com/blog/2015/12/17/react-directory-structure.html)
[個人的React周りベストプラクティス⑤ - Reduxディレクトリ構成編](https://qiita.com/enshi/items/c60bb9055623ae318377)

一方で「お前が作るアプリはそんな大層な構成が必要なほど巨大なのか？？ん？？？」
っていう類の批判もある模様です。割と的を射た指摘だと個人的には思います。
[11 mistakes I’ve made during React Native / Redux app development](https://medium.com/dailyjs/11-mistakes-ive-made-during-react-native-redux-app-development-8544e2be9a9)

今回は勉強の意味合いも兼ねているので前者寄りの以下のような構成になっています。

```
├─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

```jsx
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](https://jestjs.io/docs/en/snapshot-testing)
詳しくは上記に記載のある通りですが、
コンポネントをレンダリングした状態でスナップショットを保存し、前回保存したスナップショットと比較し、差異があればテスト失敗とします。
意図せずViewの構成が変更されてしまった場合に、早くその事を察知できます。

[react-test-renderer](https://www.npmjs.com/package/react-test-renderer)と[jest-mock](https://www.npmjs.com/package/jest-mock)という二つのモジュールをテスト用に使用しています。

package.jsonのtransformIgnorePatternsやunmockedModulePathPatternsを設定しないと動かない場合があります。

components/\_\_tests\_\_/snapshot/signIn.js

```jsx
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](https://github.com/airbnb/enzyme)を使用します。
最初に渡したモック関数がボタンクリック（onPress）時に呼び出されているかどうかをテストします。
[ShallowWrapper.simulate](https://github.com/airbnb/enzyme/blob/master/docs/api/ShallowWrapper/simulate.md)という関数もあるようで、そちらを勧めるReactの記事も多かったのですが
onPressには対応していないようでした。（やり方が間違っていただけかも）
ちょっとハマりポイントでした。

```jsx
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 のテストを良い感じに書く](https://qiita.com/hiyamamoto/items/281709cc2a98268fb6c2)
isLoginについては記事書く用に作ったstateです。（コンポネント側で使っていない）

containers/signIn/index.js

```jsx
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](https://github.com/airbnb/enzyme)のシャローレンダリングを使用しているほか
[redux-mock-store](https://www.npmjs.com/package/redux-mock-store)を使用しています。

container/\_\_tests\_\_/siginIn.js

```jsx
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

```jsx
import { createAction } from 'redux-actions';

export const SIGN_IN = 'AUTHENTICATION-SIGN_IN';
export const signIn = createAction(SIGN_IN);
```
actions/\_\_tests\_\_/authentication.js

```jsx
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](https://github.com/n6g7/redux-saga-firebase)を参考に自前で実装した関数です。

sagas/authentication.js

```jsx
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](https://github.com/jfairbank/redux-saga-test-plan)というモジュールを使用しています。
ちょっとここのテストだけ振る舞い駆動っぽくなってしまっている感が否めない
firebaseInitは自前で実装したfirebaseに設定を読み込ませる関数です。

sagas/\_\_tests\_\_/authentication.js

```jsx
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](https://github.com/n6g7/redux-saga-firebase)を参考に自前で実装した関数です。
データベースアクセス全般をこの層で管理したかったので、あまりモジュールに頼りたくなかった意図があります。
（今のところは完全に車輪の再発明...）

daos/authentication.js

```jsx
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

```jsx
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

```jsx
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

```jsx
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 のサンプル付き )](https://tech.recruit-mp.co.jp/front-end/post-14752/)

```
jest --coverage
```
というコマンドでカバレッジを見る事ができます。（コンソールに表示&HTMLとして詳細出力）

以下は実際に私が作りかけているアプリのカバレッジです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/169050/55e052f5-5b8e-97b1-4d2a-01e84666a2ae.png)

テストケース等はまだちゃんと考慮していない & 一部削除予定のソースがある段階でこのカバレッジなので
そのあたりをしっかり行えば、本投稿もタイトル詐欺にはならずに済むのではないでしょうか

なお、htmlの方は各ファイルの詳細な情報を見る事が可能です。
網羅しきれていない処理は以下のように赤く表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/169050/8600d562-f812-1992-0f8a-497d02c3f8a9.png)

これによって
「ボタンの試験はしていたけど、入力フォームのテスト忘れてた！」なんて事に気付くことが出来るのです。

## おわりに

試行錯誤しながら、なんとかひとしきりテスト出来る状態にはなりましたが
私自身まだまだ勉強不足なため、間違っている箇所等があるかもしれません。
ご意見・ご指摘等ありましたらコメントをいただけますと幸いです。