Redux Container Components のテストの必要性を身をもって知った

この記事は下記の環境下での内容になります。

```
Node.js : 12.18.2
React : 16.13.1
React Redux : 7.2.1
React Testing Library : 11.0.4
```

# そもそも Redux における Container Components とは

<dl>
<dt>Presentational Components
<dd>見た目を担当するコンポーネント。<br>
マークアップとスタイルを持ち、props としてデータとコールバックを受け取る。
<dt>Container Components
<dd>ロジック（物事の振る舞い）に関与するコンポーネント。<br>
Presentational Components にデータと振る舞いを提供する。<br>
アクション呼び出しなどをコールバックとして Presentational Components に渡す。
</dl>
([ComponentとContainerについて](https://qiita.com/ShiratoriTenta/items/7b4d2ea1a4d1d2336032)より引用)

※ 本記事における Presentational Components, Container Components も上記の想定です。

# Redux のテストコード

[Redux のドキュメント](https://redux.js.org/recipes/writing-tests#connected-components)には、下記のテストコードレシピが紹介されています。

<dl>
<dt>Action Creators
<dd>Action Creator を実行して、その返り値（action）が期待通りであるか
<dt>Reducers
<dd>特定の action を reducer に渡して state が期待通りになるか
<dt>Components
<dd>特定のコンポーネントを操作した時にハンドラーが期待通りに実行されるか（ハンドラーの実行結果はテストしていない）
<dt>Connected Components
<dd>store のデータを渡したときに DOM が期待通りになるか
</dl>
※ Middleware と Async Action Creators のテストコードレシピも記載されていますが、ここでは割愛させていただきます。

# 上記のテストがパスしていても意図しない挙動が発生する

Redux の個々のテストコードがパスしていても、実際にはそれぞれの役者を適切に配置しないと意図しない挙動が発生します。
[サンプル](https://github.com/kalbeekatz/redux-testing-example)を元に、どのような時に意図しない挙動となるか考えてみます。

## 正常に動くコード

```jsx:src/components/Form/presentation.js
import React from 'react';

const Presentation = ({
  onChangeName,
  onChangeEmail,
  value,
}) => (
  <form>
    <label>
      name: <input onChange={onChangeName} value={value.name} />
    </label>
    <label>
      email: <input onChange={onChangeEmail} value={value.email} />
    </label>
    <button type="submit">submit</button>
  </form>
);

export default Presentation;
```

```jsx:src/components/Form/container.js
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import Presentation from './presentation';
import {changeName, changeEmail} from '../../redux/form';

const Container = () => {
  const dispatch = useDispatch();
  const onChangeName = e => {
    dispatch(changeName(e.target.value));
  };
  const onChangeEmail = e => {
    dispatch(changeEmail(e.target.value));
  };
  const value = useSelector(state => state.form.value);

  return (
    <Presentation
      onChangeName={onChangeName}
      onChangeEmail={onChangeEmail}
      value={value}
    />
  );
};

export default Container;
```

```js:src/redux/form/index.js
const CHANGE_NAME = 'CHANGE_NAME';
const CHANGE_EMAIL = 'CHANGE_EMAIL';

export function changeName(value) {
  return {type: CHANGE_NAME, value};
}
export function changeEmail(value) {
  return {type: CHANGE_EMAIL, value};
}

const initialState = {
  value: {
    name: '',
    email: '',
  },
};

export function FormReducer(state = initialState, action) {
  switch (action.type) {
    case CHANGE_NAME:
      return {
        ...state,
        value: {
          ...state.value,
          name: action.value,
        },
      };
    case CHANGE_EMAIL:
      return {
        ...state,
        value: {
          ...state.value,
          email: action.value,
        },
      };

    default:
      return state;
  }
}
```

## 意図しない挙動が発生するコード
### Container でハンドラーが dispatch する action を間違えた場合
```jsx:src/components/Form/container.js
  const onChangeName = e => {
    dispatch(changeEmail(e.target.value));
  };
  const onChangeEmail = e => {
    dispatch(changeName(e.target.value));
  };
```

上記の例では、`onChangeName` で `changeEmail` が、`onChangeEmail` で `changeName` が dispatch されています。
この場合、Redux の個々のテストコードはパスしますが、画面側では name に入力した値が email に反映されるという重大な不具合が発生します。

### Presentation に渡す props を間違えた場合
```jsx:src/components/Form/container.js
const Container = () => {
  // ...中略
  return (
    <Presentation
      onChangeName={onChangeEmail}
      onChangeEmail={onChangeName}
      value={value}
    />
  );
};
```
この場合も同様に name に入力した値が email に反映されるという重大な不具合が発生します。

入力項目が2つだけなら、このようなミスは起きにくいと思いますが、項目が何十個もあるようなフォームだとなかなか気づきにくいです。

# 事故を防ぐために 動作確認、E2E、コンポーネントに対する単体テスト など行う

人の手による動作確認は必ず行うと思いますが、人間である以上100%の動作保証はできません。
[Autify](https://autify.com/ja) などツールを使って自動 E2E テストを行う方法もありますが、導入コストを懸念しているプロダクトもあるのではないでしょうか。

手軽に始められる手段として [React Testing Library](https://github.com/testing-library/react-testing-library) を紹介します。

## React Testing Library

React Testing Library の `fireEvent` を使用するとユーザーの操作に主眼を置いたテストコードを書くことができます。
[ドキュメントはこちら](https://testing-library.com/docs/dom-testing-library/api-events)

```jsx:src/components/Form/container.spec.js
import React from 'react';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import {render as rtlRender, screen, fireEvent} from '@testing-library/react';
import {Form} from '.';
import {rootReducer} from '../../withReduxFeatures';
import {initialState} from '../../redux/form';

const render = () => {
  const store = createStore(rootReducer, initialState);
  const Wrapper = ({children}) => <Provider store={store}>{children}</Provider>;
  return rtlRender(<Form />, {wrapper: Wrapper});
};

describe('components > Form', () => {
  describe('changing name input', () => {
    beforeEach(() => {
      render();
      fireEvent.change(screen.getByLabelText('name:'), {
        target: {value: 'foo'},
      });
    });
    it('reflect value to name input', () => {
      expect(screen.getByLabelText('name:')).toHaveValue('foo');
    });
    it('not reflect value to email input', () => {
      expect(screen.getByLabelText('email:')).toHaveValue('');
    });
  });
  describe('changing email input', () => {
    beforeEach(() => {
      render();
      fireEvent.change(screen.getByLabelText('email:'), {
        target: {value: 'foo'},
      });
    });
    it('reflect value to email input', () => {
      expect(screen.getByLabelText('email:')).toHaveValue('foo');
    });
    it('not reflect value to name input', () => {
      expect(screen.getByLabelText('name:')).toHaveValue('');
    });
  });
});
```

<hr>

```sh:テストの出力結果
PASS  src/components/Form/container.spec.js
  components > Form
    changing name input
      ✓ reflect value to name input (41ms)
      ✓ not reflect value to email input (8ms)
    changing email input
      ✓ reflect value to email input (7ms)
      ✓ not reflect value to email input (7ms)
```

# まとめ
Redux のテストコードだけだとカバーできない範囲があって、Container Components のテストも必要です。Autify などの E2E だと導入コストが高いと感じるのであれば、react-testing-library でミニマルに始めてみるのはいかがでしょうか。