前端开发的一个特色是更多的会涉及用户界面,当开发规模达到必定程度时,几乎注定了其复杂度会成倍的增加。javascript
不管是在代码的初始搭建过程当中,仍是以后难以免的重构和修正bug过程当中,经常会陷入逻辑难以梳理、没法掌握全局关联的境地。php
而单元测试做为一种“提纲挈领、保驾护航”的基础手段,为开发提供了“围墙和脚手架”,能够有效的改善这些问题。css
做为一种经典的开发和重构手段,单元测试在软件开发领域被普遍承认和采用;前端领域也逐渐积累起了丰富的测试框架和最佳实践。html
本文将按以下顺序进行说明:前端
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。java
简单来讲,单元
就是人为规定的最小的被测功能模块。单元测试是在软件开发过程当中要进行的最低级别的测试活动,软件的独立单元将在与程序的其余部分相隔离的状况下进行测试。node
测试框架的做用是提供一些方便的语法来描述测试用例,以及对用例进行分组。react
断言是单元测试框架中核心的部分,断言失败会致使测试不经过,或报告错误信息。webpack
对于常见的断言,举一些例子以下:es6
同等性断言 Equality Asserts
比较性断言 Comparison Asserts
类型性断言 Type Asserts
条件性测试 Condition Test
断言库主要提供上述断言的语义化方法,用于对参与测试的值作各类各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。常见的断言库有 Should.js, Chai.js 等。
为某个特殊目标而编制的一组测试输入、执行条件以及预期结果,以便测试某个程序路径或核实是否知足某个特定需求。
通常的形式为:
it('should ...', function() {
...
expect(sth).toEqual(sth);
});
复制代码
一般把一组相关的测试称为一个测试套件
通常的形式为:
describe('test ...', function() {
it('should ...', function() { ... });
it('should ...', function() { ... });
...
});
复制代码
正如
spy
字面的意思同样,咱们用这种“间谍”来“监视”函数的调用状况
经过对监视的函数进行包装,能够经过它清楚的知道该函数被调用过几回、传入什么参数、返回什么结果,甚至是抛出的异常状况。
var spy = sinon.spy(MyComp.prototype, 'componentDidMount');
...
expect(spy.callCount).toEqual(1);
复制代码
有时候会使用
stub
来嵌入或者直接替换掉一些代码,来达到隔离的目的
一个stub
可使用最少的依赖方法来模拟该单元测试。好比一个方法可能依赖另外一个方法的执行,然后者对咱们来讲是透明的。好的作法是使用stub 对它进行隔离替换。这样就实现了更准确的单元测试。
var myObj = {
prop: function() {
return 'foo';
}
};
sinon.stub(myObj, 'prop').callsFake(function() {
return 'bar';
});
myObj.prop(); // 'bar'
复制代码
mock
通常指在测试过程当中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来建立以便测试的测试方法
广义的讲,以上的 spy 和 stub 等,以及一些对模块的模拟,对 ajax 返回值的模拟、对 timer 的模拟,都叫作 mock 。
用于统计测试用例对代码的测试状况,生成相应的报表,好比 istanbul
是常见的测试覆盖率统计工具
不一样于"传统的"(其实也没出现几年)的 jasmine / Mocha / Chai 等前端测试框架 -- Jest
的使用更简单,而且提供了更高的集成度、更丰富的功能。
Jest 是 Facebook 出品的一个测试框架,相对其余测试框架,其一大特色就是就是内置了经常使用的测试工具,好比自带断言、测试覆盖率工具,实现了开箱即用。
此外, Jest 的测试用例是并行执行的,并且只执行发生改变的文件所对应的测试,提高了测试速度。
编写单元测试的语法一般很是简单;对于jest
来讲,因为其内部使用了 Jasmine 2
来进行测试,故其用例语法与 Jasmine 相同。
实际上,只要先记这住四个单词,就足以应付大多数测试状况了:
describe
: 定义一个测试套件it
:定义一个测试用例expect
:断言的判断条件toEqual
:断言的比较结果describe('test ...', function() {
it('should ...', function() {
expect(sth).toEqual(sth);
expect(sth.length).toEqual(1);
expect(sth > oth).toEqual(true);
});
});
复制代码
Jest 号称本身是一个 “Zero configuration testing platform”,只需在 npm scripts
里面配置了test: jest
,便可运行npm test
,自动识别并测试符合其规则的(通常是 __test__
目录下的)用例文件。
实际使用中,适当的自定义配置一下,会获得更适合咱们的测试场景:
//jest.config.js
module.exports = {
modulePaths: [
"<rootDir>/src/"
],
moduleNameMapper: {
"\.(css|less)$": '<rootDir>/__test__/NullModule.js'
},
collectCoverage: true,
coverageDirectory: "<rootDir>/src/",
coveragePathIgnorePatterns: [
"<rootDir>/__test__/"
],
coverageReporters: ["text"],
};
复制代码
在这个简单的配置文件中,咱们指定了测试的“根目录”,配置了覆盖率(内置的istanbul
)的一些格式,并将本来在webpack中对样式文件的引用指向了一个空模块,从而跳过了这一对测试无伤大雅的环节
//NullModule.js
module.exports = {};
复制代码
另外值得一提的是,因为jest.config.js
是一个会在npm
脚本中被调用的普通 JS 文件,而非XXX.json
或.XXXrc
的形式,因此 nodejs 的各自操做均可以进行,好比引入 fs 进行预处理读写等,灵活性很是高,能够很好的兼容各类项目
因为是面向src
目录下测试其React代码,而且还使用了ES6语法,因此项目下须要存在一个.babelrc
文件:
{
"presets": ["env", "react"]
}
复制代码
以上是基本的配置,而实际因为webpack能够编译es6的模块,通常将babel中设为{ "modules": false }
,此时的配置为:
//package.json
"scripts": {
"test": "cross-env NODE_ENV=test jest",
},
复制代码
//.babelrc
{
"presets": [
["es2015", {"modules": false}],
"stage-1",
"react"
],
"plugins": [
"transform-decorators-legacy",
"react-hot-loader/babel"
],
"env": {
"test": {
"presets": [
"es2015", "stage-1", "react"
],
"plugins": [
"transform-decorators-legacy",
"react-hot-loader/babel"
]
}
}
}
复制代码
Enzyme 来自于活跃在 JavaScript 开源社区的 Airbnb 公司,是对官方测试工具库(react-addons-test-utils)的封装。
这个单词的伦敦读音为 ['enzaɪm]
,酵素或酶的意思,Airbnb 并无给它设计一个图标,估计就是想取用它来分解 React 组件的意思吧。
它模拟了 jQuery 的 API,很是直观而且易于使用和学习,提供了一些不同凡响的接口和几个方法来减小测试的样板代码,方便判断、操纵和遍历 React Components 的输出,而且减小了测试代码和实现代码之间的耦合。
通常使用 Enzyme 中的 mount
或 shallow
方法,将目标组件转化为一个 ReactWrapper
对象,并在测试中调用其各类方法:
import Enzyme,{ mount } from 'enzyme';
...
describe('test ...', function() {
it('should ...', function() {
wrapper = mount(
<MyComp isDisabled={true} />
);
expect( wrapper.find('input').exists() ).toBeTruthy();
});
});
复制代码
图中这位“我牵着马”的并非卷帘大将沙悟净...其实图中的故事正是人所皆知的“特洛伊木马”;大概意思就是希腊人围困了特洛伊人十多年,久攻不下,心生一计,把营盘都撤了,只留下一个巨大的木马(里面装着士兵),以及这位被扒光还被打得够呛的人,也就是此处要谈的主角sinon,由他欺骗特洛伊人 --- 后面的剧情你们就都熟悉了。
因此这个命名的测试工具呢,也正是各类假装渗透方法的合集,为单元测试提供了独立而丰富的 spy, stub 和 mock 方法,兼容各类测试框架。
虽然 Jest 自己也有一些实现 spy 等的手段,但 sinon 使用起来更加方便。
这里不展开讨论经典的 “测试驱动开发”(TDD - test driven development) 理论 -- 简单的说,把测试正向加诸开发,先写用例再逐步实现,就是TDD,这是很好理解的。
而当咱们反过头来,对既有代码补充测试用例,使其测试覆盖率不断提升,并在此过程当中改善原有设计,修复潜在问题,同时又保证原有接口不收影响,这种 TDD 行为虽然没人称之为“测试驱动重构”(test driven refactoring),但“重构”这个概念自己就包含了用测试保驾护航的意思,是必不可少的题中之意。
对于一些组件和共有函数等,完善的测试也是一种最好的使用说明书。
因为测试结果中,成功的用例会用绿色表示,而失败的部分会显示为红色,因此单元测试也经常被称为 “Red/Green Testing” 或 “Red/Green Refactoring” , 这也是 TDD 中的通常性步骤:
这就是 jest
内置的 istanbul
输出的覆盖率结果。
之因此叫作“伊斯坦布尔”,是由于土耳其地毯世界闻名,而地毯是用来"覆盖"的🤦♀️。
表格中的第2列至第5列,分别对应四个衡量维度:
if
代码块都执行了测试结果根据覆盖率被分为“绿色、黄色、红色”三种,应该视具体状况尽可能提升相应模块的测试覆盖率。
合理编写组件化的 React,并将足够独立、功能专注的组件做为测试的单元,将使得单元测试变得容易;
反之,测试的过程让咱们更易厘清关系,将本来的组件重构或分解成更合理的结构。分离出的子组件每每也更容易写成stateless
的无状态组件,使得性能和关注点更加优化。
对于一些以前定义并不清晰的组件,能够统一引入 prop-types
,明确组件可接收的props
;一方面能够在开发/编译过程当中随时发现错误,另外也能够在团队中其余成员引用组件时造成一个明晰的列表。
能够用beforeEach
和afterEach
作一些统一的预置和蔼后工做,在每一个用例的以前和以后都会自动调用:
describe('test components/Comp', function() {
let wrapper;
let spy;
beforeEach(function() {
jest.useFakeTimers();
spy = sinon.spy(Comp.prototype, 'componentDidMount');
});
afterEach(function() {
jest.useRealTimers();
wrapper && wrapper.unmount();
didMountSpy.restore();
didMountSpy = null;
});
it('应该正确显示基本结构', function() {
wrapper = mount(
<Comp ... /> ); expect(wrapper.find('a').text()).toEqual('HELLO!'); }); ... }); 复制代码
对于一些组件中,若是但愿在测试阶段调用到其一些内部方法,又不想对原组件改动过大的,能够用instance()
取得组件类实例:
it('应该正确获取组件类实例', function() {
var wrapper = mount(
<MultiSelect
name="HELLOKITTY"
placeholder="select sth..." />
);
var wi = wrapper.instance();
expect( wi.props.name ).toEqual( "HELLOKITTY" );
expect( wi.state.open ).toEqual( false );
});
复制代码
做为UI组件,React组件中一些操做须要延时进行,诸如onscroll
或oninput
这类高频触发动做,须要作函数防抖或节流,好比经常使用的 lodash 的 debounce 等。
所谓的异步操做,在不考虑和 ajax 整合的集成测试的状况下,通常都是指此类操做,只用 setTimeout 是不行的,须要搭配 done
函数使用:
//组件中
const Comp = (props)=>(
<input type="text" id="searchIpt" onChange={ debounce(props.onSearch, 500) } /> ); 复制代码
//单元测试中
it('应该在输入时触发回调', function(done) {
var spy = jest.fn();
var wrapper = mount(
<Comp onChange={ spy } /> ); wrapper.find('#searchIpt').simulate('change'); setTimeout(()=>{ expect( spy ).toHaveBeenCalledTimes( 1 ); done(); }, 550); }); 复制代码
一些模块中可能耦合了对 window.xxx
这类全局对象的引用,而彻底去实例化这个对象可能又牵扯出不少其余的问题,难以进行;此时能够见招拆招,只模拟一个最小化的全局对象,保证测试的进行:
//fakeAppFacade.js
var facade = {
router: {
current: function() {
return {name:null, params:null};
}
}, appData: {
symbol: "¥"
}
};
window._appFacade = facade;
module.exports = facade;
复制代码
//测试套件中
import fakeFak from '../fakeAppFacade';
复制代码
另外好比 LocalStroage 这类对象,测试端环境中没有原生支持,也能够简单模拟一下:
//fakeStorage.js
var _util = {};
var fakeStorage = {
"set": function(k, v) {
_util['_fakeSave_'+k] = v;
},
"get": function(k) {
return _util['_fakeSave_'+k] || null;
},
"remove": function(k) {
delete _util['_fakeSave_'+k];
},
"has": function(k) {
return _util.hasOwnProperty('_fakeSave_'+k);
}
};
module.exports = fakeStorage;
复制代码
在一个项目中用到了 react-bootstrap
界面库,测试一个组件时,因为包含了其 Modal
模态弹窗,而弹窗组件是默认渲染到 document
中的,致使难以用普通的 find
方法等获取
解决的办法是模拟一个渲染到容器组件原处的普通组件:
//FakeReactBootstrapModal.js
import React, {Component} from 'react';
class FakeReactBootstrapModal extends Component {
constructor(props) {
super(props);
}
render() { //原生的 react-bootstrap/Modal 没法被 enzyme 测试
const {
show,
bgSize,
dialogClassName,
children
} = this.props;
return show
? <div className={
`fakeModal ${bgSize} ${dialogClassName}`
}>{children}</div>
: null;
}
}
export default FakeReactBootstrapModal;
复制代码
同时在组件渲染时,加入判断逻辑,使之能够支持自定义的类代替 Modal 类:
//ModalComp.js
import { Modal } from 'react-bootstrap';
...
render() {
const MyModal = this._modalClass || Modal;
return (<MyModal bsSize={props.mode>1 ? "large" : "middle"} dialogClassName="custom-modal"> ... </MyModal>;
}
复制代码
而测试套件中,实现一个测试专用的子类:
//myModal.spec.js
import ModalComp from 'components/ModalComp';
class TestModalComp extends ModalComp {
constructor(props) {
super(props);
this._modalClass = FakeReactBootstrapModal;
}
}
复制代码
这样测试便可顺利进行,跳过了并不重要的 UI 效果,而各类逻辑都能被覆盖了
在单元测试的过程当中,不免碰到一些须要远程请求数据的状况,好比组件获取初始化数据、提交变化数据等。
要注意这种测试的目的仍是考察组件自己的表现,而非重点关心实际远程数据的集成测试,因此咱们无需真实的请求,能够简单的模拟一些请求的场景。
sinon 中有一些模拟 XMLHttpRequest 请求的方法, jest 也有一些第三方的库解决 fetch 的测试;
在咱们的项目中,根据实际的用法,本身实现一个类来模拟请求的响应:
//FakeFetch.js
import { noop } from 'lodash';
const fakeFetch = (jsonResult, isSuccess=true, callback=noop)=>{
const blob = new Blob(
[JSON.stringify(jsonResult)],
{type : 'application/json'}
);
return (...args)=>{
console.log('FAKE FETCH', args);
callback.call(null, args);
return isSuccess
? Promise.resolve(
new Response(
blob,
{status:200, statusText:"OK"}
)
)
: Promise.reject(
new Response(
blob,
{status:400, statusText:"Bad Request"}
)
)
}
};
export default fakeFetch;
复制代码
//Comp.spec.js
import fakeFetch from '../FakeFetch';
const _fc = window.fetch; //缓存“真实的”fetch
describe('test components/Comp', function() {
let wrapper;
afterEach(function() {
wrapper && wrapper.unmount();
window.fetch = _fc; //恢复
});
it("应该在远程请求时响应onRemoteData", (done)=>{
window.fetch = fakeFetch({
brand: "GoBelieve",
tree: {
node: '总部',
children: null
}
});
let spy = jest.fn();
wrapper = mount(
<Comp onRemoteData={ spy } />
);
jest.useRealTimers();
_clickTrigger(); //此时应该发起请求
setTimeout(()=>{
expect(wrapper.html()).toMatch(/总部/);
expect(spy).toHaveBeenCalledTimes(1);
done();
}, 500);
});
});
复制代码
单元测试做为一种经典的开发和重构手段,在软件开发领域被普遍承认和采用;前端领域也逐渐积累起了丰富的测试框架和方法。
单元测试能够为咱们的开发和维护提供基础保障,使咱们在思路清晰、心中有底的状况下完成对代码的搭建和重构;
须要注意的是,世上没有包治百病的良药,单元测试也毫不是万金油,秉持谨慎认真负责的态度才能从根本上保证咱们工做的进行。
一个重构的实例 mp.weixin.qq.com/s?__biz=MzI…
长按二维码或搜索 fewelife 关注咱们哦