React redux アプリケーションのフロントユニットテストについて

https://viblo.asia/p/how-to-test-your-react-redux-application-924lJrDNlPMから翻訳された記事です)

Jest & Enzymeのインストール

  • Jest: yarn add --dev jestまたは npm install --save-dev jest
  • Enzyme: 現在のreactバージョンに合わせてenzymeとadapterをインストール
    例: 現在のreactバージョンは16であった場合 yarn add --dev enzyme enzyme-adapter-react-16 または npm i --save-dev enzyme enzyme-adapter-react-16 *** 注意**:jest が JSをコンプラいする時、 babel を利用できるようにこちらのパッケージを追加インストールすると良いでしょう。 package babel-jest (npm install --save-dev babel-jest babel-core または yarn add --dev babel-jest babel-core)

Reducer test

Reducer はpure function(現在の状態(state)を取得し、とあるアクションを呼び出した後、新しい状態を返す関数)のため、再度アフェリエイトはありません。ここでmockingは不要です。 Jestはデフォルト「test」のフォルダーの配下にある.spec .js 又は.test.jsの拡張のファイルを探してくれる。 例えば、「src/store/topics/reducer.js」のフォルダーにreducerを置きた場合、 テストのフォルダーは「src/__test__/store/topics/reducer.spec.js.」のように作られます。

最もシンプルなreducerのテストケースは初期値。こちらは初期の状態(state)を定義、アクションの定義などは不要です。

import uut from '../store/topics/reducer';

const initialState = {
  topicsByUrl: undefined,
  selectedTopicUrls: [],
  selectionFinalized: false,
};

describe('store/topics/reducer', () => {
  it('should have initial state', () => {
    expect(uut()).toEqual(initialState);
  });
});

例えば、一つのアクションを追加し、返す状態は正しいかどうかのテストを作る場合、このソースコードになります。

import Immutable from 'seamless-immutable';
import { Reducer } from 'redux-testkit'; // thư viện hỗ trợ verify immutable reducer
import uut from '../store/topics/reducer';
import * as actionTypes from '../constants/actionTypes';

const initialState = {
  topicsByUrl: undefined,
  selectedTopicUrls: [],
  selectionFinalized: false,
};

describe('store/topics/reducer', () => {

  it('should have initial state', () => {
    expect(uut()).toEqual(initialState);
  });

  it('should not affect state', () => {
    Reducer(uut).expect({type: 'NOT_EXISTING'}).toReturnState(initialState);
  });

  it('should store fetched topics', () => {
    const topicsByUrl = {url1: 'topic1', url2: 'topic2'};
    const action = {type: actionTypes.TOPICS_FETCHED, topicsByUrl};
    Reducer(uut).expect(action).toReturnState({...initialState, topicsByUrl});
  });

  it('should store fetched topics and override existing topics', () => {
    const existingState = Immutable({...initialState, topicsByUrl: {url3: 'topic3'}});
    const topicsByUrl = {url1: 'topic1', url2: 'topic2'};
    const action = {type: actionTypes.TOPICS_FETCHED, topicsByUrl};
    Reducer(uut).withState(existingState).expect(action).toReturnState({...initialState, topicsByUrl});
  });
  
});

個人的に、jestはrspecに似ていて、読みやすくわかりやすくテストケース手法です。:man_detective:

Nếu state object phức tạp, redux-testkit có các method cho nested state hoặc sử dụng custom expectations. (tham khảo thêm tại đây)

state objectが複雑な場合、redux-testkit にはnested state用のメソッドを提供し、またはcustom expectationsを利用(参考リンク

アクションのテスト(デフォルトミドルゥエア: thunks)

Action test

Thunks wrap sync または async function でアクションを実行する. サーバからのデータを取得するというサイドエフェクトなどを作ることができるために mock function は必須です。

import reducer from '../store/topics/reducer';
import * as actions from '../actions/posts/getPost';
import { UPDATE_POST_SUCCESS } from '../actions/posts/updatePost';
import getPostMock from '../mocks/getPostMock';

describe('post reducer', () => {
  it('should return the initial state', () => {
    expect(reducer(undefined, {})).toEqual({});
  });

  it('should handle GET_POST_START', () => {
    const startAction = {
      type: actions.GET_POST_START
    };
    // it's empty on purpose because it's just starting to fetch posts
    expect(reducer({}, startAction)).toEqual({});
  });

  it('should handle GET_POST_SUCCESS', () => {
    const successAction = {
      type: actions.GET_POST_SUCCESS,
      post: getPostMock.data, // important to pass correct payload, that's what the tests are for ;)
    };
    expect(reducer({}, successAction)).toEqual(getPostMock.data);
  });

  it('should handle UPDATE_POST_SUCCESS', () => {
    const updateAction = {
      type: UPDATE_POST_SUCCESS,
      post: getPostMock.data,
    };
    expect(reducer({}, updateAction)).toEqual(getPostMock.data);
  });

  it('should handle GET_POST_FAIL', () => {
    const failAction = {
      type: actions.GET_POST_FAIL,
      error: { success: false },
    };
    expect(reducer({}, failAction)).toEqual({ error: { success: false } });
  });
});

コンポーネントテスト

  1. スナップショットテスト 多くの会社が公表された スナップショットテスト(snapshot)という機能を持っている。 最初、コンポーネントの構成を__snapshot__フォルダーにカプチャ(capture)する。 スナップショットされたコンポーネントには何かの変更があった場合、コンポーネントのテストケースにはエラーが発生します。 例えば、Linkというコンンポーネントを用意します。
// Link.react.js
import React from 'react';

const STATUS = {
  HOVERED: 'hovered',
  NORMAL: 'normal',
};

export default class Link extends React.Component {
  constructor(props) {
    super(props);

    this._onMouseEnter = this._onMouseEnter.bind(this);
    this._onMouseLeave = this._onMouseLeave.bind(this);

    this.state = {
      class: STATUS.NORMAL,
    };
  }

  _onMouseEnter() {
    this.setState({class: STATUS.HOVERED});
  }

  _onMouseLeave() {
    this.setState({class: STATUS.NORMAL});
  }

  render() {
    return (
      <a
        className={this.state.class}
        href={this.props.page || '#'}
        onMouseEnter={this._onMouseEnter}
        onMouseLeave={this._onMouseLeave}
      >
        {this.props.children}
      </a>
    );
  }
}

toMatchSnapshot() メソッドでスナップショットを作ります。

import React from 'react';
import Link from '../Link.react';
import renderer from 'react-test-renderer';

test('Link changes the class when hovered', () => {
  const component = renderer.create(
    <Link page="http://www.facebook.com">Facebook</Link>,
  );
  let tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  // manually trigger the callback
  tree.props.onMouseEnter();
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();

  // manually trigger the callback
  tree.props.onMouseLeave();
  // re-rendering
  tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});

もし、コンポーネントの構成を自分で変えたい場合、コンポーネントのスナップショットの更新を次のコマンドを使います jest -u

  1. DOM テストについてはenzyme shallow renderer を利用します。 DOM treeの要素(element)をテストに使うテストです。 例えば、次のコンポーネント
// CheckboxWithLabel.js

import React from 'react';

export default class CheckboxWithLabel extends React.Component {
  constructor(props) {
    super(props);
    this.state = {isChecked: false};

    // bind manually because React class components don't auto-bind
    // http://facebook.github.io/react/blog/2015/01/27/react-v0.13.0-beta-1.html#autobinding
    this.onChange = this.onChange.bind(this);
  }

  onChange() {
    this.setState({isChecked: !this.state.isChecked});
  }

  render() {
    return (
      <label>
        <input
          type="checkbox"
          checked={this.state.isChecked}
          onChange={this.onChange}
        />
        {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
      </label>
    );
  }
}

コンポーネントはこのようにテストされます

// __tests__/CheckboxWithLabel-test.js

import React from 'react';
import {shallow} from 'enzyme';
import CheckboxWithLabel from '../CheckboxWithLabel';

test('CheckboxWithLabel changes the text after click', () => {
  // Render a checkbox with label in the document
  const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);

  expect(checkbox.text()).toEqual('Off');

  checkbox.find('input').simulate('change');

  expect(checkbox.text()).toEqual('On');
});

終わり

長くなりましたがこの記載を使ってreact reduxのユニットテストできたら嬉しいです。 何か疑問がありましたら、下にコメントしてくれたら嬉しいです。🙏