JavaScript
reactjs
jest
redux
enzyme
0

ReactJSとReduxの単体テストを'きちん'と書く

React ComponentとRedux(action、reducer)のテスト

ReactのComponent周りとReduxのaction周りのCIテストを行いたいと思ったので
Jest+Enzymeで単体テストを書いてみました。

JestはFacebookが開発しているReactJS単体テスト用のライブラリです。
単体テストに加え、Reactのレンダリング結果をスナップショットとして保存することもできるため、
レンダリングの差分テストを行うこともできます。

EnzymeはAirbnbが開発しているReactのUnitテスト用ライブラリです。
propsにパラメータを渡したり、stateの変更を行ったりできます。

ReactJSを実践で使う時は
Redux、 Matarial-UI、 redux-formを組み合わせることが多いと思います。
今回はMatarial-UI、redux-formのHOCでwrapされたComponentの単体テストを行います。
ReactJSの基本的なプロジェクト作成はReactJSで作る今時のSPA入門(基本編)を参考にしてください。

セットアップ

以下のライブラリを使用しています。

enzymeはバージョン3.5を使用しています。
次のパッケージバージョンの想定で単体テストしています。
npmもしくはyarnでインストールします。

package.json
"dependencies": {
    "@material-ui/core": "^1.2.3",
    "@material-ui/icons": "^1.1.0",
    "axios": "^0.16.1",
    "react": "^16.3.2",
    "react-dom": "^16.3.2",
    "react-redux": "^5.0.3",
    "redux": "^3.6.0",
    "redux-form": "^7.1.2",
    "redux-thunk": "^2.2.0",
}
"devDependencies": {
    "axios-mock-adapter": "^1.15.0",
    "enzyme": "^3.5.0",
    "enzyme-adapter-react-16": "^1.3.1",
    "jest": "^23.5.0",
    "jest-enzyme": "^6.0.4",
    "redux-mock-store": "^1.5.3",
},

Jestのセットアップ設定をpackage.jsonに追記します。
rootsにJestテスト対象のフォルダパスを指定、
testファイルの拡張子パターンをtestRegexに指定(無指定の場合は*.test.js*.spec.jsがテスト対象になります)
Jest実行時の初期化スクリプトのパスをsetupTestFrameworkScriptFileに指定します。

package.json
"jest": {
    "verbose": true,
    "roots": [
      "./test/web/components"
    ],
    "testRegex": ".*.test.js",
    "setupTestFrameworkScriptFile": "./test/web/components/setupTests.js"
}

Jestの初期化スクリプトをsetupTest.jsに書きます。
jestコマンドの実行時に必ず最初に実行されます。
Enzymeの初期化処理を行います。

setupTest.js
import Enzyme from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import 'jest-enzyme'

Enzyme.configure({ adapter: new Adapter() })

テスト作成例

HOCしていないComponentを分離してテストするべきというのを見かけたのですが、
Material-UIはwithStylesを使う前提とするとそもそもHOC必須なため、
HOCされたComponentに関してのReact+Enzymeの実践例があまりなかったので
手探りながら頑張って作ってみました。(みんなテストどうしているんだろう・・・?)
以下のテストを行っています。

  • propsのテスト
  • stateのテスト
  • componentメソッドのコールテスト
  • actionのテスト
  • reducerのテスト

今回はわかりやすいようにテスト対象のComponentやaction、 reducerをテストファイル内に含めてしまいましたが実際はそれぞれ別ファイルからimportします。

App.test.js
/* global jest:false, test:false, expect:false */
import React from 'react'
import configureStore from 'redux-mock-store' // redux store mock
import thunk from 'redux-thunk'
import client from 'axios'
import MockAdapter from 'axios-mock-adapter' // axios api mock
import { createShallow } from '@material-ui/core/test-utils' // material-ui版 enzyme wrapper
const shallow = createShallow()

////////////// 実際にはテストするものをimportする /////////////
import { connect } from 'react-redux'
import { withStyles } from '@material-ui/core'
import { reduxForm, Field } from 'redux-form'
import Button from '@material-ui/core/Button'

const SUBMIT = 'test/SUBMIT'

// reducer
function reducer(state = {user: {name: ''}}, action = {}) {
  switch (action.type) {
    case SUBMIT:
      return {
        ...state,
        user: action.user || state.user,
      }
    default:
      return state
  }
}

// action
function send(value) {
  return (dispatch, getState, client) => {
    return client.post('/api/users', value)
      .then(res => res.data)
      .then(data => {
        return dispatch({ type: SUBMIT, user: data })
      })
  }
}


// decoratorsの利用には.babelrcにtransform-decorators-legacyプラグインが必要
@connect(
  state => ({user: state.user}),
  {send}
)
@reduxForm({
  form: 'login',
})
@withStyles(() => ({root: {color: '#ff0000'}}))
class Hello extends React.Component {

  state = {
    isChecked: false,
  }

  constructor(props) {
    super(props)
    this.submit = this.submit.bind(this)
    this.onChange = this.onChange.bind(this)
  }

  submit(values) {
    this.props.send(values)
  }

  onChange() {
    this.setState(prevState => ({isChecked: !prevState.isChecked}))
  }

  render () {
    const { handleSubmit, classes, user, isShow } = this.props
    const { isChecked } = this.state

    return (
      <form onSubmit={handleSubmit(this.submit)} className={classes.root}>
        {isShow && <div>{user.name}</div>}
        <input
          type='checkbox'
          checked={isChecked}
          onChange={this.onChange}
        />
        {isChecked && <p>Checked!</p>}
        <Field name='name' component='input' type='text' />
        <Button type='submit' size='medium' variant='raised' color='primary'>ボタン</Button>
      </form>
    )
  }
}


////////////////////////////////////////////////

test('test Hello', async () => {

  // redux initialize state
  const state = {
    user: {
      name: 'hoge',
    },
  }

  // mock redux store作成
  const thunkWithClient = thunk.withExtraArgument(client)
  const createMockStore = configureStore([thunkWithClient])
  const store = createMockStore(state)

  // HOCされているのでHello Componentまでたどる
  const root = shallow(<Hello />, { context: {store} })
  let hello = root.dive().dive().dive().dive()

  expect(store.getState().user.name).toEqual('hoge')
  expect(hello.props().user.name).toEqual(state.user.name)
  // propsのテスト
  // console.log(wrapper.props())
  hello.setProps({isShow: true})
  // console.log(wrapper.dive().debug())
  expect(hello.dive().find('form > div').length).toEqual(1)

  // stateのテスト
  let component = hello.dive().setState({isChecked: true})
  // console.log(component.state())
  // console.log(component.debug())
  expect(component.find('form > p').length).toEqual(1)

  // redux formのテスト
  let shallowNode = hello.get(0)
  jest.spyOn(shallowNode.type.prototype, 'submit').mockImplementation()
  hello.props().change({'name': 'fuga'})
  hello.dive().simulate('submit')
  expect(shallowNode.type.prototype.submit).toHaveBeenCalled()
  // actionのテスト
  const mock = new MockAdapter(client)
  mock.onPost('/api/users', { name: 'fuga' }).reply(200, {
    name: 'fuga',
  })
  expect(await hello.props().send({'name': 'fuga'})).toEqual({ type: SUBMIT, user: {name: 'fuga'} })
  // reducerのテストはpure functionなのでmock不要、そのまま呼べば良い
  expect(reducer()).toEqual({user: {name: ''}})
})

redux storeのモックを作る

redux-mock-storeライブラリで以下のような初期化処理でredux storeのモックを作成します。
configureStoreにはreduxのmiddlewareを指定します。
今回はredux-thunkを使っているため、redux-thunkを指定します。
後述のactionでaxiosを引数で受け取るため、事前にthunk.withExtraArgumentでaxiosを渡すようにします。

App.test.js
import configureStore from 'redux-mock-store' // redux store mock
import thunk from 'redux-thunk'
import client from 'axios'

// redux initialize state
const state = {
  user: {
    name: 'hoge',
  },
}

// mock redux store作成
const thunkWithClient = thunk.withExtraArgument(client)
const createMockStore = configureStore([thunkWithClient])
const store = createMockStore(state)

React Componentのstateとpropsのテスト

今回は次のようなComponentを用意しました。
可読性を良くするためにdecorators文法を利用しています。
decoratorsに関しては以下を参考にしてください。
JavaScriptのDecoratorsでDependency Injectionの実例

App.test.js
// decoratorsの利用には.babelrcにtransform-decorators-legacyプラグインが必要
@connect(
  state => ({user: state.user}),
  {send}
)
@reduxForm({
  form: 'login',
})
@withStyles(() => ({root: {color: '#ff0000'}}))
class Hello extends React.Component {
  (...中略)
}

Matrial-UIのHOCを使っているため(withStyle)、
Material-UI版のshallowを作成します。
shallowを通じて、Componentのeventのsimulateやpropsやstateの変更を行うことができます。
また、shallowでの生成時にreduxのモックstoreをComponentに渡します。
HOCでwrapされたHello Componentを取得するためにはdiveメソッドで走査する必要があります。
shallowで使えるAPIに関してはenzymeのほうのAPI Referenceを参考にしてください

App.test.js
import { createShallow } from '@material-ui/core/test-utils' // material-ui版 enzyme wrapper
const shallow = createShallow()

// HOCされているのでHello Componentまでたどる
const root = shallow(<Hello />, { context: {store} })
let hello = root.dive().dive().dive().dive()

expect(store.getState().user.name).toEqual('hoge')
expect(hello.props().user.name).toEqual(state.user.name)
// propsのテスト
// console.log(wrapper.props())
hello.setProps({isShow: true})
// console.log(wrapper.dive().debug())
// Hello ComponentでrenderされているDOMを見るためにはさらにdiveする必要あり
expect(hello.dive().find('form > div').length).toEqual(1)

// stateのテスト
let component = hello.dive().setState({isChecked: true})
// console.log(component.state())
// console.log(component.debug())
expect(component.find('form > p').length).toEqual(1)

stateの方は使い方が公式のドキュメントにかかれているやり方だとレンダリングに反映されていなかったため、地味にはまりました。

イベントとメソッドコールのテスト

jestのspyOnメソッドを使うことで、メソッドのモックに上書きすることができます。
処理は実行したくはないが、メソッドが実際に呼ばれているか確認するためのモックとして使います。
getメソッドでReactElementを取得し、prototypeでメソッド本体をモック(空の処理)に差し替えます。
simulateメソッドでフォームのsubmitイベントを実行してsubmitメソッドが呼ばれるかテストします。

App.test.js
class Hello extends React.Component {

  submit(values) {
    this.props.send(values)
  }

  render () {
    const { handleSubmit } = this.props

    return (
      <form onSubmit={handleSubmit(this.submit)}>
       (...中略)
      </form>
    )
  }
}

// redux formのテスト
let shallowNode = hello.get(0)
jest.spyOn(shallowNode.type.prototype, 'submit').mockImplementation()
hello.props().change({'name': 'fuga'})
hello.dive().simulate('submit')
expect(shallowNode.type.prototype.submit).toHaveBeenCalled()

Redux actionのテスト

Reduxのactionをテストします。
今回はredux-thunkのwithExtraArgument middlewareで第3引数にaxiosを渡しています。
axios-mock-adapterライブラリのMockAdapterを使うことでaxiosのAPIコールのモックを作成しています。
redux-thunkは非同期処理(Promise)のため、テスト時はasync/awaitで実行の完了待ちをします。

App.test.js
// action
function send(value) {
  return (dispatch, getState, client) => {
    return client.post('/api/users', value)
      .then(res => res.data)
      .then(data => {
        return dispatch({ type: SUBMIT, user: data })
      })
  }
}

test('test Hello', async () => {

  // actionのテスト
  const mock = new MockAdapter(client)
  mock.onPost('/api/users', { name: 'fuga' }).reply(200, {
    name: 'fuga',
  })
  expect(await hello.props().send({'name': 'fuga'})).toEqual({ type: SUBMIT, user: {name: 'fuga'} })
})

Redux reducerのテスト

Reduxのreducerはpure関数なのでそのまんま関数のテストをすればよいです。
特に説明不要です。

App.test.js
// reducer
function reducer(state = {user: {name: ''}}, action = {}) {
  switch (action.type) {
    case SUBMIT:
      return {
        ...state,
        user: action.user || state.user,
      }
    default:
      return state
  }
}

// reducerのテストはpure functionなのでmock不要、そのまま呼べば良い
expect(reducer()).toEqual({user: {name: ''}})

テスト実行

jestコマンドでテストを実行します。

$ jest
 PASS  test/web/components/App.test.js
  ✓ test Hello (38ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.459s
Ran all test suites.

所感としてはdiveメソッドで正しいComponentから操作しないとハマります。
debugメソッドで今どのComponentを操作しているか確認できます。
余力があったら別記事にE2Eテストのことを書くかもしれません。