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
, 测试方法参考第三部分