Edit This Page

撰寫測試

大部份你撰寫的 Redux 程式碼都是 function,而且它們之中有很多是 pure 的,它們不需要 mock 就能簡單測試。

設置

我們推薦用 Mocha 作為測試引擎。 注意,因為它運行在 Node 環境中,所以你不會存取到 DOM。

npm install --save-dev mocha

要結合 Babel 一起使用的話,你必須先安裝 babel-register

npm install --save-dev babel-register

並在 .babelrc 內設定使用 ES2015 功能:

{
  "presets": ["es2015"]
}

接著,把這段加到你的 package.jsonscripts

{
  ...
  "scripts": {
    ...
    "test": "mocha --compilers js:babel-register --recursive",
    "test:watch": "npm test -- --watch",
  },
  ...
}

然後執行 npm test 來跑一次測試,或是 npm run test:watch 來在每一次檔案變更時測試。

Action Creator

在 Redux 中,action creator 是回傳一般物件的 function。在測試 action creator 的時候,我們想要測試是否呼叫了正確的 action creator,還有是否回傳了正確的 action。

範例

export function addTodo(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}

可以像這樣測試:

import expect from 'expect'
import * as actions from '../../actions/TodoActions'
import * as types from '../../constants/ActionTypes'

describe('actions', () => {
  it('should create an action to add a todo', () => {
    const text = 'Finish docs'
    const expectedAction = {
      type: types.ADD_TODO,
      text
    }
    expect(actions.addTodo(text)).toEqual(expectedAction)
  })
})

Async Action Creator

針對使用 Redux Thunk 或其他的 middleware 的 async action creator,為了測試,完全的 mock Redux store 是最好的。你可以使用 redux-mock-store 來把 middleware 應用在一個 mock store 上。你也可以使用 nock 來 mock HTTP 請求。

範例

function fetchTodosRequest() {
  return {
    type: FETCH_TODOS_REQUEST
  }
}

function fetchTodosSuccess(body) {
  return {
    type: FETCH_TODOS_SUCCESS,
    body
  }
}

function fetchTodosFailure(ex) {
  return {
    type: FETCH_TODOS_FAILURE,
    ex
  }
}

export function fetchTodos() {
  return dispatch => {
    dispatch(fetchTodosRequest())
    return fetch('http://example.com/todos')
      .then(res => res.json())
      .then(json => dispatch(fetchTodosSuccess(json.body)))
      .catch(ex => dispatch(fetchTodosFailure(ex)))
  }
}

可以像這樣測試:

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import * as actions from '../../actions/counter'
import * as types from '../../constants/ActionTypes'
import nock from 'nock'
import expect from 'expect' // 你可以使用任何的測試函式庫

const middlewares = [ thunk ]
const mockStore = configureMockStore(middlewares)

describe('async actions', () => {
  afterEach(() => {
    nock.cleanAll()
  })

  it('creates FETCH_TODOS_SUCCESS when fetching todos has been done', () => {
    nock('http://example.com/')
      .get('/todos')
      .reply(200, { body: { todos: ['do something'] }})

    const expectedActions = [
      { type: types.FETCH_TODOS_REQUEST },
      { type: types.FETCH_TODOS_SUCCESS, body: { todos: ['do something']  } }
    ]
    const store = mockStore({ todos: [] })

    return store.dispatch(actions.fetchTodos())
      .then(() => { // 回傳非同步的 action
        expect(store.getActions()).toEqual(expectedActions)
      })
  })
})

Reducer

reducer 應該把 action 應用到先前的 state,然後回傳新的 state,而這就是下面所測試的行為。

範例

import { ADD_TODO } from '../constants/ActionTypes'

const initialState = [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]

export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: action.text
        },
        ...state
      ]

    default:
      return state
  }
}

可以像這樣測試:

import expect from 'expect'
import reducer from '../../reducers/todos'
import * as types from '../../constants/ActionTypes'

describe('todos reducer', () => {
  it('should return the initial state', () => {
    expect(
      reducer(undefined, {})
    ).toEqual([
      {
        text: 'Use Redux',
        completed: false,
        id: 0
      }
    ])
  })

  it('should handle ADD_TODO', () => {
    expect(
      reducer([], {
        type: types.ADD_TODO,
        text: 'Run the tests'
      })
    ).toEqual(
      [
        {
          text: 'Run the tests',
          completed: false,
          id: 0
        }
      ]
    )

    expect(
      reducer(
        [
          {
            text: 'Use Redux',
            completed: false,
            id: 0
          }
        ],
        {
          type: types.ADD_TODO,
          text: 'Run the tests'
        }
      )
    ).toEqual(
      [
        {
          text: 'Run the tests',
          completed: false,
          id: 1
        },
        {
          text: 'Use Redux',
          completed: false,
          id: 0
        }
      ]
    )
  })
})

Component

React component 的其中一個優點是它們通常都很小,而且只依賴它們的 props。這使它們容易測試。

首先,我們會先安裝 Enzyme。Enzyme 的背後使用 React Test Utilities,不過更為方便、可讀與強大。

npm install --save-dev enzyme

為了測試 component,我們寫了一個 setup() helper,它會傳遞被 stub 的 callback 作為 props 並 shallow rendering 來 render component。這讓獨立的測試在預期 callback 會被呼叫時,可以 assert callback 是否有被呼叫。

範例

import React, { PropTypes, Component } from 'react'
import TodoTextInput from './TodoTextInput'

class Header extends Component {
  handleSave(text) {
    if (text.length !== 0) {
      this.props.addTodo(text)
    }
  }

  render() {
    return (
      <header className='header'>
          <h1>todos</h1>
          <TodoTextInput newTodo={true}
                         onSave={this.handleSave.bind(this)}
                         placeholder='What needs to be done?' />
      </header>
    )
  }
}

Header.propTypes = {
  addTodo: PropTypes.func.isRequired
}

export default Header

可以像這樣測試:

import expect from 'expect'
import React from 'react'
import { shallow } from 'enzyme'
import Header from '../../components/Header'

function setup() {
  const props = {
    addTodo: expect.createSpy()
  }

  const enzymeWrapper = shallow(<Header {...props} />)

  return {
    props,
    enzymeWrapper
  }
}

describe('components', () => {
  describe('Header', () => {
    it('should render self and subcomponents', () => {
      const { enzymeWrapper } = setup()

      expect(enzymeWrapper.find('header').hasClass('header')).toBe(true)

      expect(enzymeWrapper.find('h1').text()).toBe('todos')

      const todoInputProps = enzymeWrapper.find('TodoTextInput').props()
      expect(todoInputProps.newTodo).toBe(true)
      expect(todoInputProps.placeholder).toEqual('What needs to be done?')
    })

    it('should call addTodo if length of text is greater than 0', () => {
      const { enzymeWrapper, props } = setup()
      const input = enzymeWrapper.find('TodoTextInput')
      input.props().onSave('')
      expect(props.addTodo.calls.length).toBe(0)
      input.props().onSave('Use Redux')
      expect(props.addTodo.calls.length).toBe(1)
    })
  })
})

已連結的 Component

如果你使用一個類似 React Redux 的 library,你可能正在使用像是 connect() 之類的 higher-order component。它讓你把 Redux state 注入一個正規的 React component 裡。

試想下面的 App component:

import { connect } from 'react-redux'

class App extends Component { /* ... */ }

export default connect(mapStateToProps)(App)

在單元測試中,你通常會像這樣 import App component:

import App from './App'

但是當你 import 它時,你實際上拿到的是 connect() 回傳的包裝過後的 component,而不是 App component 本身。如果你想要測試它與 Redux 的互動,這是個好消息:你可以把它跟特別為這個單元測試建立的 store 包在一個 <Provider> 中。但是有時你只是想要測試 component 的 render,而不想測試 Redux store。

為了能夠不處理 decorator 即可測試 App component 本身,我們也建議你 export 沒有被 decorate 的 component:

import { connect } from 'react-redux'

// 使用 named export 處理未連結的 component (測試用)
export class App extends Component { /* ... */ }

// 使用 default export 來處理已連結的 component (應用程式用)
export default connect(mapStateToProps)(App)

因為 default export 仍然是個被 decorate 的 component,上面出現的 import 語句會像之前一樣運作,所以你不需要變動應用程式中的程式碼。不過,你現在可以在你的測試檔像這樣 import 沒有被 decorate 的 App component:

// 注意大括號:抓取 named export 而不是 default export
import { App } from './App'

而如果你兩個都需要:

import ConnectedApp, { App } from './App'

在應用程式裡面,你仍然可以像一般一樣 import 它:

import App from './App'

你應該只會在測試使用 named export。

關於混合使用 ES6 模組和 CommonJS 的附註

如果你有在應用程式的原始碼裡面使用 ES6,不過是用 ES5 撰寫你的測試,你必須知道 Babel 處理了 ES6 import 和 CommonJS require 之間的互相使用,透過它的 interop 功能就可以讓兩種模組格式一起運作,但行為有一點點不一樣。如果你在 default export 旁邊添加了第二個 export,。你就不再能直接使用 require('./App') 來 import default。而你必須使用 require('./App').default

Middleware

Middleware function 包裝了 Redux 中 dispatch 呼叫的行為,所以要測試這個修改後的行為我們必須 mock dispatch 呼叫的行為。

範例

import expect from 'expect'
import * as types from '../../constants/ActionTypes'
import singleDispatch from '../../middleware/singleDispatch'

const createFakeStore = fakeData => ({
  getState() {
    return fakeData
  }
})

const dispatchWithStoreOf = (storeData, action) => {
  let dispatched = null
  const dispatch = singleDispatch(createFakeStore(storeData))(actionAttempt => dispatched = actionAttempt)
  dispatch(action)
  return dispatched
}

describe('middleware', () => {
  it('should dispatch if store is empty', () => {
    const action = {
      type: types.ADD_TODO
    }

    expect(
      dispatchWithStoreOf({}, action)
    ).toEqual(action)
  })

  it('should not dispatch if store already has type', () => {
    const action = {
      type: types.ADD_TODO
    }

    expect(
      dispatchWithStoreOf({
        [types.ADD_TODO]: 'dispatched'
      }, action)
    ).toNotExist()
  })
})

術語表

  • Enzyme:Enzyme 是一個給 React 用的 JavaScript 的 Test utility,它讓 assert、操作與 traverse 你的 React Component 的 output 變得更簡單。

  • React Test Utils:React 的測試 Utilities。被 Enzyme 所使用。

  • Shallow rendering:Shallow rendering 讓你可以實體化一個 component 並有效率地取得它的 render 方法的回傳結果,它只會 render 一層的深度而不會遞迴地把 component render 成 DOM。Shallow rendering 在單元測試非常有用,在那邊你只測試一個特定的 component,而重要的不是它的 children。這也意味著改變一個 child component 不會影響 parent component 的測試。測試一個 component 和它所有的 children 可以藉由 Enzyme 的 mount() 方法來實現,也就是完整的 DOM render。