Table of Contents generated with DocToc

前端单元测试探索

虽然很多公司有自己的测试部门,而且前端开发大多不涉及测试环节,但鉴于目前前端领域的快速发展,其涉及面越来越广,前端开发者们必然不能止步于目前的状态。我觉得学会编写良好的测试,不仅仅有利于自己整理需求、检查代码,更是一个优秀开发者的体现。

首先不得不推荐两篇文章:

前端自动化测试探索

测试驱动开发(TDD)介绍中的误区

Intro

单元测试到底是什么?

需要访问数据库的测试不是单元测试

需要访问网络的测试不是单元测试

需要访问文件系统的测试不是单元测试

— 修改代码的艺术

我们在单元测试中应该避免什么?

  • 太多的条件逻辑
  • 构造函数中做了太多事情
  • too many全局变量
  • too many静态方法
  • 无关逻辑
  • 过多外部依赖

TDD(Test-driven development)

测试驱动开发(TDD),其基本思路是通过测试来推动整个开发的进行。

  • 单元测试的首要目的不是为了能够编写出大覆盖率的全部通过的测试代码,而是需要从使用者(调用者)的角度出发,尝试函数逻辑的各种可能性,进而辅助性增强代码质量
  • 测试是手段而不是目的。测试的主要目的不是证明代码正确,而是帮助发现错误,包括低级的错误
  • 测试要快。快速运行、快速 编写
  • 测试代码保持简洁
  • 不会忽略失败的测试。一旦团队开始接受1个测试的构建失败,那么他们渐渐地适应2、3、4或者更多的失败。在这种情况下,测试集就不再起作用

IMPORTANT

  • 一定不能误解了TDD的核心目的!
  • 测试不是为了覆盖率和正确率
  • 而是作为实例,告诉开发人员要编写什么代码
  • 红灯(代码还不完善,测试挂)-> 绿灯(编写代码,测试通过)-> 重构(优化代码并保证测试通过)

大致过程

  1. 需求分析,思考实现。考虑如何“使用”产品代码,是一个实例方法还是一个类方法,是从构造函数传参还是从方法调用传参,方法的命名,返回值等。这时其实就是在做设计,而且设计以代码来体现。此时测试为红
  2. 实现代码让测试为绿
  3. 重构,然后重复测试
  4. 最终符合所有要求:
    • 每个概念都被清晰的表达
    • Not Repeat Self
    • 没有多余的东西
    • 通过测试

BDD(Behavior-driven development)

行为驱动开发(BDD),重点是通过与利益相关者的讨论,取得对预期的软件行为的清醒认识,其重点在于沟通

大致过程

  1. 从业务的角度定义具体的,以及可衡量的目标
  2. 找到一种可以达到设定目标的、对业务最重要的那些功能的方法
  3. 然后像故事一样描述出一个个具体可执行的行为。其描述方法基于一些通用词汇,这些词汇具有准确无误的表达能力和一致的含义。例如,expect, should, assert
  4. 寻找合适语言及方法,对行为进行实现
  5. 测试人员检验产品运行结果是否符合预期行为。最大程度的交付出符合用户期望的产品,避免表达不一致带来的问题

测试的分类 & 测试工具

分类

  • API/Func UnitTest
    • 测试不常变化的函数逻辑
    • 测试前后端API接口
  • UI UnitTest
    • 页面自动截图
    • 页面DOM元素检查
    • 跑通交互流程

工具

mocha + chai的API/Func UnitTest

mocha是一套前端测试工具,我们可以拿它和其他测试工具搭配。

而chai则是BDD/TDD测试断言库,提供诸如expect这样的测试语法

initial

下面两篇文章值得一看:

Testing in ES6 with Mocha and Babel 6

Using Babel

setup
1
2
$ npm i mocha --save-dev
$ npm i chai --save-dev
Use with es6

babel 6+

1
2
$ npm install --save-dev babel-register
$ npm install babel-preset-es2015 --save-dev
1
2
3
4
5
6
7
8
9
10
11
// package.json
{
"scripts": {
"test": "./node_modules/mocha/bin/mocha --compilers js:babel-register"
},
"babel": {
"presets": [
"es2015"
]
}
}

babel 5+

1
$ npm install --save-dev babel-core
1
2
3
4
5
6
// package.json
{
"scripts": {
"test": "./node_modules/mocha/bin/mocha --compilers js:babel-core/register"
}
}
Use with coffeescript
1
$ npm install --save coffee-script
1
2
3
4
5
{
"scripts": {
"test": "./node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register"
}
}
Use with es6+coffeescript

After done both…

1
2
3
4
5
{
"scripts": {
"test": "./node_modules/mocha/bin/mocha --compilers js:babel-core/register,coffee:coffee-script/register"
}
}
1
2
3
# $ mocha
$ npm t
$ npm test

chai

1
2
3
4
5
import chai from 'chai';

const assert = chai.assert;
const expect = chai.expect;
const should = chai.should();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
foo.should.be.a('string');
foo.should.equal('bar');
list.should.have.length(3);
obj.should.have.property('name');

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(list).to.have.length(3);
expect(obj).to.have.property('flavors');

assert.typeOf(foo, 'string');
assert.equal(foo, 'bar');
assert.lengthOf(list, 3);
assert.property(obj, 'flavors');

Test

测试的一个基本思路是,自身从函数的调用者出发,对函数进行各种情况的调用,查看其容错程度、返回结果是否符合预期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import chai from 'chai';
const assert = chai.assert;
const expect = chai.expect;
const should = chai.should();

describe('describe a test', () => {

it('should return true', () => {
let example = true;
// expect
expect(example).not.to.equal(false);
expect(example).to.equal(true);
// should
example.should.equal(true);
example.should.be.a(boolen);
[1, 2].should.have.length(2);
});

it('should check an object', () => {
// 对于多层嵌套的Object而言..
let nestedObj = {
a: {
b: 1
}
};
let nestedObjCopy = Object.assign({}, nestedObj);
nestedObj.a.b = 2;

// do a function to change nestedObjCopy.a.b
expect(nestedObjCopy).to.deep.equal(nestedObj);
expect(nestedObjCopy).to.have.property('a');
});
});

AsynTest

Testing Asynchronous Code with MochaJS and ES7 async/await

mocha无法自动监听异步方法的完成,需要我们在完成之后手动调用done()方法

而如果要在回调之后使用异步测试语句,则需要使用try/catch进行捕获。成功则done(),失败则done(error)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 普通的测试方法
it("should work", () =>{
console.log("Synchronous test");
});
// 异步的测试方法
it("should work", (done) =>{
setTimeout(() => {
try {
expect(1).not.to.equal(0);
done(); // 成功
} catch (err) {
done(err); // 失败
}
}, 200);
});

异步测试有两种方法完结:done或者返回Promise。而通过返回Promise,则不再需要编写笨重的try/catch语句

1
2
3
4
5
6
7
8
9
10
11
it("Using a Promise that resolves successfully with wrong expectation!", function() {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello World!");
}, 200);
});

return testPromise.then(function(result){
expect(result).to.equal("Hello!");
});
});

mock

mock是一个接口模拟库,我们可以通过它来模拟代码中的一些异步操作

React单元测试

Test React Component

React组件无法直接通过上述方法进行测试,需要安装enzyme依赖。

1
2
3
$ npm i --save-dev enzyme
#
$ npm i --save-dev react-addons-test-utils

假设有这样一个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ...省略部分import代码
class TestComponent extends React.Component {
constructor(props) {
super(props);
let {num} = props;
this.state = {
clickNum: num
}
this.handleClick = this.handleClick.bind(this)
}

handleClick() {
let {clickNum} = this.state;
this.setState({
clickNum: clickNum + 1
});
}

render() {
let {clickNum} = this.state;
return (
<div className="test_component">
{clickNum}
<span onClick={this.handleClick}>点我加1</span>
</div>
)

}
}

使用样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React from 'react';
import {expect} from 'chai';
import {shallow} from 'enzyme';

import TestComponent from '../components/TestComponent';

describe('Test TestComponent', () => {
// 创建一个虚拟的组件
const wrapper = shallow(
<TestComponent num={10} />/
);

/*
* 之后,我们可以:
* 通过wrapper.state()拿到组件的state
* 通过wrapper.instance()拿到组件实例,以此调用组件内的方法
* 通过wrapper.find()找到组件内的子组件
* 但是,无法通过wrapper.props()拿到组件的props
*/

// 测试该组件组外层的class
it('should render with currect wrapper', () => {
expect(wrapper.is('.test_component')).to.equal(true);
});

// 测试该组件初始化的state
it('should render with currect state', () => {
expect(wrapper.state()).to.deep.equal({
clickNum: 10
});
});

// 测试组件的方法
it('should add one', () => {
wrapper.instance().handleClick();
expect(wrapper.state()).to.deep.equal({
clickNum: 11
});
});
});

Test Redux

redux身为纯函数,非常便于mocha进行测试

1
2
3
4
5
6
7
8
9
10
11
12
// 测试actions
import * as ACTIONS from '../redux/actions';

describe('test actions', () => {
it('should return an action to create a todo', () => {
let expectedAction = {
type: ACTIONS.NEW_TODO,
todo: 'this is a new todo'
};
expect(ACTIONS.addNewTodo('this is a new todo')).to.deep.equal(expectedAction);
});
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 测试reducer
import * as REDUCERS from '../redux/reducers';
import * as ACTIONS from '../redux/actions';

describe('todos', () => {
let todos = [];
it('should add a new todo', () => {
todos.push({
todo: 'new todo',
complete: false
});
expect(REDUCERS.todos(todos, {
type: ACTIONS.NEW_TODO,
todo: 'new todo'
})).to.deep.equal([
{
todo: 'new todo',
complete: false
}
]);
});
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 还可以和store混用
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import chai from 'chai';
import thunkMiddleware from 'redux-thunk';
import * as REDUCERS from '../redux/reducers';
import defaultState from '../redux/ConstValues';
import * as ACTIONS from '../redux/actions'

const appReducers = combineReducers(REDUCERS);
const AppStore = createStore(appReducers, defaultState, applyMiddleware(thunk));
let state = Object.assign({}, AppStore.getState());

// 一旦注册就会时刻监听state变化
const subscribeListener = (result, done) => {
return AppStore.subscribe(() => {
expect(AppStore.getState()).to.deep.equal(result);
done();
});
};

describe('use store in unittest', () => {
it('should create a todo', (done) => {
// 首先取得我们的期望值
state.todos.append({
todo: 'new todo',
complete: false
});

// 注册state监听
let unsubscribe = subscribeListener(state, done);
AppStore.dispatch(ACTIONS.addNewTodo('new todo'));
// 结束之后取消监听
unsubscribe();
});
});

基于phantomjsselenium的UI UnitTest

PhantomJS是一个基于webkit的服务器端JavaScript API,即相当于在内存中跑了个无界面的webkit内核的浏览器。通过它我们可以模拟页面加载,并获取到页面上的DOM元素,进行一系列的操作,以此来模拟UI测试。但缺点是无法实时看见页面上的情况(不过可以截图)。

Selenium是专门为Web应用程序编写的一个验收测试工具,它直接运行在浏览器中。Selenium测试通常会调起一个可见的界面,但也可以通过设置,让它以PhantomJS的形式进行无界面的测试。

  • open 某个 url
  • 监听 onload 事件
  • 事件完成后调用 sendEvent 之类的 api 去点击某个 DOM 元素所在 point
  • 触发交互
  • 根据 UI 交互情况 延时 setTimeout (规避惰加载组件点不到的情况)继续 sendEvent 之类的交互

Getting started with Selenium Webdriver for node.js