React+Redux單元測試一小時入門

1、工具介紹

  • karma:測試過程管理工具。能夠監控文件變化自動執行單元測試,能夠緩存測試結果,能夠console.log顯示測試過程當中的變量css

  • mocha:測試框架。提供describe,it,beforeEach等函數管理你的 testcase,後面示例中會看到node

  • chai:BDD(行爲驅動開發)和TDD(測試驅動開發)雙測試風格的斷言庫react

  • enzyme:React測試工具,能夠相似 jquery 風格的 api 操做react 節點jquery

  • sinon: 提供 fake 數據, 替換函數調用等功能git

2、環境準備

工具安裝就是 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()

...

3、簡單的函數測試

先熱身看看簡單的函數如何單元測試: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.confredux

圖片描述

3、component測試

在 redux 的理念中,react 組件應該分爲視覺組件 component 和 高階組件 container,UI與邏輯分離,更利於測試。redux 的 example 裏,這兩種組件通常都分開文件去存放。本人認爲,若是視覺組件須要屢次複用,應該與container分開來寫,但若是基本不復用,或者能夠複用的組件已經專門組件化了(下面例子就是),那就不必分開寫,能夠寫在一個文件裏更方便管理,而後經過 exportexport 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,這裏用到 enzymesinon

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

4、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.', () => {
      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', '不能含有特殊字符')
    }) 
  })
})

5、 reducer 的測試

reducer 就是一個普通函數 (state, action) => newState, 測試方法參考第三部分

相關文章
相關標籤/搜索