编写测试

由于你写的大部分 Redux 代码是函数,而且其中大部分是纯函数,所以很好测,不需要模拟。

准备工作

我们推荐使用 Jest) 作为测试引擎,需要注意的是 Jest 运行在 Node 环境中,因此你不能访问 DOM。

  1. npm install --save-dev jest

如果想要和 Babel 一起使用,还需要安装 babel-jest

  1. npm install --save-dev babel-jest

并且在 .babelrc 中通过 babel-preset-env 来配置

  1. {
  2. "presets": ["@babel/preset-env"]
  3. }

然后,在 package.json 中的 scripts 处添加相关的命令

  1. {
  2. ...
  3. "scripts": {
  4. ...
  5. "test": "jest",
  6. "test:watch": "npm test -- --watch"
  7. },
  8. ...
  9. }

执行 npm test 可以运行一次测试,执行 npm run test:watch 可以让每当文件改变时自动执行测试。

测试 Action Creators

在 Redux 中,action creators 是返回普通对象的函数,当我们测试 action creators 时,我们想要测试是否调用了正确的 action creator 以及是否返回了正确的 action。

示例

  1. export function addTodo(text) {
  2. return {
  3. type: 'ADD_TODO',
  4. text
  5. }
  6. }

可以这样来测试:

  1. import * as actions from '../../actions/TodoActions'
  2. import * as types from '../../constants/ActionTypes'
  3. describe('actions', () => {
  4. it('should create an action to add a todo', () => {
  5. const text = 'Finish docs'
  6. const expectedAction = {
  7. type: types.ADD_TODO,
  8. text
  9. }
  10. expect(actions.addTodo(text)).toEqual(expectedAction)
  11. })
  12. })

测试异步 Action Creators

对于使用 Redux-Thunk 或者其它中间件的异步 action Creator ,最好完全模拟 Redux store 来进行测试,可以通过使用 redux-mock-store 来把中间件应用于模拟的 store,还可以使用 fetch-mock) 来模拟 HTTP 请求。

示例

  1. import 'cross-fetch/polyfill'
  2. function fetchTodosRequest() {
  3. return {
  4. type: FETCH_TODOS_REQUEST
  5. }
  6. }
  7. function fetchTodosSuccess(body) {
  8. return {
  9. type: FETCH_TODOS_SUCCESS,
  10. body
  11. }
  12. }
  13. function fetchTodosFailure(ex) {
  14. return {
  15. type: FETCH_TODOS_FAILURE,
  16. ex
  17. }
  18. }
  19. export function fetchTodos() {
  20. return dispatch => {
  21. dispatch(fetchTodosRequest())
  22. return fetch('http://example.com/todos')
  23. .then(res => res.json())
  24. .then(body => dispatch(fetchTodosSuccess(body)))
  25. .catch(ex => dispatch(fetchTodosFailure(ex)))
  26. }
  27. }

可以这样来测试:

  1. import configureMockStore from 'redux-mock-store'
  2. import thunk from 'redux-thunk'
  3. import * as actions from '../../actions/TodoActions'
  4. import * as types from '../../constants/ActionTypes'
  5. import fetchMock from 'fetch-mock'
  6. import expect from 'expect' // 可以使用任何测试库
  7. const middlewares = [thunk]
  8. const mockStore = configureMockStore(middlewares)
  9. describe('async actions', () => {
  10. afterEach(() => {
  11. fetchMock.reset()
  12. fetchMock.restore()
  13. })
  14. it('creates FETCH_TODOS_SUCCESS when fetching todos has been done', () => {
  15. fetchMock
  16. .getOnce('/todos', { body: { todos: ['do something'] }, headers: { 'content-type': 'application/json' } })
  17. const expectedActions = [
  18. { type: types.FETCH_TODOS_REQUEST },
  19. { type: types.FETCH_TODOS_SUCCESS, body: { todos: ['do something'] } }
  20. ]
  21. const store = mockStore({ todos: [] })
  22. return store.dispatch(actions.fetchTodos()).then(() => {
  23. // return of async actions
  24. expect(store.getActions()).toEqual(expectedActions)
  25. })
  26. })
  27. })

测试 Reducers

Reducer 把 action 应用到之前的 state,并返回新的 state。示例如下。

示例

  1. import { ADD_TODO } from '../constants/ActionTypes'
  2. const initialState = [
  3. {
  4. text: 'Use Redux',
  5. completed: false,
  6. id: 0
  7. }
  8. ]
  9. export default function todos(state = initialState, action) {
  10. switch (action.type) {
  11. case ADD_TODO:
  12. return [
  13. {
  14. id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
  15. completed: false,
  16. text: action.text
  17. },
  18. ...state
  19. ]
  20. default:
  21. return state
  22. }
  23. }

可以这样来测试:

  1. import reducer from '../../reducers/todos'
  2. import * as types from '../../constants/ActionTypes'
  3. describe('todos reducer', () => {
  4. it('should return the initial state', () => {
  5. expect(reducer(undefined, {})).toEqual([
  6. {
  7. text: 'Use Redux',
  8. completed: false,
  9. id: 0
  10. }
  11. ])
  12. })
  13. it('should handle ADD_TODO', () => {
  14. expect(
  15. reducer([], {
  16. type: types.ADD_TODO,
  17. text: 'Run the tests'
  18. })
  19. ).toEqual([
  20. {
  21. text: 'Run the tests',
  22. completed: false,
  23. id: 0
  24. }
  25. ])
  26. expect(
  27. reducer(
  28. [
  29. {
  30. text: 'Use Redux',
  31. completed: false,
  32. id: 0
  33. }
  34. ],
  35. {
  36. type: types.ADD_TODO,
  37. text: 'Run the tests'
  38. }
  39. )
  40. ).toEqual([
  41. {
  42. text: 'Run the tests',
  43. completed: false,
  44. id: 1
  45. },
  46. {
  47. text: 'Use Redux',
  48. completed: false,
  49. id: 0
  50. }
  51. ])
  52. })
  53. })

测试组件

React 组件有一个优点,它们通常很小且只依赖于传入的 props ,因此测试起来很简便。

首先,我们需要安装 Enzyme ,Enzyme 底层使用了 React Test Utilities ,但是更方便,更具可读性,更强大。

  1. npm install save-dev enzyme

为了兼容 React 的版本,我们还需要安装 Enzyme 适配器,Enzyme 提供了适配器用以兼容 React16 ,React 15.x,React 0.14.x,React 0.13.x。如果你使用的是 React16,你可以使用下面的命令安装相关依赖:

  1. npm install --save-dev enzyme-adapter-react-16

为了测试组件,我们创建了一个 setup() 辅助函数,用来把模拟过的(stubbed)回调函数当作 props 传入,然后使用 (React Shallow Rendering) 来渲染组件。这样就可以依据 “是否调用了回调函数” 的断言来写独立的测试。

示例

  1. import React, { Component } from 'react'
  2. import PropTypes from 'prop-types'
  3. import TodoTextInput from './TodoTextInput'
  4. class Header extends Component {
  5. handleSave(text) {
  6. if (text.length !== 0) {
  7. this.props.addTodo(text)
  8. }
  9. }
  10. render() {
  11. return (
  12. <header className="header">
  13. <h1>todos</h1>
  14. <TodoTextInput
  15. newTodo={true}
  16. onSave={this.handleSave.bind(this)}
  17. placeholder="What needs to be done?"
  18. />
  19. </header>
  20. )
  21. }
  22. }
  23. Header.propTypes = {
  24. addTodo: PropTypes.func.isRequired
  25. }
  26. export default Header

上面的组件可以这样来测试:

  1. import React from 'react'
  2. import Enzyme, { mount } from 'enzyme'
  3. import Adapter from 'enzyme-adapter-react-16';
  4. import Header from '../../components/Header'
  5. Enzyme.configure({ adapter: new Adapter() });
  6. function setup() {
  7. const props = {
  8. addTodo: jest.fn()
  9. }
  10. const enzymeWrapper = mount(<Header {...props} />)
  11. return {
  12. props,
  13. enzymeWrapper
  14. }
  15. }
  16. describe('components', () => {
  17. describe('Header', () => {
  18. it('should render self and subcomponents', () => {
  19. const { enzymeWrapper } = setup()
  20. expect(enzymeWrapper.find('header').hasClass('header')).toBe(true)
  21. expect(enzymeWrapper.find('h1').text()).toBe('todos')
  22. const todoInputProps = enzymeWrapper.find('TodoTextInput').props()
  23. expect(todoInputProps.newTodo).toBe(true)
  24. expect(todoInputProps.placeholder).toEqual('What needs to be done?')
  25. })
  26. it('should call addTodo if length of text is greater than 0', () => {
  27. const { enzymeWrapper, props } = setup()
  28. const input = enzymeWrapper.find('TodoTextInput')
  29. input.props().onSave('')
  30. expect(props.addTodo.mock.calls.length).toBe(0)
  31. input.props().onSave('Use Redux')
  32. expect(props.addTodo.mock.calls.length).toBe(1)
  33. })
  34. })
  35. })

测试 connected 组件

如果你使用类似 React redux 的库,你可能会使用 高阶组件,比如 connect() 。可以让你把 Redux state 注入到常规的 React 组件中。

考虑如下 App 组件

  1. import { connect } from 'react-redux'
  2. class App extends Component { /* ... */ }
  3. export default connect(mapStateToProps)(App)

在单元测试中,通常会这样导入 App 组件:

  1. import App from './App'

不过,上面这样导入的是通过 connect() 方法返回的包装组件,并非 App 组件本身,如果你想测试和 Redux 的整合,这很容易,通过 <Provider>包裹它后传入用以单元测试的特殊 store 就可以了。但是有时候我们想测试的其实是不带 Redux store 的组件的渲染。

为了测试 App 组件本身而不用处理装饰器,我们推荐你导出未装饰的组件:

  1. import { connect } from 'react-redux'
  2. // 命名导出未连接的组件 (测试用)
  3. export class App extends Component { /* ... */ }
  4. // 默认导出已连接的组件 (app 用)
  5. export default connect(mapStateToProps)(App)

由于默认导出的组件依旧是包装过的组件,上面代码中的导入依旧会生效,无须你更改已有的代码。不过现在你可以通过下面这样的办法导入未装饰的组件了:

  1. import { App } from './App'

如果你需要导入二者,可以按下面这样做:

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

在 app 中,仍然正常地导入:

  1. import App from './App'

只在测试中使用命名导出。

混用 ES6 模块和 CommonJS 的注意事项

如果在应用代码中使用 ES6,但在测试中使用 ES5,Babel 会通过其 interop 机制处理 ES6 的 import 和 CommonJS 的 require ,使得这两种模式能一起使用,但其行为依旧有细微的区别。 如果在默认导出的附近增加另一个导出,将导致无法默认导出 require('./App')。此时,应代以 require('./App').default

对中间件的测试

中间件函数包装了 Redux 中 dispatch 的行为,为了测试中间件的行为,我们需要模拟 dispatch 调用时的行为。

示例

首先,我们需要创建一个中间件函数,下述代码和 redux-thunk 类似

  1. const thunk = ({ dispatch, getState }) => next => action => {
  2. if (typeof action === 'function') {
  3. return action(dispatch, getState)
  4. }
  5. return next(action)
  6. }

我们需要创造一个假的 getState,dispatchnext 函数,我们可以使用 jest.fn() 来创建 stubs,你也可以使用 sinon 等测试框架

我们可以像 Redux 一样来触发函数

  1. const create = () => {
  2. const store = {
  3. getState: jest.fn(() => ({})),
  4. dispatch: jest.fn(),
  5. };
  6. const next = jest.fn()
  7. const invoke = (action) => thunk(store)(next)(action)
  8. return {store, next, invoke}
  9. };

然后我们在适当的时机通过调用 getState,dispatch,next函数来测试中间件。

  1. it('passes through non-function action', () => {
  2. const { next, invoke } = create()
  3. const action = {type: 'TEST'}
  4. invoke(action)
  5. expect(next).toHaveBeenCalledWith(action)
  6. })
  7. it('calls the function', () => {
  8. const { invoke } = create()
  9. const fn = jest.fn()
  10. invoke(fn)
  11. expect(fn).toHaveBeenCalled()
  12. });
  13. it('passes dispatch and getState', () => {
  14. const { store, invoke } = create()
  15. invoke((dispatch, getState) => {
  16. dispatch('TEST DISPATCH')
  17. getState();
  18. })
  19. expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH')
  20. expect(store.getState).toHaveBeenCalled()
  21. });

在一些情况下,你需要修改 create 函数来模拟不同的 getStatenext

词汇表

  • Enzyme: Enzyme 是一种用于 React 测试的 JavaScript 工具,它使得断言、操作以及遍历你的 React 组件的输出变得更简单。
  • React Test Utilities :React 提供的测试工具,被 Enzyme 使用
  • shallow renderer: shallow renderer 使你可以实例化一个组件, 并有效地获取其 render 方法的结果, 其渲染深度仅一层, 而非递归地将组件渲染为 DOM。 shallow renderer 对单元测试很有用, 你只要测试某个特定的组件,而不用管它的子组件。这也意味着,更改子组件不会影响到其父组件的测试。如果要测试一个组件和它所有的子组件,可以用 Enzyme's mount() 方法 ,这个方法会进行完全的 DOM 渲染。