karma:測試過程管理工具。能夠監控文件變化自動執行單元測試,能夠緩存測試結果,能夠console.log顯示測試過程當中的變量css
mocha:測試框架。提供describe,it,beforeEach等函數管理你的 testcase,後面示例中會看到node
chai:BDD(行爲驅動開發)和TDD(測試驅動開發)雙測試風格的斷言庫react
enzyme:React測試工具,能夠相似 jquery 風格的 api 操做react 節點jquery
sinon: 提供 fake 數據, 替換函數調用等功能git
工具安裝就是 npm install
,這裏就再也不詳述,主要的配置項目在karma.conf.js中,能夠參考這個模板項目 react-redux-starter-kit 。若是項目中用到全局變量,好比jquery, momentjs等,須要在測試環境中全局引入,不然報錯,例如,在karma.conf中引入全局變量jQuery:github
{ files: [ './node_modules/jquery/jquery.min.js', { pattern: `./tests/test-bundler.js`, watched: false, served: true, included: true } ] }
在test-bundler.js中設置全局的變量,包括chai, sinon等:ajax
/* tests/test-bundler.js */ import 'babel-polyfill' import sinon from 'sinon' import chai from 'chai' import sinonChai from 'sinon-chai' import chaiAsPromised from 'chai-as-promised' import chaiEnzyme from 'chai-enzyme' chai.use(sinonChai) chai.use(chaiAsPromised) chai.use(chaiEnzyme()) global.chai = chai global.sinon = sinon global.expect = chai.expect global.should = chai.should() ...
先熱身看看簡單的函數如何單元測試:npm
/* helpers/validator.js */ export function checkUsername (name) { if (name.length === 0 || name.length > 15) { return '用戶名必須爲1-15個字' } return '' }
/* tests/helpers/validator.spec.js */ import * as Validators from 'helpers/validator' describe('helpers/validator', () => { describe('Function: checkUsername', () => { it('Should not return error while input foobar.', () => { expect(Validators.checkUsername('foobar')).to.be.empty }) it('Should return error while empty.', () => { expect(Validators.checkUsername('')).to.equal('用戶名必須爲1-15個字') }) it('Should return error while more then 15 words.', () => { expect(Validators.checkUsername('abcdefghijklmnop')).to.equal('用戶名必須爲1-15個字') expect(Validators.checkUsername('一二三四五六七八九十一二三四五六')).to.equal('用戶名必須爲1-15個字') }) }) })
describe能夠屢次嵌套使用,更清晰的描述測試功能的結構。執行單元測試: babel-node ./node_modules/karma/bin/karma start build/karma.conf
redux
在 redux 的理念中,react 組件應該分爲視覺組件 component 和 高階組件 container,UI與邏輯分離,更利於測試
。redux 的 example 裏,這兩種組件通常都分開文件去存放。本人認爲,若是視覺組件須要屢次複用,應該與container分開來寫,但若是基本不復用,或者能夠複用的組件已經專門組件化了(下面例子就是),那就不必分開寫,能夠寫在一個文件裏更方便管理,而後經過 export
和 export default
分別輸出api
/* componets/Register.js */ import React, { Component, PropTypes } from 'react' import { connect } from 'react-redux' import { FormGroup, FormControl, FormLabel, FormError, FormTip, Button, TextInput } from 'componentPath/basic/form' export class Register extends Component { render () { const { register, onChangeUsername, onSubmit } = this.props <div style={{padding: '50px 130px'}}> <FormGroup> <FormLabel>用戶名</FormLabel> <FormControl> <TextInput width='370px' limit={15} value={register.username} onChange={onChangeUsername} /> <FormTip>請輸入用戶名</FormTip> <FormError>{register.usernameError}</FormError> </FormControl> </FormGroup> <FormGroup> <Button type='primary' onClick={onSubmit}>提交</Button> </FormGroup> </div> } } Register.propTypes = { register: PropTypes.object.isRequired, onChangeUsername: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired } const mapStateToProps = (state) => { return { register: state.register } } const mapDispatchToProps = (dispatch) => { return { onChangeUsername: name => { ... }, onSubmit: () => { ... } } } export default connect(mapStateToProps, mapDispatchToProps)(Register)
測試 componet,這裏用到 enzyme
和 sinon
:
import React from 'react' import { bindActionCreators } from 'redux' import { Register } from 'components/Register' import { shallow } from 'enzyme' import { FormGroup, FormControl, FormLabel, FormError, FormTip, Dropdown, Button, TextInput } from 'componentPath/basic/form' describe('rdappmsg/trade_edit/componets/Plan', () => { let _props, _spies, _wrapper let register = { username: '', usernameError: '' } beforeEach(() => { _spies = {} _props = { register, ...bindActionCreators({ onChangeUsername: (_spies.onChangeUsername = sinon.spy()), onSubmit: (_spies.onSubmit = sinon.spy()) }, _spies.dispatch = sinon.spy()) } _wrapper = shallow(<Register {..._props} />) }) it('Should render as a <div>.', () => { expect(_wrapper.is('div')).to.equal(true) }) it('Should has two children.', () => { expect(_wrapper.children()).to.have.length(2); }) it('Each element of form should be <FormGroup>.', () => { _wrapper.children().forEach(function (node) { expect(node.is(FormGroup)).to.equal(true); }) }) it('Should render username properly.', () => { expect(_wrapper.find(TextInput).prop('value')).to.be.empty _wrapper.setProps({register: {...register, username: 'foobar' }}) expect(_wrapper.find(TextInput).prop('value')).to.equal('foobar') }) it('Should call onChangeUsername.', () => { _spies.onChangeUsername.should.have.not.been.called _wrapper.find(TextInput).prop('onChange')('hello') _spies.dispatch.should.have.been.called }) })
beforeEach
函數在每一個測試用例啓動前作一些初始化工做
enzyme shallow
的用法跟 jquery 的dom操做相似,能夠經過選擇器過濾出想要的節點,能夠接受 css 選擇器或者react class,如:find('.someClass')
, find(TextInput)
這裏用到了 sinon
的spies, 能夠觀察到函數的調用狀況。他還提供stub, mock功能,瞭解更多請 google
先來看一個普通的 action:
/* actions/register.js */ import * as Validator from 'helpers/validator' export const CHANGE_USERNAME_ERROR = 'CHANGE_USERNAME_ERROR' export function checkUsername (name) { return { type: CHANGE_USERNAME_ERROR, error: Validator.checkUsername(name) } }
普通的 action 就是一個簡單的函數,返回一個 object,測試起來跟前面的簡單函數例子同樣:
/* tests/actions/register.js */ import * as Actions from 'actions/register' describe('actions/register', () => { describe('Action: checkUsername', () => { it('Should export a constant CHANGE_USERNAME_ERROR.', () => { expect(Actions.CHANGE_USERNAME_ERROR).to.equal('CHANGE_USERNAME_ERROR') }) it('Should be exported as a function.', () => { expect(Actions.checkUsername).to.be.a('function') }) it('Should be return an action.', () => { const action = Actions.checkUsername('foobar') expect(action).to.have.property('type', Actions.CHANGE_USERNAME_ERROR) }) it('Should be return an action with error while input empty name.', () => { const action = Actions.checkUsername('') expect(action).to.have.property('error').to.not.be.empty }) }) })
再來看一下異步 action, 這裏功能是改變 username 的同時發起檢查:
export const CHANGE_USERNAME = 'CHANGE_USERNAME' export function changeUsername (name) { return (dispatch) => { dispatch({ type: CHANGE_USERNAME, name }) dispatch(checkUsername(name)) } }
測試代碼:
/* tests/actions/register.js */ import * as Actions from 'actions/register' describe('actions/register', () => { let actions let dispatchSpy let getStateSpy beforeEach(function() { actions = [] dispatchSpy = sinon.spy(action => { actions.push(action) }) }) describe('Action: changeUsername', () => { it('Should export a constant CHANGE_USERNAME.', () => { expect(Actions.CHANGE_USERNAME).to.equal('CHANGE_USERNAME') }) it('Should be exported as a function.', () => { expect(Actions.changeUsername).to.be.a('function') }) it('Should return a function (is a thunk).', () => { expect(Actions.changeUsername()).to.be.a('function') }) it('Should be return an action.', () => { const action = Actions.checkUsername('foobar') expect(action).to.have.property('type', Actions.CHANGE_USERNAME_ERROR) }) it('Should call dispatch CHANGE_USERNAME and CHANGE_USERNAME_ERROR.', () => { Actions.changeUsername('hello')(dispatchSpy) dispatchSpy.should.have.been.calledTwice expect(actions[0]).to.have.property('type', Actions.CHANGE_USERNAME) expect(actions[0]).to.have.property('name', 'hello') expect(actions[1]).to.have.property('type', Actions.CHANGE_USERNAME_ERROR) expect(actions[1]).to.have.property('error', '') }) }) })
假如如今產品需求變動,要求實時在後臺檢查 username
的合法性, 就須要用到 ajax 了, 這裏假設使用 Jquery 來實現 ajax 請求:
/* actions/register.js */ export const CHANGE_USERNAME_ERROR = 'CHANGE_USERNAME_ERROR' export function checkUsername (name) { return (dispatch) => { $.get('/check', {username: name}, (msg) => { dispatch({ type: CHANGE_USERNAME_ERROR, error: msg }) }) } }
要測試 ajax 請求,能夠用 sinon
的 fake XMLHttpRequest, 不用爲了測試改動 action 任何代碼:
/* tests/actions/register.js */ import * as Actions from 'actions/register' describe('actions/register', () => { let actions let dispatchSpy let getStateSpy let xhr let requests beforeEach(function() { actions = [] dispatchSpy = sinon.spy(action => { actions.push(action) }) xhr = sinon.useFakeXMLHttpRequest() requests = [] xhr.onCreate = function(xhr) { requests.push(xhr); }; }) afterEach(function() { xhr.restore(); }); describe('Action: checkUsername', () => { it('Should call dispatch CHANGE_USERNAME_ERROR.', () => { Actions.checkUsername('foo@bar')(dispatchSpy) const body = '不能含有特殊字符' // 手動設置 ajax response requests[0].respond(200, {'Content-Type': 'text/plain'}, body) expect(actions[0]).to.have.property('type', Actions. CHANGE_USERNAME_ERROR) expect(actions[0]).to.have.property('error', '不能含有特殊字符') }) }) })
reducer 就是一個普通函數 (state, action) => newState
, 測試方法參考第三部分