# 編寫測試
由于你寫的大部分 Redux 代碼是函數,而且其中大部分是純函數,所以很好測,不需要模擬。
## 準備工作
我們推薦使用 [Jest](https://jestjs.io/)) 作為測試引擎,需要注意的是 Jest 運行在 Node 環境中,因此你不能訪問 DOM。
```bash
npm install --save-dev jest
```
如果想要和 [Babel](http://babeljs.io/) 一起使用,還需要安裝 `babel-jest`
```bash
npm install --save-dev babel-jest
```
并且在 `.babelrc` 中通過 [babel-preset-env](https://github.com/babel/babel/tree/master/packages/babel-preset-env) 來配置
```javascript
{
"presets": ["@babel/preset-env"]
}
```
然后,在 `package.json` 中的 `scripts` 處添加相關的命令
```json
{
...
"scripts": {
...
"test": "jest",
"test:watch": "npm test -- --watch"
},
...
}
```
執行 `npm test` 可以運行一次測試,執行 `npm run test:watch` 可以讓每當文件改變時自動執行測試。
## 測試 Action Creators
在 Redux 中,action creators 是返回普通對象的函數,當我們測試 action creators 時,我們想要測試是否調用了正確的 action creator 以及是否返回了正確的 action。
### 示例
```js
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
```
可以這樣來測試:
```js
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)
})
})
```
## 測試異步 Action Creators
對于使用 [Redux-Thunk](https://github.com/reduxjs/redux-thunk) 或者其它中間件的異步 action Creator ,最好完全模擬 Redux store 來進行測試,可以通過使用 [redux-mock-store](https://github.com/dmitry-zaets/redux-mock-store) 來把中間件應用于模擬的 store,還可以使用 [fetch-mock](http://www.wheresrhys.co.uk/fetch-mock/)) 來模擬 HTTP 請求。
### 示例
```js
import 'cross-fetch/polyfill'
?
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(body => dispatch(fetchTodosSuccess(body)))
.catch(ex => dispatch(fetchTodosFailure(ex)))
}
}
```
可以這樣來測試:
```js
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import * as actions from '../../actions/TodoActions'
import * as types from '../../constants/ActionTypes'
import fetchMock from 'fetch-mock'
import expect from 'expect' // 可以使用任何測試庫
?
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
?
describe('async actions', () => {
afterEach(() => {
fetchMock.reset()
fetchMock.restore()
})
?
it('creates FETCH_TODOS_SUCCESS when fetching todos has been done', () => {
fetchMock
.getOnce('/todos', { body: { todos: ['do something'] }, headers: { 'content-type': 'application/json' } })
?
?
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(() => {
// return of async actions
expect(store.getActions()).toEqual(expectedActions)
})
})
})
```
## 測試 Reducers
Reducer 把 action 應用到之前的 state,并返回新的 state。示例如下。
### 示例
```js
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
}
}
```
可以這樣來測試:
```js
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
}
])
})
})
```
## 測試組件
React 組件有一個優點,它們通常很小且只依賴于傳入的 `props` ,因此測試起來很簡便。
首先,我們需要安裝 [Enzyme](http://airbnb.io/enzyme/) ,Enzyme 底層使用了 [React Test Utilities](https://reactjs.org/docs/test-utils.html) ,但是更方便,更具可讀性,更強大。
```bash
npm install —save-dev enzyme
```
為了兼容 React 的版本,我們還需要安裝 Enzyme 適配器,Enzyme 提供了適配器用以兼容 `React16` ,`React 15.x`,`React 0.14.x`,`React 0.13.x`。如果你使用的是 React16,你可以使用下面的命令安裝相關依賴:
```bash
npm install --save-dev enzyme-adapter-react-16
```
為了測試組件,我們創建了一個 `setup()` 輔助函數,用來把模擬過的(stubbed)回調函數當作 props 傳入,然后使用 ([React Shallow Rendering](https://reactjs.org/docs/test-utils.html#shallow-rendering)) 來渲染組件。這樣就可以依據 “是否調用了回調函數” 的斷言來寫獨立的測試。
### 示例
```js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
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
```
上面的組件可以這樣來測試:
```js
import React from 'react'
import Enzyme, { mount } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16';
import Header from '../../components/Header'
?
Enzyme.configure({ adapter: new Adapter() });
?
function setup() {
const props = {
addTodo: jest.fn()
}
?
const enzymeWrapper = mount(<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.mock.calls.length).toBe(0)
input.props().onSave('Use Redux')
expect(props.addTodo.mock.calls.length).toBe(1)
})
})
})
```
## 測試 connected 組件
如果你使用類似 [React redux](https://github.com/reduxjs/react-redux) 的庫,你可能會使用 [高階組件](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750),比如 [`connect()` ](https://github.com/reduxjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)。可以讓你把 Redux state 注入到常規的 React 組件中。
考慮如下 `App` 組件
```js
import { connect } from 'react-redux'
?
class App extends Component { /* ... */ }
?
export default connect(mapStateToProps)(App)
```
在單元測試中,通常會這樣導入 `App` 組件:
```
import App from './App'
```
不過,上面這樣導入的是通過 `connect()` 方法返回的包裝組件,并非 `App` 組件本身,如果你想測試和 Redux 的整合,這很容易,通過 [`<Provider>`](https://github.com/reduxjs/react-redux/blob/master/docs/api.md#provider-store)包裹它后傳入用以單元測試的特殊 store 就可以了。但是有時候我們想測試的其實是不帶 Redux store 的組件的渲染。
為了測試 App 組件本身而不用處理裝飾器,我們推薦你導出未裝飾的組件:
```js
import { connect } from 'react-redux'
?
// 命名導出未連接的組件 (測試用)
export class App extends Component { /* ... */ }
?
// 默認導出已連接的組件 (app 用)
export default connect(mapStateToProps)(App)
```
由于默認導出的組件依舊是包裝過的組件,上面代碼中的導入依舊會生效,無須你更改已有的代碼。不過現在你可以通過下面這樣的辦法導入未裝飾的組件了:
```js
import { App } from './App'
```
如果你需要導入二者,可以按下面這樣做:
```js
import ConnectedApp, { App } from './App'
```
在 app 中,仍然正常地導入:
```js
import App from './App'
```
只在測試中使用命名導出。
> **混用 ES6 模塊和 CommonJS 的注意事項**
>
> 如果在應用代碼中使用 ES6,但在測試中使用 ES5,Babel 會通過其 [`interop`](https://old.babeljs.io/docs/plugins/#interop) 機制處理 ES6 的 `import` 和 CommonJS 的 `require` ,使得這兩種模式能一起使用,但其行為依舊有細微的區別。 如果在默認導出的附近增加另一個導出,將導致無法默認導出 `require('./App')`。此時,應代以 `require('./App').default`
## 對中間件的測試
中間件函數包裝了 Redux 中 `dispatch` 的行為,為了測試中間件的行為,我們需要模擬 `dispatch` 調用時的行為。
### 示例
首先,我們需要創建一個中間件函數,下述代碼和 [redux-thunk](https://github.com/reduxjs/redux-thunk/blob/master/src/index.js) 類似
```js
const thunk = ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
?
return next(action)
}
```
我們需要創造一個假的 `getState`,`dispatch` 和 `next` 函數,我們可以使用 `jest.fn()` 來創建 stubs,你也可以使用 sinon 等測試框架
我們可以像 Redux 一樣來觸發函數
```js
const create = () => {
const store = {
getState: jest.fn(() => ({})),
dispatch: jest.fn(),
};
const next = jest.fn()
?
const invoke = (action) => thunk(store)(next)(action)
?
return {store, next, invoke}
};
```
然后我們在適當的時機通過調用 `getState`,`dispatch`,`next`函數來測試中間件。
```js
it('passes through non-function action', () => {
const { next, invoke } = create()
const action = {type: 'TEST'}
invoke(action)
expect(next).toHaveBeenCalledWith(action)
})
?
it('calls the function', () => {
const { invoke } = create()
const fn = jest.fn()
invoke(fn)
expect(fn).toHaveBeenCalled()
});
?
it('passes dispatch and getState', () => {
const { store, invoke } = create()
invoke((dispatch, getState) => {
dispatch('TEST DISPATCH')
getState();
})
expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH')
expect(store.getState).toHaveBeenCalled()
});
```
在一些情況下,你需要修改 `create` 函數來模擬不同的 `getState` 和 `next` 。
## 詞匯表
- [Enzyme](http://airbnb.io/enzyme/): Enzyme 是一種用于 React 測試的 JavaScript 工具,它使得斷言、操作以及遍歷你的 React 組件的輸出變得更簡單。
- [React Test Utilities](https://reactjs.org/docs/test-utils.html) :React 提供的測試工具,被 Enzyme 使用
- [shallow renderer](http://airbnb.io/enzyme/docs/api/shallow.html): shallow renderer 使你可以實例化一個組件, 并有效地獲取其 `render` 方法的結果, 其渲染深度僅一層, 而非遞歸地將組件渲染為 DOM。 shallow renderer 對單元測試很有用, 你只要測試某個特定的組件,而不用管它的子組件。這也意味著,更改子組件不會影響到其父組件的測試。如果要測試一個組件和它所有的子組件,可以用 [`Enzyme's mount()`](http://airbnb.io/enzyme/docs/api/mount.html) 方法 ,這個方法會進行完全的 DOM 渲染。
- 自述
- 介紹
- 動機
- 核心概念
- 三大原則
- 先前技術
- 學習資源
- 生態系統
- 示例
- 基礎
- Action
- Reducer
- Store
- 數據流
- 搭配 React
- 示例:Todo List
- 高級
- 異步 Action
- 異步數據流
- Middleware
- 搭配 React Router
- 示例:Reddit API
- 下一步
- 技巧
- 配置 Store
- 遷移到 Redux
- 使用對象展開運算符
- 減少樣板代碼
- 服務端渲染
- 編寫測試
- 計算衍生數據
- 實現撤銷重做
- 子應用隔離
- 組織 Reducer
- Reducer 基礎概念
- Reducer 基礎結構
- Reducer 邏輯拆分
- Reducer 重構示例
- combineReducers 用法
- combineReducers 進階
- State 范式化
- 管理范式化數據
- Reducer 邏輯復用
- 不可變更新模式
- 初始化 State
- 結合 Immutable.JS 使用 Redux
- 常見問題
- 綜合
- Reducer
- 組織 State
- 創建 Store
- Action
- 不可變數據
- 代碼結構
- 性能
- 設計哲學
- React Redux
- 其它
- 排錯
- 詞匯表
- API 文檔
- createStore
- Store
- combineReducers
- applyMiddleware
- bindActionCreators
- compose
- react-redux 文檔
- API
- 排錯