寸志

测试驱动编写 React 简易计算器

本文的源码DEMO 都可以点击链接找到。

在5月30号 Teambition 组织的 React 的分享会中,我分享了如何对 React 组件进行单元测试,本文将做一些介绍和记录,以飨读者。

单元测试的重要性

此处省略十万字。

模块范式和测试方案

React 的开发并不脱离前端的开发范式。下表总结了 React 开发各个环节的一些可选方案。

Code Runtime Unit Test Test Runner
Global In Order Jasmine
Mocha

Tools:
Jasmine-react
Chance
Manual
Karma
AMD RequireJS
CommonJS Browserify
Webpack
RequireJS
ES6 Module Browserify
Webpack
RequireJS
CommonJS Jest

模块范式

目前前端通常有四种代码的组织范式。

  • Global:即无论是 React 还是 JSXTransformer,包括 业务代码都顺序的引入到页面中。通过全局对象来实现模块的共享。即如 React 入门 这样的例子;
  • AMD:我们也可以在 AMD 的项目中使用 React,AMD 已经是一种非常成熟的方案,而且社区对 AMD 的支持也非常广泛。我们可以把 Flux 中的各个要件都写成 AMD 模块,然后异步加载到前端配合使用;
  • CommonJS:CommonJS 是 React 项目本身代码组织的方式,也是 React 社区许多组件的模块化方案;基于 CommonJS;然后是用 Browserify 和 Webpack 来搭建运行时,当然通过一些工具转化成 AMD 模块也行;
  • ES6 Module:编写未来的模块,通过 babel 等工具转化成现在可用的模块。

测试用例

测试用例代码必须依附于开发范式,无论选择组织范式,代码必须都是分模块的(分文件、分模块),这样测试用例也可以分模块细粒度的编写。至于说基于何种测试框架,前端推荐使用 Jasmine,再加上一些测试的辅助工具即可,比如做 spy、mock 等。除此之外我们可以使用像 Karma 这样 Testing Runner,最大限度地排除开发过程中的重复劳动。

Jest

Jest 是 Facebook 打造的无脑的 CommonJS 模块测试框架。优点如下:

  • 熟悉,基于 Jasmine;
  • 轻量,一个待测试模块文件,一个测试文件,命令行就可以跑,无需浏览器;
  • 内置 mock 方案,自动 mock 所有模块;

但,跑了几个官方的例子,有的不通,Github 上看说是 jsdom 的问题,必须使用 0.10.x 版的 node。呵呵,还是使用小而美的组合比较靠谱。而且 Jest 只能用来测试 CommonJS 范式的代码。

ES6 + Webpack + Jasmine + Karma 组合

我选择了 ES6 + Webpack + Jasmine + Karma 组合。

Code Runtime Unit Test Test Runner
Global In Order Jasmine
Mocha

Tools:
Jasmine-react
Chance
Manual
Karma
AMD RequireJS
CommonJS Browserify
Webpack
RequireJS
ES6 Module Browserify
Webpack
RequireJS
CommonJS Jest

ES6 Module,编写未来的代码,相信不久的将来 React 也会切换到 ES6 Module 上来。使用 Webpack 来 bundle 代码实现运行时,作为工具可以随时替换,如果之后有更好的工具就可以换掉。Jasmine 和 Karma 就不用细说了。

开始

我们的目标(也是最终结果):

React Caculator

拆解成三个模块来实现这个计算器:

  • Caculator.js:主界面,包括计算结果显示屏;
  • Button.js:每一个按钮;
  • Parser.js:用来解析用户的输入流(2+1=+3=5+1-…),产生结果,本质是一个状态机。

搭建 TDD 环境

新建目录,添加文件如下:

├── src
│   ├── Button.js
│   ├── Button.less
│   ├── Caculator.js
│   ├── Caculator.less
│   ├── Parser.js
├── test
│   ├── specs
│   │   ├── Button.spec.js
│   │   ├── Caculator.spec.js
│   │   └── Parser.spec.js
│   └── test-main.js
├── package.json
├── karma.conf.js
└── webpack.config.js
karma.conf.js

该文件通过 karma init 生成,然后做一些简单修改,添加 karma-webpack 插件把 test/test-main.js bundle 成一个可运行在浏览器中的测试文件:

// 监听文件变化,重新运行测试
files: [
  // included: false 为不包含这些文件到浏览器中
  {pattern: 'src/*.js', included: false},
  {pattern: 'src/*.less', included: false},
  {pattern: 'test/specs/**/*.js', included: false},
  'test/test-main.js'
],
// ...
preprocessors: {
  'test/test-main.js': ['webpack']
},

webpack: {
  devtool: 'inline-source-map',
  module: {
    loaders: [
      { test: /\.js$/, loader: 'babel-loader' },
      { test: /\.less$/, loader: "style!css!less" }
    ]
  }
}

webpackMiddleware: {
    noInfo: true,
    devtool: "#inline-source-map"
},

plugins: [
    require("karma-webpack"),
    require('karma-jasmine'),
    require('karma-chrome-launcher')
]
test-main.js

ES6 module,加入测试用例。

import './specs/Button.spec'
import './specs/Caculator.spec'
import './specs/Parser.spec'
Caculator.spec.js

首先编写 Caculator 的测试用例:

import React from 'react/addons'
import Caculator from '../../src/Caculator'

var TestUtils = React.addons.TestUtils

describe('Caculator', function () {
  var caculator

  beforeEach(function () {
    caculator = TestUtils.renderIntoDocument(<Caculator />)
  })

  it('should display a caculator', function () {
    var divs = TestUtils.scryRenderedDOMComponentsWithTag(caculator, 'div')
    expect(divs.length).toBe(3)
    var as = TestUtils.scryRenderedDOMComponentsWithTag(caculator, 'a')
    expect(as.length).toBe(18)
  })
})

TestUtils.renderIntoDocument 较于 React.render 的优点在于,并不会把组件渲染到页面上,这样测试用例之间不会互相污染。TestUtils 多个像 scryRenderedDOMComponentsWithTag 这样的方法,便于你在 React 组件中查找子对象(可以是标记名、组件名等)。

Caculator.js

首先编写一个简单的 React 组件:

"use strict";

import './Caculator.less'

export default React.createClass({
  render: function () {
    return (<div></div>)
  }
})

运行 karma start,运行测试用例:

$ karma start
INFO [karma]: Karma v0.12.33 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 43.0.2357 (Mac OS X 10.10.4)]: Connected on socket SQQx_6CxC3UHjooVPFk7 with id 86084967
INFO [karma]: Delaying execution, these browsers are not ready: Chrome 43.0.2357 (Mac OS X 10.10.4)
Chrome 43.0.2357 (Mac OS X 10.10.4) Caculator should display a caculator FAILED
  Expected 1 to be 3.
  Expected 0 to be 18.
Chrome 43.0.2357 (Mac OS X 10.10.4): Executed 1 of 1 (1 FAILED) (0 secs / 0.026 Chrome 43.0.2357 (Mac OS X 10.10.4): Executed 1 of 1 (1 FAILED) ERROR (0.003 secs / 0.026 secs)

接下来我们编写 Caculator.js 的实现逻辑,以及修改 Button.spec.jsButton.js,实现计算器的 UI 功能。这里不再深入细节中,大家可以查看示例代码

事件模拟

Button 会注册一个 click 监听函数,当用户点击时,会通知 Caculator 输入的内容是什么。下面是这部分的测试用例:

it('should call onPress as being clicked', function () {
  var letter
  var button = TestUtils.renderIntoDocument(
    <Button
      letter="=" onPress={function (lt) {
        letter = lt
      }}
    />
  )

  TestUtils.Simulate.click(button.getDOMNode())

  expect(letter).toBe('=')
})

TestUtils.SimulateTestUtils 提供的另外一个功能,可以模拟用户的操作,向组件发送事件。更多相关的使用可以参考React 相关文档

Parser.js

Parser.js 是计算器的算法核心,提供了两个接口:

  • .take(letter),将用户每次点击的按钮输入到解析器中;
  • .getScreen(),获取屏幕上应该显示的值。

// Caculator.js
updateScreen: function () {
  this.setState({
    screen: this.parser.getScreen()
  })
},
onPress: function (letter) {
  this.parser.take(letter)
  this.updateScreen()
},
componentDidMount: function () {
  this.parser = new Parser()
  this.updateScreen()
}

在用户点击按钮的时候,输入 letter,然后调用 getScreen(),通过 setState 来更新显示。

部分测试用例如下:

it('should handle ±', function () {
  parser.take('1')
  expect(parser.getScreen()).toBe('1')
  parser.take('±')
  expect(parser.getScreen()).toBe('-1')
  parser.take('±')
  expect(parser.getScreen()).toBe('1')
})

it('should accpet float', function () {
  parser.take('1')
  parser.take('.')
  parser.take('1')
  expect(parser.getScreen()).toBe('1.1')
  parser.take('±')
  expect(parser.getScreen()).toBe('-1.1')
})

it('should clear screen when user click C', function () {
  parser.take('1')
  parser.take('.')
  parser.take('1')
  expect(parser.getScreen()).toBe('1.1')
  parser.take('C')
  expect(parser.getScreen()).toBe(0)
})
状态图

关于 Parser.js 里的算法,琢磨了很久,怎么改代码都写不好,很明显的问题就是,同一个输入在不同状态下,需要实施的操作完全不一样。于是停止 coding,先把状态图画出来:

Caculator State Machine

  • s1:初始状态(求值操作或者清空操作都会回到这个状态。);
  • s2:左操作数输入中;
  • s3:右操作数输入中;
  • s4:出错(除0操作导致);

状态图画好,一切引刃而解,据此添加更多的测试用例,在 Parser.js 把逻辑实现即可。

webpack.config.js

组件都写好了,新建 dist 目录,添加 dist/index.html 和 入口 ‘src/index.js’ 文件,是时候把组件组装成起来了:

// webpack.config.js
module.exports = {
    entry: "./src/index.js",
    output: {
        path: 'dist/',
        filename: "bundle.js"
    },
    module: {
        loaders: [
            { test: /\.js$/, loader: 'babel-loader' },
            { test: /\.less$/, loader: "style!css!less" }
        ]
    }
}

通过 webpack 把应用打包到 dist/bundle.js。做一些样式方面的调整就行啦。猛击 DEMO

总结

单元测试驱动开发除了能够保证代码质量以外,一定还可以减少调试的重复劳动。比如 Parser 的输入序列使用测试用例来输入很简单,如果使用手动点击就很麻烦了。针对 React 组件的单元测试还有点本文并没有提到,比如 React-Jasmine rewire 的使用等等,大家可以自己琢磨。