资讯专栏INFORMATION COLUMN

React+Redux单元测试一小时入门

xiongzenghui / 1239人阅读

摘要:可以监控文件变化自动执行单元测试,可以缓存测试结果,可以显示测试过程中的变量测试框架。执行单元测试三测试在的理念中,组件应该分为视觉组件和高阶组件,与逻辑分离,更利于测试。

一、工具介绍

karma:测试过程管理工具。可以监控文件变化自动执行单元测试,可以缓存测试结果,可以console.log显示测试过程中的变量

mocha:测试框架。提供describe,it,beforeEach等函数管理你的 testcase,后面示例中会看到

chai:BDD(行为驱动开发)和TDD(测试驱动开发)双测试风格的断言库

enzyme:React测试工具,可以类似 jquery 风格的 api 操作react 节点

sinon: 提供 fake 数据, 替换函数调用等功能

二、环境准备

工具安装就是 npm install,这里就不再详述,主要的配置项目在karma.conf.js中,可以参考这个模板项目 react-redux-starter-kit 。如果项目中用到全局变量,比如jquery, momentjs等,需要在测试环境中全局引入,否则报错,例如,在karma.conf中引入全局变量jQuery:

</>复制代码

  1. {
  2. files: [
  3. "./node_modules/jquery/jquery.min.js",
  4. {
  5. pattern: `./tests/test-bundler.js`,
  6. watched: false,
  7. served: true,
  8. included: true
  9. }
  10. ]
  11. }

在test-bundler.js中设置全局的变量,包括chai, sinon等:

</>复制代码

  1. /* tests/test-bundler.js */
  2. import "babel-polyfill"
  3. import sinon from "sinon"
  4. import chai from "chai"
  5. import sinonChai from "sinon-chai"
  6. import chaiAsPromised from "chai-as-promised"
  7. import chaiEnzyme from "chai-enzyme"
  8. chai.use(sinonChai)
  9. chai.use(chaiAsPromised)
  10. chai.use(chaiEnzyme())
  11. global.chai = chai
  12. global.sinon = sinon
  13. global.expect = chai.expect
  14. global.should = chai.should()
  15. ...
三、简单的函数测试

先热身看看简单的函数如何单元测试:

</>复制代码

  1. /* helpers/validator.js */
  2. export function checkUsername (name) {
  3. if (name.length === 0 || name.length > 15) {
  4. return "用户名必须为1-15个字"
  5. }
  6. return ""
  7. }

</>复制代码

  1. /* tests/helpers/validator.spec.js */
  2. import * as Validators from "helpers/validator"
  3. describe("helpers/validator", () => {
  4. describe("Function: checkUsername", () => {
  5. it("Should not return error while input foobar.", () => {
  6. expect(Validators.checkUsername("foobar")).to.be.empty
  7. })
  8. it("Should return error while empty.", () => {
  9. expect(Validators.checkUsername("")).to.equal("用户名必须为1-15个字")
  10. })
  11. it("Should return error while more then 15 words.", () => {
  12. expect(Validators.checkUsername("abcdefghijklmnop")).to.equal("用户名必须为1-15个字")
  13. expect(Validators.checkUsername("一二三四五六七八九十一二三四五六")).to.equal("用户名必须为1-15个字")
  14. })
  15. })
  16. })

describe可以多次嵌套使用,更清晰的描述测试功能的结构。执行单元测试:
babel-node ./node_modules/karma/bin/karma start build/karma.conf

三、component测试

在 redux 的理念中,react 组件应该分为视觉组件 component 和 高阶组件 container,UI与逻辑分离,更利于测试。redux 的 example 里,这两种组件一般都分开文件去存放。本人认为,如果视觉组件需要多次复用,应该与container分开来写,但如果基本不复用,或者可以复用的组件已经专门组件化了(下面例子就是),那就没必要分开写,可以写在一个文件里更方便管理,然后通过 exportexport default 分别输出

</>复制代码

  1. /* componets/Register.js */
  2. import React, { Component, PropTypes } from "react"
  3. import { connect } from "react-redux"
  4. import {
  5. FormGroup,
  6. FormControl,
  7. FormLabel,
  8. FormError,
  9. FormTip,
  10. Button,
  11. TextInput
  12. } from "componentPath/basic/form"
  13. export class Register extends Component {
  14. render () {
  15. const { register, onChangeUsername, onSubmit } = this.props
  16. 用户名
  17. 请输入用户名
  18. {register.usernameError}
  19. }
  20. }
  21. Register.propTypes = {
  22. register: PropTypes.object.isRequired,
  23. onChangeUsername: PropTypes.func.isRequired,
  24. onSubmit: PropTypes.func.isRequired
  25. }
  26. const mapStateToProps = (state) => {
  27. return {
  28. register: state.register
  29. }
  30. }
  31. const mapDispatchToProps = (dispatch) => {
  32. return {
  33. onChangeUsername: name => {
  34. ...
  35. },
  36. onSubmit: () => {
  37. ...
  38. }
  39. }
  40. }
  41. export default connect(mapStateToProps, mapDispatchToProps)(Register)

测试 componet,这里用到 enzymesinon

</>复制代码

  1. import React from "react"
  2. import { bindActionCreators } from "redux"
  3. import { Register } from "components/Register"
  4. import { shallow } from "enzyme"
  5. import {
  6. FormGroup,
  7. FormControl,
  8. FormLabel,
  9. FormError,
  10. FormTip,
  11. Dropdown,
  12. Button,
  13. TextInput
  14. } from "componentPath/basic/form"
  15. describe("rdappmsg/trade_edit/componets/Plan", () => {
  16. let _props, _spies, _wrapper
  17. let register = {
  18. username: "",
  19. usernameError: ""
  20. }
  21. beforeEach(() => {
  22. _spies = {}
  23. _props = {
  24. register,
  25. ...bindActionCreators({
  26. onChangeUsername: (_spies.onChangeUsername = sinon.spy()),
  27. onSubmit: (_spies.onSubmit = sinon.spy())
  28. }, _spies.dispatch = sinon.spy())
  29. }
  30. _wrapper = shallow()
  31. })
  32. it("Should render as a
    .", () => {
  33. expect(_wrapper.is("div")).to.equal(true)
  34. })
  35. it("Should has two children.", () => {
  36. expect(_wrapper.children()).to.have.length(2);
  37. })
  38. it("Each element of form should be .", () => {
  39. _wrapper.children().forEach(function (node) {
  40. expect(node.is(FormGroup)).to.equal(true);
  41. })
  42. })
  43. it("Should render username properly.", () => {
  44. expect(_wrapper.find(TextInput).prop("value")).to.be.empty
  45. _wrapper.setProps({register: {...register, username: "foobar" }})
  46. expect(_wrapper.find(TextInput).prop("value")).to.equal("foobar")
  47. })
  48. it("Should call onChangeUsername.", () => {
  49. _spies.onChangeUsername.should.have.not.been.called
  50. _wrapper.find(TextInput).prop("onChange")("hello")
  51. _spies.dispatch.should.have.been.called
  52. })
  53. })

beforeEach函数在每个测试用例启动前做一些初始化工作

enzyme shallow 的用法跟 jquery 的dom操作类似,可以通过选择器过滤出想要的节点,可以接受 css 选择器或者react class,如:find(".someClass")find(TextInput)

这里用到了 sinon 的spies, 可以观察到函数的调用情况。他还提供stub, mock功能,了解更多请 google

四、action 的测试

先来看一个普通的 action:

</>复制代码

  1. /* actions/register.js */
  2. import * as Validator from "helpers/validator"
  3. export const CHANGE_USERNAME_ERROR = "CHANGE_USERNAME_ERROR"
  4. export function checkUsername (name) {
  5. return {
  6. type: CHANGE_USERNAME_ERROR,
  7. error: Validator.checkUsername(name)
  8. }
  9. }

普通的 action 就是一个简单的函数,返回一个 object,测试起来跟前面的简单函数例子一样:

</>复制代码

  1. /* tests/actions/register.js */
  2. import * as Actions from "actions/register"
  3. describe("actions/register", () => {
  4. describe("Action: checkUsername", () => {
  5. it("Should export a constant CHANGE_USERNAME_ERROR.", () => {
  6. expect(Actions.CHANGE_USERNAME_ERROR).to.equal("CHANGE_USERNAME_ERROR")
  7. })
  8. it("Should be exported as a function.", () => {
  9. expect(Actions.checkUsername).to.be.a("function")
  10. })
  11. it("Should be return an action.", () => {
  12. const action = Actions.checkUsername("foobar")
  13. expect(action).to.have.property("type", Actions.CHANGE_USERNAME_ERROR)
  14. })
  15. it("Should be return an action with error while input empty name.", () => {
  16. const action = Actions.checkUsername("")
  17. expect(action).to.have.property("error").to.not.be.empty
  18. })
  19. })
  20. })

再来看一下异步 action, 这里功能是改变 username 的同时发起检查:

</>复制代码

  1. export const CHANGE_USERNAME = "CHANGE_USERNAME"
  2. export function changeUsername (name) {
  3. return (dispatch) => {
  4. dispatch({
  5. type: CHANGE_USERNAME,
  6. name
  7. })
  8. dispatch(checkUsername(name))
  9. }
  10. }

测试代码:

</>复制代码

  1. /* tests/actions/register.js */
  2. import * as Actions from "actions/register"
  3. describe("actions/register", () => {
  4. let actions
  5. let dispatchSpy
  6. let getStateSpy
  7. beforeEach(function() {
  8. actions = []
  9. dispatchSpy = sinon.spy(action => {
  10. actions.push(action)
  11. })
  12. })
  13. describe("Action: changeUsername", () => {
  14. it("Should export a constant CHANGE_USERNAME.", () => {
  15. expect(Actions.CHANGE_USERNAME).to.equal("CHANGE_USERNAME")
  16. })
  17. it("Should be exported as a function.", () => {
  18. expect(Actions.changeUsername).to.be.a("function")
  19. })
  20. it("Should return a function (is a thunk).", () => {
  21. expect(Actions.changeUsername()).to.be.a("function")
  22. })
  23. it("Should be return an action.", () => {
  24. const action = Actions.checkUsername("foobar")
  25. expect(action).to.have.property("type", Actions.CHANGE_USERNAME_ERROR)
  26. })
  27. it("Should call dispatch CHANGE_USERNAME and CHANGE_USERNAME_ERROR.", () => {
  28. Actions.changeUsername("hello")(dispatchSpy)
  29. dispatchSpy.should.have.been.calledTwice
  30. expect(actions[0]).to.have.property("type", Actions.CHANGE_USERNAME)
  31. expect(actions[0]).to.have.property("name", "hello")
  32. expect(actions[1]).to.have.property("type", Actions.CHANGE_USERNAME_ERROR)
  33. expect(actions[1]).to.have.property("error", "")
  34. })
  35. })
  36. })

假如现在产品需求变更,要求实时在后台检查 username 的合法性, 就需要用到 ajax 了, 这里假设使用 Jquery 来实现 ajax 请求:

</>复制代码

  1. /* actions/register.js */
  2. export const CHANGE_USERNAME_ERROR = "CHANGE_USERNAME_ERROR"
  3. export function checkUsername (name) {
  4. return (dispatch) => {
  5. $.get("/check", {username: name}, (msg) => {
  6. dispatch({
  7. type: CHANGE_USERNAME_ERROR,
  8. error: msg
  9. })
  10. })
  11. }
  12. }

要测试 ajax 请求,可以用 sinon 的 fake XMLHttpRequest, 不用为了测试改动 action 任何代码:

</>复制代码

  1. /* tests/actions/register.js */
  2. import * as Actions from "actions/register"
  3. describe("actions/register", () => {
  4. let actions
  5. let dispatchSpy
  6. let getStateSpy
  7. let xhr
  8. let requests
  9. beforeEach(function() {
  10. actions = []
  11. dispatchSpy = sinon.spy(action => {
  12. actions.push(action)
  13. })
  14. xhr = sinon.useFakeXMLHttpRequest()
  15. requests = []
  16. xhr.onCreate = function(xhr) {
  17. requests.push(xhr);
  18. };
  19. })
  20. afterEach(function() {
  21. xhr.restore();
  22. });
  23. describe("Action: checkUsername", () => {
  24. it("Should call dispatch CHANGE_USERNAME_ERROR.", () => {
  25. Actions.checkUsername("foo@bar")(dispatchSpy)
  26. const body = "不能含有特殊字符"
  27. // 手动设置 ajax response
  28. requests[0].respond(200, {"Content-Type": "text/plain"}, body)
  29. expect(actions[0]).to.have.property("type", Actions. CHANGE_USERNAME_ERROR)
  30. expect(actions[0]).to.have.property("error", "不能含有特殊字符")
  31. })
  32. })
  33. })
五、 reducer 的测试

reducer 就是一个普通函数 (state, action) => newState, 测试方法参考第三部分

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/80131.html

相关文章

  • 本命年定要记得穿红裤衩:2015年总结

    摘要:年终总结结果到这个时间才写,其实也是无奈。这一年最重要的事情就是顺利从一只学生狗转职为一只社畜。四月份毕业之后以前端工程师的职位入职天猫,到现在也差不多工作一年了。 年终总结结果到这个时间才写,其实也是无奈。本来计划过年写的,没想到Steam竟然开了个农历春节特惠,然后就被各种游戏打了,辣鸡平台,敛我钱财,颓我精神,耗我青春,害我单身 以下全都是个人看法,如果有不认同的地方,请大吼一声...

    AlienZHOU 评论0 收藏0
  • 本命年定要记得穿红裤衩:2015年总结

    摘要:年终总结结果到这个时间才写,其实也是无奈。这一年最重要的事情就是顺利从一只学生狗转职为一只社畜。四月份毕业之后以前端工程师的职位入职天猫,到现在也差不多工作一年了。 年终总结结果到这个时间才写,其实也是无奈。本来计划过年写的,没想到Steam竟然开了个农历春节特惠,然后就被各种游戏打了,辣鸡平台,敛我钱财,颓我精神,耗我青春,害我单身 以下全都是个人看法,如果有不认同的地方,请大吼一声...

    xi4oh4o 评论0 收藏0
  • Redux入门教程(快速上手)

    摘要:接下来演示不变性打开终端并启动输入。修改代码如下我们使用在控制台中打印出当前的状态。可以在控制台中确认新的商品已经添加了。修改和文件最后,我们在中分发这两个保存完代码之后,可以在浏览器的控制台中检查修改和删除的结果。 典型的Web应用程序通常由共享数据的多个UI组件组成。通常,多个组件的任务是负责展示同一对象的不同属性。这个对象表示可随时更改的状态。在多个组件之间保持状态的一致性会是一...

    amuqiao 评论0 收藏0
  • 前端最实用书签(持续更新)

    摘要:前言一直混迹社区突然发现自己收藏了不少好文但是管理起来有点混乱所以将前端主流技术做了一个书签整理不求最多最全但求最实用。 前言 一直混迹社区,突然发现自己收藏了不少好文但是管理起来有点混乱; 所以将前端主流技术做了一个书签整理,不求最多最全,但求最实用。 书签源码 书签导入浏览器效果截图showImg(https://segmentfault.com/img/bVbg41b?w=107...

    sshe 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<