karma
reactjs
React
redux
enzyme
ReactDay 16

React Redux テスト考察

More than 1 year has passed since last update.

ReactReduxでのテストを書いた時のTipsを集めました。「何を使って」ではなく「何をどの様に」テストするかについて書いています。「どこまで書くか」はプロダクトコードを取り巻く環境によるため言及していません。サンプルに利用しているプロダクトコード・テストコードは共に、webpackを経由しbabelで記述しています。利用しているツールについては下の方で少し触れていますが、とりあえず頭出し。

karma + jsdom + enzyme + mocha + chai

テストコードを書く前に

プロダクトコードとテストコードが同じパスで参照出来ると幸せになれるので、webpack.config でリポジトリのルートに「~」でエイリアスを貼っておきます。karma.conf の webpack 設定にも同様のエイリアスを貼っておきましょう。

karma.conf.babel.js
export default function(config) {
  config.set({
    ...
    resolve: {
        extensions: [ '.js' ],
        alias: { '~': 'path/to/repos/root' }
      }
    ...
  })
}
src/main.js
import SomeComponent from '~/path/to/SomeComponent'
test/path/to/some/test.js
import SomeComponent from '~/path/to/SomeComponent'

では、UnitTestについて書いていきます。紹介しているコードは可読性のため、describe や context を省略していますので、あしからず。

ActionTypes(重要度:B)

ReactReduxを使うのであれば、スケールする想定のアプリケーションを作られているはずです。reducer はすぐに肥大化するので、はじめから分割しておき combineReducers しておくと後々リファクタせずに済みます。reducer に併せて ActionTypes や ActionCreators も分割しておいたほうが何かと便利です。

そんなファイル分割していた ActionTypes の value が重複すると、意図していない方の Action を dispatch していた、という事故が起こり得ます。それを防ぐためのテストコードサンプルです。value の抽出と比較のために lodash で楽しています。ES2017のObject.valuesを使ってもいいかもしれません。

typesA.js
const ActionTypes = {
  ...
  ADD_TODO:    'ADD_TODO',
  REMOVE_TODO: 'REMOVE_TODO',
  EDIT_TODO:   'EDIT_TODO'
  ...
}

上記の様に key と value を同一にしている場合に、ファイルをまたいで value が重複する可能性があります。

ActionTypes.test.js
import { expect } from 'chai'
import _ from 'lodash'
import typesA from '~/path/to/actions/types/typesA'
import typesB from '~/path/to/actions/types/typesB'
import typesC from '~/path/to/actions/types/typesC'

it('ActionTypes are not duplicates', () => {
  const arr = [
    ..._.values(typesA),
    ..._.values(typesB),
    ..._.values(typesC)
  ]
  expect(arr).to.be.eql(_.uniq(arr))
})

lodash の uniq 関数は重複した値を削除するものです。

ActionCreators(重要度:C)

これからプロダクトコードを書き始めるのであれば、reducer で処理を完結出来る様に ActionCreator は ActionType と引数をそのまま渡すぐらいの薄い関数にしておいた方が良いです。その場合、このテストコードは不要になるので reducer の方に委譲します。

特に難しいことはなく、引数から期待値が返ってきているかテストしましょう。

creator.js
imort * as types from '~/path/to/actions/types'

export function multiplicate(a, b) {
  const result = a * b
  return { type: types.CALCULATED, result } 
}
ActionCreators.test.js
imort * as actions from '~/path/to/actions/creator'

it('Can multiply #multiplicate', () => {
  const action = actions.multiplicate(2,4)
  expect(action.result).to.be.eql(8)
})

Reducers(重要度:A)

ActionCreator を経由して期待値を return しているかどうかのテスト。state を変更して欲しくない action が dispatch された時に、変更されていないかどうかテストするとなお良いです。また、reducer の initialState を export しておくと、production コードのスキーマ変更をテストコード側でも共有することが出来るため、リファクタ時の事故を防ぐ有意義なものになります。

reducers.js
import types from '~/path/to/actions/types/typesA'

export const initialState = {
  todos: []
}

export default (state = initialState, action) => {
  switch (action.type) {
    case types.ADD_TODO : {
      const todos = [
        {
          id: action.id,
          text: action.text,
          completed: false
        },
        ...state.todos
      ]
      return Object.assign({}, state, { todos })
    }
    case types.REMOVE_TODO : {
      const from = state.todos.slice(0,action.index)
      const to = state.todos.slice(action.index+1)
      const todos = [ ...from, ...to ]
      if(action.index < 0) return state
      return Object.assign({}, state, { todos })
    }
    ...
  }
}
reducers.test.js
import { expect } from 'chai'
import reducer, { initialState } from '~/path/to/reducer'
import * as actions from '~/path/to/actions'

const test = action => reducer(state, action)

it('Can add TODO to TODO list', () => {
  const state = test(actions.addTodo({ id: 0, text: 'add test' }))
  expect(state.todos[0].text).to.be.equal('add test')
}
it('Can remove TODO', () => {
  const state = test(actions.removeTodo({ index: -1 }))
  expect(state).to.be.equal(initialState)
}

Components(重要度:A)

propsテスト。与えたpropsに応じて期待値が出力されているかどうか。componentのI/F明示として記述しておくと、仕様を残せるのでお勧めです。component のテストに jsdom と enzyme を使用しています。設定につきましてはこちらの記事を参考にさせて頂きました。

MyComponent.js
export default function MyComponent(props) {
  const { error, article } = props
  return (
    <div className='MyComponent'>
      { error.show ?   <div>{ error.message }</div> : '' }
      { article.show ? <div>{ article.content }</div> : '' }
    </div>
  )
}
MyComponent.test.js
import React            from 'react'
import { mount }        from 'enzyme'
import { expect }       from 'chai'
import { initialState } from '~/path/to/reducer'
import MyComponent      from '~/path/to/MyComponent'

it('is Show Error by props', () => {
  const error = { message: 'Error', show: true }
  const state = Object.assign({}, initialState, { error })
  const component = mount(<MyComponent state={ state } />)
  expect(component.text()).to.contain('Error')
})
it('is Show Article by props', () => {
  const article = { content: 'MyComponent Article', show: true }
  const state = Object.assign({}, initialState, { article })
  const component = mount(<MyComponent state={ state } />)
  expect(component.text()).to.contain('MyComponent Article')
})

テスト項目のネストが浅い場合はshallowレンダリングを用いましょう。enzymeを利用するために、以下の通りjsdomのsetupコードを挟んでおきます。
https://github.com/airbnb/enzyme/blob/master/docs/guides/jsdom.md


番外編として、props が undefined になってないかどうかのテスト。component内部をpropsのflagで表示非表示を切り替えている場合、props が undefined になってないか確認しておくべきです。設計者は false/null を期待していても、undefined は期待していません。つまり、reducer のリファクタリングで改変された値を参照していないかの確認になります。PropTypes で isRequired 確認している場合はこちらのテストは不要です。

MyComponent.js
export default function MyComponent(props) {
  const isShowError = props.showError
  return(
    <div className='MyComponent'>
      { isShowError ? <p>Error message = { props.error.message }</p> : '' }
      <p>MyComponent article.</p>
    </div>
  )
}

chai で undefined をチェックするためには以下の通り。他にも javascript のプリミティブ型をテストするにはこちらを参照ください。

MyComponent.test.js
import React            from 'react'
import { mount }        from 'enzyme'
import { expect }       from 'chai'
import { initialState } from '~/path/to/reducer'

const component = mount(<MyComponent state={ initialState } />)
it('isnt undefined "showError" ', () => {
  expect(initialState.showError).not.to.be.an('undefined')
})

e2eTest

クリック出来るかどうかなどのUIテストは、CSS や StoreState を含め担保されるべきです。必要を感じる場合は以下の様な e2eテストフレームワークでテストすることをお勧めします。今回選定してるテストツールだけでは react-router のテストが難しいため、遷移テストなどもこちらで賄うと良さそうです。

e2eテストツールの選定についてはこちらの記事がとても参考になります。
ブラウザテストツール総まとめ・2016年夏版

Test tools

babel + webpack
karma + phantomjs + mocha + chai
jsdom + enzyme

karma と phantomjs でテストサーバを立てながらコード編集すると、リアルタイムにテスト結果を通知してくれます。今回紹介しているテストコードのツール設定だけ載せておきます。

package.json
"devDependencies": {
  "babel-polyfill": "^6.16.0",
  "babel-preset-es2015": "^6.16.0",
  "babel-preset-react": "^6.16.0",
  "babel-preset-stage-0": "^6.16.0",
  "babel-register": "^6.18.0",
  "chai": "^3.5.0",
  "chai-enzyme": "^0.5.2",
  "enzyme": "^2.5.1",
  "jsdom": "^9.8.2",
  "json-loader": "^0.5.4",
  "karma": "^1.3.0",
  "karma-chrome-launcher": "^2.0.0",
  "karma-mocha": "^1.2.0",
  "karma-mocha-reporter": "^2.2.0",
  "karma-phantomjs-launcher": "^1.0.2",
  "karma-sourcemap-loader": "^0.3.7",
  "karma-webpack": "^1.8.0",
  "lodash": "^4.17.2",
  "mocha": "^3.1.2",
  "phantomjs-polyfill-object-assign": "^0.0.2",
  "sinon": "^1.17.6",
  "webpack": "^1.13.2"
}
karma.conf.js
require('babel-register')()
require('./.enzyme.setup')
module.exports = require('./karma.conf.babel').default

enzyme.setup.js
import {jsdom} from "jsdom";

const exposedProperties = ["window", "navigator", "document"];

global.document = jsdom("");
global.window = document.defaultView;
Object.keys(document.defaultView).forEach(property => {
  if (typeof global[property] === "undefined") {
    exposedProperties.push(property);
    global[property] = document.defaultView[property];
  }
});

global.navigator = {
    userAgent: "node.js"
};

karma.conf.babel.js
import { ROOT, JS_SRC, JS_TEST, PORT } from './env'
const polyfills = [ `${ROOT}/node_modules/phantomjs-polyfill-object-assign/object-assign-polyfill.js` ]
const entries = [ `${JS_TEST}/common/main.js` ]
const preprocessors = {}
const files = entries.map(file => {
  preprocessors[file] = ['webpack', 'sourcemap']
  return { pattern: file }
})

export default function(config) {
  config.set({
    basePath: '',
    frameworks: ['mocha'],
    reporters:  ['mocha'],
    browsers:   ['PhantomJS'],
    plugins: [
      'karma-mocha',
      'karma-webpack',
      'karma-sourcemap-loader',
      'karma-mocha-reporter',
      'karma-phantomjs-launcher'
    ],
    files: [ ...polyfills, ...files ],
    preprocessors,
    webpack: {
      devtool: 'inline-source-map',
      module: {
        loaders: [
          {
            test: /\.js$/,
            loader: 'babel-loader',
            exclude: /node_modules/,
            query: { presets: [ 'es2015', 'stage-0', 'react' ]}
          },
          { test: /\.json$/, loader: 'json' },
        ],
        noParse: [ /sinon/ ]
      },
      externals: {
        'react/addons': true,
        'react/lib/ExecutionEnvironment': true,
        'react/lib/ReactContext': true
      },
      resolve: {
        extensions: [ '.js' ],
        alias: { '~': JS_SRC, sinon: 'sinon/pkg/sinon' }
      }
    },
    webpackMiddleware: { stats: 'errors-only' },
    port: PORT.karma,
    autoWatch: true,
    logLevel: config.LOG_INFO
  })
}

総括

ReactRedux はテストが書き易いです。必要なテストコードはプロダクトコードを取り巻く環境によって異なるため、ベストプラクティスと呼べるものが無いように思います。別のe2eテストフレームワークによるテストコードがある場合は尚更、バランスを見ながら書くのが良さそうです。とはいえ、フロントエンドのテストコードもリグレッションテスト目的だけではありません。

疎結合でテスタブルなコードであるかどうかの判断、コードの作者が何を意図して設計したか、I/Fの明示目的として活きることは確かです。