一、工具介绍
karma:测试过程管理工具。可以监控文件变化自动执行单元测试,可以缓存测试结果,可以console.log显示测试过程中的变量
mocha:测试框架。提供describe,it,beforeEach等函数管理你的 testcase,后面示例中会看到
chai:BDD(行为驱动开发)和TDD(测试驱动开发)双测试风格的断言库
enzyme:React测试工具,可以类似 jquery 风格的 api 操作react 节点
二、环境准备
工具安装就是 npm install
,这里就不再详述,主要的配置项目在karma.conf.js中,可以参考这个模板项目 react-redux-starter-kit 。如果项目中用到全局变量,比如jquery,momentjs等,需要在测试环境中全局引入,否则报错,例如,在karma.conf中引入全局变量jQuery:
{ files: [ './node_modules/jquery/jquery.min.js',{ pattern: `./tests/test-bundler.js`,watched: false,served: true,included: true } ] }
在test-bundler.js中设置全局的变量,包括chai,sinon等:
/* 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() ...
三、简单的函数测试
先热身看看简单的函数如何单元测试:
/* 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
三、component测试
在 redux 的理念中,react 组件应该分为视觉组件 component 和 高阶组件 container,UI与逻辑分离,更利于测试
。redux 的 example 里,这两种组件一般都分开文件去存放。本人认为,如果视觉组件需要多次复用,应该与container分开来写,但如果基本不复用,或者可以复用的组件已经专门组件化了(下面例子就是),那就没必要分开写,可以写在一个文件里更方便管理,然后通过 export
和 export default
分别输出
/* 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,Dropdown,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 的测试
先来看一个普通的 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.',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','不能含有特殊字符') }) }) })