聊一聊前端自动化测试

本文转载自 天猫前端博客,更多精彩文章请进入天猫前端博客查看css

前言

为什么要测试

之前不喜欢写测试,主要是以为编写和维护测试用例很是的浪费时间。在真正写了一段时间的基础组件和基础工具后,才发现自动化测试有不少好处。测试最重要的天然是提高代码质量。代码有测试用例,虽不能说百分百无bug,但至少说明测试用例覆盖到的场景是没有问题的。有测试用例,发布前跑一下,能够杜绝各类疏忽而引发的功能bug。html

自动化测试另一个重要特色就是快速反馈,反馈越迅速意味着开发效率越高。拿UI组件为例,开发过程都是打开浏览器刷新页面点点点才能肯定UI组件工做状况是否符合本身预期。接入自动化测试之后,经过脚本代替这些手动点击,接入代码watch后每次保存文件都能快速得知本身的的改动是否影响功能,节省了不少时间,毕竟机器干事情比人老是要快得多。前端

有了自动化测试,开发者会更加信任本身的代码。开发者不再会害怕将代码交给别人维护,不用担忧别的开发者在代码里搞“破坏”。后人接手一段有测试用例的代码,修改起来也会更加从容。测试用例里很是清楚的阐释了开发者和使用者对于这端代码的指望和要求,也很是有利于代码的传承。node

考虑投入产出比来作测试

说了这么多测试的好处,并不表明一上来就要写出100%场景覆盖的测试用例。我的一直坚持一个观点:基于投入产出比来作测试。因为维护测试用例也是一大笔开销(毕竟没有多少测试会专门帮前端写业务测试用例,而前端使用的流程自动化工具更是没有测试参与了)。对于像基础组件、基础模型之类的不常变动且复用较多的部分,能够考虑去写测试用例来保证质量。我的比较倾向于先写少许的测试用例覆盖到80%+的场景,保证覆盖主要使用流程。一些极端场景出现的bug能够在迭代中造成测试用例沉淀,场景覆盖也将逐渐趋近100%。但对于迭代较快的业务逻辑以及生存时间不长的活动页面之类的就别花时间写测试用例了,维护测试用例的时间大了去了,成本过高。react

Node.js模块的测试

对于Node.js的模块,测试算是比较方便的,毕竟源码和依赖都在本地,看得见摸得着。webpack

测试工具

测试主要使用到的工具是测试框架、断言库以及代码覆盖率工具:git

  1. 测试框架:MochaJasmine等等,测试主要提供了清晰简明的语法来描述测试用例,以及对测试用例分组,测试框架会抓取到代码抛出的AssertionError,并增长一大堆附加信息,好比那个用例挂了,为何挂等等。测试框架一般提供TDD(测试驱动开发)或BDD(行为驱动开发)的测试语法来编写测试用例,关于TDD和BDD的对比能够看一篇比较知名的文章The Difference Between TDD and BDD。不一样的测试框架支持不一样的测试语法,好比Mocha既支持TDD也支持BDD,而Jasmine只支持BDD。这里后续以Mocha的BDD语法为例github

  2. 断言库:Should.jschaiexpect.js等等,断言库提供了不少语义化的方法来对值作各类各样的判断。固然也能够不用断言库,Node.js中也能够直接使用原生assert库。这里后续以Should.js为例web

  3. 代码覆盖率:istanbul等等为代码在语法级分支上打点,运行了打点后的代码,根据运行结束后收集到的信息和打点时的信息来统计出当前测试用例的对源码的覆盖状况。chrome

一个煎蛋的栗子

以以下的Node.js项目结构为例

.
├── LICENSE
├── README.md
├── index.js
├── node_modules
├── package.json
└── test
    └── test.js

首先天然是安装工具,这里先装测试框架和断言库:npm install --save-dev mocha should。装完后就能够开始测试之旅了。

好比当前有一段js代码,放在index.js

'use strict';
module.exports = () => 'Hello Tmall';

那么对于这么一个函数,首先须要定一个测试用例,这里很明显,运行函数,获得字符串Hello Tmall就算测试经过。那么就能够按照Mocha的写法来写一个测试用例,所以新建一个测试代码在test/index.js

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('should get "Hello Tmall"', () => {
    mylib().should.be.eql('Hello Tmall');
  });
});

测试用例写完了,那么怎么知道测试结果呢?

因为咱们以前已经安装了Mocha,能够在node_modules里面找到它,Mocha提供了命令行工具_mocha,能够直接在./node_modules/.bin/_mocha找到它,运行它就能够执行测试了:

Hello Tmall

这样就能够看到测试结果了。一样咱们能够故意让测试不经过,修改test.js代码为:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('should get "Hello Taobao"', () => {
    mylib().should.be.eql('Hello Taobao');
  });
});

就能够看到下图了:

Taobao is different with Tmall

Mocha实际上支持不少参数来提供不少灵活的控制,好比使用./node_modules/.bin/_mocha --require should,Mocha在启动测试时就会本身去加载Should.js,这样test/test.js里就不须要手动require('should');了。更多参数配置能够查阅Mocha官方文档

那么这些测试代码分别是啥意思呢?

这里首先引入了断言库Should.js,而后引入了本身的代码,这里it()函数定义了一个测试用例,经过Should.js提供的api,能够很是语义化的描述测试用例。那么describe又是干什么的呢?

describe干的事情就是给测试用例分组。为了尽量多的覆盖各类状况,测试用例每每会有不少。这时候经过分组就能够比较方便的管理(这里提一句,describe是能够嵌套的,也就是说外层分组了以后,内部还能够分子组)。另外还有一个很是重要的特性,就是每一个分组均可以进行预处理(beforebeforeEach)和后处理(after, afterEach)。

若是把index.js源码改成:

'use strict';
module.exports = bu => `Hello ${bu}`;

为了测试不一样的bu,测试用例也对应的改成:

'use strict';
require('should');
const mylib = require('../index');
let bu = 'none';

describe('My First Test', () => {
  describe('Welcome to Tmall', () => {
    before(() => bu = 'Tmall');
    after(() => bu = 'none');
    it('should get "Hello Tmall"', () => {
      mylib(bu).should.be.eql('Hello Tmall');
    });
  });
  describe('Welcome to Taobao', () => {
    before(() => bu = 'Taobao');
    after(() => bu = 'none');
    it('should get "Hello Taobao"', () => {
      mylib(bu).should.be.eql('Hello Taobao');
    });
  });
});

一样运行一下./node_modules/.bin/_mocha就能够看到以下图:

all bu welcomes you

这里before会在每一个分组的全部测试用例运行前,相对的after则会在全部测试用例运行后执行,若是要以测试用例为粒度,可使用beforeEachafterEach,这两个钩子则会分别在该分组每一个测试用例运行前和运行后执行。因为不少代码都须要模拟环境,能够再这些beforebeforeEach作这些准备工做,而后在afterafterEach里作回收操做。

异步代码的测试

回调

这里很显然代码都是同步的,但不少状况下咱们的代码都是异步执行的,那么异步的代码要怎么测试呢?

好比这里index.js的代码变成了一段异步代码:

'use strict';
module.exports = (bu, callback) => process.nextTick(() => callback(`Hello ${bu}`));

因为源代码变成异步,因此测试用例就得作改造:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('Welcome to Tmall', done => {
    mylib('Tmall', rst => {
      rst.should.be.eql('Hello Tmall');
      done();
    });
  });
});

这里传入it的第二个参数的函数新增了一个done参数,当有这个参数时,这个测试用例会被认为是异步测试,只有在done()执行时,才认为测试结束。那若是done()一直没有执行呢?Mocha会触发本身的超时机制,超过必定时间(默认是2s,时长能够经过--timeout参数设置)就会自动终止测试,并以测试失败处理。

固然,beforebeforeEachafterafterEach这些钩子,一样支持异步,使用方式和it同样,在传入的函数第一个参数加上done,而后在执行完成后执行便可。

Promise

日常咱们直接写回调会感受本身很low,也容易出现回调金字塔,咱们可使用Promise来作异步控制,那么对于Promise控制下的异步代码,咱们要怎么测试呢?

首先把源码作点改造,返回一个Promise对象:

'use strict';
module.exports = bu => new Promise(resolve => resolve(`Hello ${bu}`));

固然,若是是co党也能够直接使用co包裹:

'use strict';
const co = require('co');
module.exports = co.wrap(function* (bu) {
  return `Hello ${bu}`;
});

对应的修改测试用例以下:

'use strict';
require('should');
const mylib = require('../index');

describe('My First Test', () => {
  it('Welcome to Tmall', () => {
    return mylib('Tmall').should.be.fulfilledWith('Hello Tmall');
  });
});

Should.js在8.x.x版本自带了Promise支持,能够直接使用fullfilled()rejected()fullfilledWith()rejectedWith()等等一系列API测试Promise对象。

注意:使用should测试Promise对象时,请必定要return,必定要return,必定要return,不然断言将无效

异步运行测试

有时候,咱们可能并不仅是某个测试用例须要异步,而是整个测试过程都须要异步执行。好比测试Gulp插件的一个方案就是,首先运行Gulp任务,完成后测试生成的文件是否和预期的一致。那么如何异步执行整个测试过程呢?

其实Mocha提供了异步启动测试,只须要在启动Mocha的命令后加上--delay参数,Mocha就会以异步方式启动。这种状况下咱们须要告诉Mocha何时开始跑测试用例,只须要执行run()方法便可。把刚才的test/test.js修改为下面这样:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Hello Tmall');
    });
  });
  run();
}, 1000);

直接执行./node_modules/.bin/_mocha就会发生下面这样的杯具:

no cases

那么加上--delay试试:

oh my green

熟悉的绿色又回来了!

代码覆盖率

单元测试玩得差很少了,能够开始试试代码覆盖率了。首先须要安装代码覆盖率工具istanbul:npm install --save-dev istanbul,istanbul一样有命令行工具,在./node_modules/.bin/istanbul能够寻觅到它的身影。Node.js端作代码覆盖率测试很简单,只须要用istanbul启动Mocha便可,好比上面那个测试用例,运行./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,能够看到下图:

my first coverage

这就是代码覆盖率结果了,由于index.js中的代码比较简单,因此直接就100%了,那么修改一下源码,加个if吧:

'use strict';
module.exports = bu => new Promise(resolve => {
  if (bu === 'Tmall') return resolve(`Welcome to Tmall`);
  resolve(`Hello ${bu}`);
});

测试用例也跟着变一下:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Welcome to Tmall');
    });
  });
  run();
}, 1000);

换了姿式,咱们再来一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,能够获得下图:

coverage again

当使用istanbul运行Mocha时,istanbul命令本身的参数放在--以前,须要传递给Mocha的参数放在--以后

如预期所想,覆盖率再也不是100%了,这时候我想看看哪些代码被运行了,哪些没有,怎么办呢?

运行完成后,项目下会多出一个coverage文件夹,这里就是放代码覆盖率结果的地方,它的结构大体以下:

.
├── coverage.json
├── lcov-report
│   ├── base.css
│   ├── index.html
│   ├── prettify.css
│   ├── prettify.js
│   ├── sort-arrow-sprite.png
│   ├── sorter.js
│   └── test
│       ├── index.html
│       └── index.js.html
└── lcov.info
  • coverage.json和lcov.info:测试结果描述的json文件,这个文件能够被一些工具读取,生成可视化的代码覆盖率结果,这个文件后面接入持续集成时还会提到。

  • lcov-report:经过上面两个文件由工具处理后生成的覆盖率结果页面,打开能够很是直观的看到代码的覆盖率

这里open coverage/lcov-report/index.html能够看到文件目录,点击对应的文件进入到文件详情,能够看到index.js的覆盖率如图所示:

coverage report

这里有四个指标,经过这些指标,能够量化代码覆盖状况:

  • statements:可执行语句执行状况

  • branches:分支执行状况,好比if就会产生两个分支,咱们只运行了其中的一个

  • Functions:函数执行状况

  • Lines:行执行状况

下面代码部分,没有被执行过得代码会被标红,这些标红的代码每每是bug滋生的土壤,咱们要尽量消除这些红色。为此咱们添加一个测试用例:

'use strict';
require('should');
const mylib = require('../index');

setTimeout(() => {
  describe('My First Test', () => {
    it('Welcome to Tmall', () => {
      return mylib('Tmall').should.be.fulfilledWith('Welcome to Tmall');
    });
    it('Hello Taobao', () => {
      return mylib('Taobao').should.be.fulfilledWith('Hello Taobao');
    });
  });
  run();
}, 1000);

再来一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,从新打开覆盖率页面,能够看到红色已经消失了,覆盖率100%。目标完成,能够睡个安稳觉了

集成到package.json

好了,一个简单的Node.js测试算是作完了,这些测试任务均可以集中写到package.jsonscripts字段中,好比:

{
  "scripts": {
    "test": "NODE_ENV=test ./node_modules/.bin/_mocha --require should",
    "cov": "NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay"
  },
}

这样直接运行npm run test就能够跑单元测试,运行npm run cov就能够跑代码覆盖率测试了,方便快捷

对多个文件分别作测试

一般咱们的项目都会有不少文件,比较推荐的方法是对每一个文件单独去作测试。好比代码在./lib/下,那么./lib/文件夹下的每一个文件都应该对应一个./test/文件夹下的文件名_spec.js的测试文件

为何要这样呢?不能直接运行index.js入口文件作测试吗?

直接从入口文件来测实际上是黑盒测试,咱们并不知道代码内部运行状况,只是看某个特定的输入可否获得指望的输出。这一般能够覆盖到一些主要场景,可是在代码内部的一些边缘场景,就很难直接经过从入口输入特定的数据来解决了。好比代码里须要发送一个请求,入口只是传入一个url,url自己正确与否只是一个方面,当时的网络情况和服务器情况是没法预知的。传入相同的url,可能因为服务器挂了,也可能由于网络抖动,致使请求失败而抛出错误,若是这个错误没有获得处理,极可能致使故障。所以咱们须要把黑盒打开,对其中的每一个小块作白盒测试。

固然,并非全部的模块测起来都这么轻松,前端用Node.js常干的事情就是写构建插件和自动化工具,典型的就是Gulp插件和命令行工具,那么这俩种特定的场景要怎么测试呢?

Gulp插件的测试

如今前端构建使用最多的就是Gulp了,它简明的API、流式构建理念、以及在内存中操做的性能,让它备受追捧。虽然如今有像webpack这样的后起之秀,但Gulp依旧凭借着其繁荣的生态圈担当着前端构建的绝对主力。目前天猫前端就是使用Gulp做为代码构建工具。

用了Gulp做为构建工具,也就免不了要开发Gulp插件来知足业务定制化的构建需求,构建过程本质上实际上是对源代码进行修改,若是修改过程当中出现bug极可能直接致使线上故障。所以针对Gulp插件,尤为是会修改源代码的Gulp插件必定要作仔细的测试来保证质量。

又一个煎蛋的栗子

好比这里有个煎蛋的Gulp插件,功能就是往全部js代码前加一句注释// 天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com,Gulp插件的代码大概就是这样:

'use strict';

const _ = require('lodash');
const through = require('through2');
const PluginError = require('gulp-util').PluginError;
const DEFAULT_CONFIG = {};

module.exports = config => {
  config = _.defaults(config || {}, DEFAULT_CONFIG);
  return through.obj((file, encoding, callback) => {
    if (file.isStream()) return callback(new PluginError('gulp-welcome-to-tmall', `Stream is not supported`));
    file.contents = new Buffer(`// 天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com\n${file.contents.toString()}`);
    callback(null, file);
  });
};

对于这么一段代码,怎么作测试呢?

一种方式就是直接伪造一个文件传入,Gulp内部其实是经过vinyl-fs从操做系统读取文件并作成虚拟文件对象,而后将这个虚拟文件对象交由through2创造的Transform来改写流中的内容,而外层任务之间经过orchestrator控制,保证执行顺序(若是不了解能够看看这篇翻译文章Gulp思惟——Gulp高级技巧)。固然一个插件不须要关心Gulp的任务管理机制,只须要关心传入一个vinyl对象可否正确处理。所以只须要伪造一个虚拟文件对象传给咱们的Gulp插件就能够了。

首先设计测试用例,考虑两个主要场景:

  1. 虚拟文件对象是流格式的,应该抛出错误

  2. 虚拟文件对象是Buffer格式的,可以正常对文件内容进行加工,加工完的文件加上// 天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com的头

对于第一个测试用例,咱们须要建立一个流格式的vinyl对象。而对于各第二个测试用例,咱们须要建立一个Buffer格式的vinyl对象。

固然,首先咱们须要一个被加工的源文件,放到test/src/testfile.js下吧:

'use strict';
console.log('hello world');

这个源文件很是简单,接下来的任务就是把它分别封装成流格式的vinyl对象和Buffer格式的vinyl对象。

构建Buffer格式的虚拟文件对象

构建一个Buffer格式的虚拟文件对象能够用vinyl-fs读取操做系统里的文件生成vinyl对象,Gulp内部也是使用它,默认使用Buffer:

'use strict';
require('should');
const path = require('path');
const vfs = require('vinyl-fs');
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    vfs.src(path.join(__dirname, 'src', 'testfile.js'))
      .pipe(welcome())
      .on('data', function(vf) {
        vf.contents.toString().should.be.eql(`// 天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com\n'use strict';\nconsole.log('hello world');\n`);
        done();
      });
  });
});

这样测了Buffer格式后算是完成了主要功能的测试,那么要如何测试流格式呢?

构建流格式的虚拟文件对象

方案一和上面同样直接使用vinyl-fs,增长一个参数buffer: false便可:

把代码修改为这样:

'use strict';
require('should');
const path = require('path');
const vfs = require('vinyl-fs');
const PluginError = require('gulp-util').PluginError;
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    // blabla
  });
  it('should throw PluginError when stream', done => {
    vfs.src(path.join(__dirname, 'src', 'testfile.js'), {
      buffer: false
    })
      .pipe(welcome())
      .on('error', e => {
        e.should.be.instanceOf(PluginError);
        done();
      });
  });
});

这样vinyl-fs直接从文件系统读取文件并生成流格式的vinyl对象。

若是内容并不来自于文件系统,而是来源于一个已经存在的可读流,要怎么把它封装成一个流格式的vinyl对象呢?

这样的需求能够借助vinyl-source-stream

'use strict';
require('should');
const fs = require('fs');
const path = require('path');
const source = require('vinyl-source-stream');
const vfs = require('vinyl-fs');
const PluginError = require('gulp-util').PluginError;
const welcome = require('../index');

describe('welcome to Tmall', function() {
  it('should work when buffer', done => {
    // blabla
  });
  it('should throw PluginError when stream', done => {
    fs.createReadStream(path.join(__dirname, 'src', 'testfile.js'))
      .pipe(source())
      .pipe(welcome())
      .on('error', e => {
        e.should.be.instanceOf(PluginError);
        done();
      });
  });
});

这里首先经过fs.createReadStream建立了一个可读流,而后经过vinyl-source-stream把这个可读流包装成流格式的vinyl对象,并交给咱们的插件作处理

Gulp插件执行错误时请抛出PluginError,这样可以让gulp-plumber这样的插件进行错误管理,防止错误终止构建进程,这在gulp watch时很是有用

模拟Gulp运行

咱们伪造的对象已经能够跑通功能测试了,可是这数据来源终究是本身伪造的,并非用户平常的使用方式。若是采用最接近用户使用的方式来作测试,测试结果才更加可靠和真实。那么问题来了,怎么模拟真实的Gulp环境来作Gulp插件的测试呢?

首先模拟一下咱们的项目结构:

test
├── build
│   └── testfile.js
├── gulpfile.js
└── src
    └── testfile.js

一个简易的项目结构,源码放在src下,经过gulpfile来指定任务,构建结果放在build下。按照咱们日常使用方式在test目录下搭好架子,而且写好gulpfile.js:

'use strict';
const gulp = require('gulp');
const welcome = require('../index');
const del = require('del');

gulp.task('clean', cb => del('build', cb));

gulp.task('default', ['clean'], () => {
  return gulp.src('src/**/*')
    .pipe(welcome())
    .pipe(gulp.dest('build'));
});

接着在测试代码里来模拟Gulp运行了,这里有两种方案:

  1. 使用child_process库提供的spawnexec开子进程直接跑gulp命令,而后测试build目录下是不是想要的结果

  2. 直接在当前进程获取gulpfile中的Gulp实例来运行Gulp任务,而后测试build目录下是不是想要的结果

开子进程进行测试有一些坑,istanbul测试代码覆盖率时时没法跨进程的,所以开子进程测试,首先须要子进程执行命令时加上istanbul,而后还须要手动去收集覆盖率数据,当开启多个子进程时还须要本身作覆盖率结果数据合并,至关麻烦。

那么不开子进程怎么作呢?能够借助run-gulp-task这个工具来运行,其内部的机制就是首先获取gulpfile文件内容,在文件尾部加上module.exports = gulp;后require gulpfile从而获取Gulp实例,而后将Gulp实例递交给run-sequence调用内部未开放的APIgulp.run来运行。

咱们采用不开子进程的方式,把运行Gulp的过程放在before钩子中,测试代码变成下面这样:

'use strict';
require('should');
const path = require('path');
const run = require('run-gulp-task');
const CWD = process.cwd();
const fs = require('fs');

describe('welcome to Tmall', () => {
  before(done => {
    process.chdir(__dirname);
    run('default', path.join(__dirname, 'gulpfile.js'))
      .catch(e => e)
      .then(e => {
        process.chdir(CWD);
        done(e);
      });
  });
  it('should work', function() {
    fs.readFileSync(path.join(__dirname, 'build', 'testfile.js')).toString().should.be.eql(`// 天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com\n'use strict';\nconsole.log('hello world');\n`);
  });
});

这样因为不须要开子进程,代码覆盖率测试也能够和普通Node.js模块同样了

测试命令行输出

双一个煎蛋的栗子

固然前端写工具并不仅限于Gulp插件,偶尔还会写一些辅助命令啥的,这些辅助命令直接在终端上运行,结果也会直接展现在终端上。好比一个简单的使用commander实现的命令行工具:

// in index.js
'use strict';
const program = require('commander');
const path = require('path');
const pkg = require(path.join(__dirname, 'package.json'));

program.version(pkg.version)
  .usage('[options] <file>')
  .option('-t, --test', 'Run test')
  .action((file, prog) => {
    if (prog.test) console.log('test');
  });

module.exports = program;

// in bin/cli
#!/usr/bin/env node
'use strict';
const program = require('../index.js');

program.parse(process.argv);

!program.args[0] && program.help();

// in package.json
{
  "bin": {
    "cli-test": "./bin/cli"
  }
}

拦截输出

要测试命令行工具,天然要模拟用户输入命令,这一次依旧选择不开子进程,直接用伪造一个process.argv交给program.parse便可。命令输入了问题也来了,数据是直接console.log的,要怎么拦截呢?

这能够借助sinon来拦截console.log,并且sinon很是贴心的提供了mocha-sinon方便测试用,这样test.js大体就是这个样子:

'use strict';
require('should');
require('mocha-sinon');
const program = require('../index');
const uncolor = require('uncolor');

describe('cli-test', () => {
  let rst;
  beforeEach(function() {
    this.sinon.stub(console, 'log', function() {
      rst = arguments[0];
    });
  });
  it('should print "test"', () => {
    program.parse([
      'node',
      './bin/cli',
      '-t',
      'file.js'
    ]);
    return uncolor(rst).trim().should.be.eql('test');
  });
});

PS:因为命令行输出时常常会使用colors这样的库来添加颜色,所以在测试时记得用uncolor把这些颜色移除

小结

Node.js相关的单元测试就扯这么多了,还有不少场景像服务器测试什么的就不扯了,由于我不会。固然前端最主要的工做仍是写页面,接下来扯一扯如何对页面上的组件作测试。

页面测试

对于浏览器里跑的前端代码,作测试要比Node.js模块要麻烦得多。Node.js模块纯js代码,使用V8运行在本地,测试用的各类各样的依赖和工具都能快速的安装,而前端代码不只仅要测试js,CSS等等,更麻烦的事须要模拟各类各样的浏览器,比较常见的前端代码测试方案有下面几种:

  1. 构建一个测试页面,人肉直接到虚拟机上开各类浏览器跑测试页面(好比公司的f2etest)。这个方案的缺点就是很差作代码覆盖率测试,也很差持续化集成,同时人肉工做较多

  2. 使用PhantomJS构建一个伪造的浏览器环境跑单元测试,好处是解决了代码覆盖率问题,也能够作持续集成。这个方案的缺点是PhantomJS毕竟是Qt的webkit,并非真实浏览器环境,PhantomJS也有各类各样兼容性坑

  3. 经过Karma调用本机各类浏览器进行测试,好处是能够跨浏览器作测试,也能够测试覆盖率,但持续集成时须要注意只能开PhantomJS作测试,毕竟集成的Linux环境不可能有浏览器。这能够说是目前看到的最好的前端代码测试方式了

这里以gulp为构建工具作测试,后面在React组件测试部分再介绍以webpack为构建工具作测试

叒一个煎蛋的栗子

前端代码依旧是js,同样能够用Mocha+Should.js来作单元测试。打开node_modules下的Mocha和Should.js,你会发现这些优秀的开源工具已经很是贴心的提供了可在浏览器中直接运行的版本:mocha/mocha.jsshould/should.min.js,只须要把他们经过script标签引入便可,另外Mocha还须要引入本身的样式mocha/mocha.css

首先看一下咱们的前端项目结构:

.
├── gulpfile.js
├── package.json
├── src
│   └── index.js
└── test
    ├── test.html
    └── test.js

好比这里源码src/index.js就是定义一个全局函数:

window.render = function() {
  var ctn = document.createElement('div');
  ctn.setAttribute('id', 'tmall');
  ctn.appendChild(document.createTextNode('天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com'));
  document.body.appendChild(ctn);
}

而测试页面test/test.html大体上是这个样子:

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="../node_modules/mocha/mocha.css"/>
  <script src="../node_modules/mocha/mocha.js"></script>
  <script src="../node_modules/should/should.js"></script>
</head>

<body>
  <div id="mocha"></div>
  <script src="../src/index.js"></script>
  <script src="test.js"></script>
</body>

</html>

head里引入了测试框架Mocha和断言库Should.js,测试的结果会被显示在<div id="mocha"></div>这个容器里,而test/test.js里则是咱们的测试的代码。

前端页面上测试和Node.js上测试没啥太大不一样,只是须要指定Mocha使用的UI,并须要手动调用mocha.run()

mocha.ui('bdd');
describe('Welcome to Tmall', function() {
  before(function() {
    window.render();
  });
  it('Hello', function() {
    document.getElementById('tmall').textContent.should.be.eql('天猫前端招人,有意向的请发送简历至lingyucoder@gmail.com');
  });
});
mocha.run();

在浏览器里打开test/test.html页面,就能够看到效果了:

test page

在不一样的浏览器里打开这个页面,就能够看到当前浏览器的测试了。这种方式能兼容最多的浏览器,固然要跨机器以前记得把资源上传到一个测试机器都能访问到的地方,好比CDN。

测试页面有了,那么来试试接入PhantomJS吧

使用PhantomJS进行测试

PhantomJS是一个模拟的浏览器,它能执行js,甚至还有webkit渲染引擎,只是没有浏览器的界面上渲染结果罢了。咱们可使用它作不少事情,好比对网页进行截图,写爬虫爬取异步渲染的页面,以及接下来要介绍的——对页面作测试。

固然,这里咱们不是直接使用PhantomJS,而是使用mocha-phantomjs来作测试。npm install --save-dev mocha-phantomjs安装完成后,就能够运行命令./node_modules/.bin/mocha-phantomjs ./test/test.html来对上面那个test/test.html的测试了:

PhantomJS test

单元测试没问题了,接下来就是代码覆盖率测试

覆盖率打点

首先第一步,改写咱们的gulpfile.js

'use strict';
const gulp = require('gulp');
const istanbul = require('gulp-istanbul');

gulp.task('test', function() {
  return gulp.src(['src/**/*.js'])
    .pipe(istanbul({
      coverageVariable: '__coverage__'
    }))
    .pipe(gulp.dest('build-test'));
});

这里把覆盖率结果保存到__coverage__里面,把打完点的代码放到build-test目录下,好比刚才的src/index.js的代码,在运行gulp test后,会生成build-test/index.js,内容大体是这个样子:

var __cov_WzFiasMcIh_mBvAjOuQiQg = (Function('return this'))();
if (!__cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__) { __cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__ = {}; }
__cov_WzFiasMcIh_mBvAjOuQiQg = __cov_WzFiasMcIh_mBvAjOuQiQg.__coverage__;
if (!(__cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'])) {
   __cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'] = {"path":"/Users/lingyu/gitlab/dev/mui/test-page/src/index.js","s":{"1":0,"2":0,"3":0,"4":0,"5":0},"b":{},"f":{"1":0},"fnMap":{"1":{"name":"(anonymous_1)","line":1,"loc":{"start":{"line":1,"column":16},"end":{"line":1,"column":27}}}},"statementMap":{"1":{"start":{"line":1,"column":0},"end":{"line":6,"column":1}},"2":{"start":{"line":2,"column":2},"end":{"line":2,"column":42}},"3":{"start":{"line":3,"column":2},"end":{"line":3,"column":34}},"4":{"start":{"line":4,"column":2},"end":{"line":4,"column":85}},"5":{"start":{"line":5,"column":2},"end":{"line":5,"column":33}}},"branchMap":{}};
}
__cov_WzFiasMcIh_mBvAjOuQiQg = __cov_WzFiasMcIh_mBvAjOuQiQg['/Users/lingyu/gitlab/dev/mui/test-page/src/index.js'];
__cov_WzFiasMcIh_mBvAjOuQiQg.s['1']++;window.render=function(){__cov_WzFiasMcIh_mBvAjOuQiQg.f['1']++;__cov_WzFiasMcIh_mBvAjOuQiQg.s['2']++;var ctn=document.createElement('div');__cov_WzFiasMcIh_mBvAjOuQiQg.s['3']++;ctn.setAttribute('id','tmall');__cov_WzFiasMcIh_mBvAjOuQiQg.s['4']++;ctn.appendChild(document.createTextNode('天猫前端招人\uFF0C有意向的请发送简历至lingyucoder@gmail.com'));__cov_WzFiasMcIh_mBvAjOuQiQg.s['5']++;document.body.appendChild(ctn);};

这都什么鬼!无论了,反正运行它就好。把test/test.html里面引入的代码从src/index.js修改成build-test/index.js,保证页面运行时使用的是编译后的代码。

编写钩子

运行数据会存放到变量__coverage__里,可是咱们还须要一段钩子代码在单元测试结束后获取这个变量里的内容。把钩子代码放在test/hook.js下,里面内容这样写:

'use strict';

var fs = require('fs');

module.exports = {
  afterEnd: function(runner) {
    var coverage = runner.page.evaluate(function() {
      return window.__coverage__;
    });
    if (coverage) {
      console.log('Writing coverage to coverage/coverage.json');
      fs.write('coverage/coverage.json', JSON.stringify(coverage), 'w');
    } else {
      console.log('No coverage data generated');
    }
  }
};

这样准备工做工做就大功告成了,执行命令./node_modules/.bin/mocha-phantomjs ./test/test.html --hooks ./test/hook.js,能够看到以下图结果,同时覆盖率结果被写入到coverage/coverage.json里面了。

coverage hook

生成页面

有告终果覆盖率结果就能够生成覆盖率页面了,首先看看覆盖率概况吧。执行命令./node_modules/.bin/istanbul report --root coverage text-summary,能够看到下图:

coverage summary

仍是原来的配方,仍是想熟悉的味道。接下来运行./node_modules/.bin/istanbul report --root coverage lcov生成覆盖率页面,执行完后open coverage/lcov-report/index.html,点击进入到src/index.js

coverage page

一颗赛艇!这样咱们对前端代码就能作覆盖率测试了

接入Karma

Karma是一个测试集成框架,能够方便地以插件的形式集成测试框架、测试环境、覆盖率工具等等。Karma已经有了一套至关完善的插件体系,这里尝试在PhantomJS、Chrome、FireFox下作测试,首先须要使用npm安装一些依赖:

  1. karma:框架本体

  2. karma-mocha:Mocha测试框架

  3. karma-coverage:覆盖率测试

  4. karma-spec-reporter:测试结果输出

  5. karma-phantomjs-launcher:PhantomJS环境

  6. phantomjs-prebuilt: PhantomJS最新版本

  7. karma-chrome-launcher:Chrome环境

  8. karma-firefox-launcher:Firefox环境

安装完成后,就能够开启咱们的Karma之旅了。仍是以前的那个项目,咱们把该清除的清除,只留下源文件和而是文件,并增长一个karma.conf.js文件:

.
├── karma.conf.js
├── package.json
├── src
│   └── index.js
└── test
    └── test.js

karma.conf.js是Karma框架的配置文件,在这个例子里,它大概是这个样子:

'use strict';

module.exports = function(config) {
  config.set({
    frameworks: ['mocha'],
    files: [
      './node_modules/should/should.js',
      'src/**/*.js',
      'test/**/*.js'
    ],
    preprocessors: {
      'src/**/*.js': ['coverage']
    },
    plugins: ['karma-mocha', 'karma-phantomjs-launcher', 'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-coverage', 'karma-spec-reporter'],
    browsers: ['PhantomJS', 'Firefox', 'Chrome'],
    reporters: ['spec', 'coverage'],
    coverageReporter: {
      dir: 'coverage',
      reporters: [{
        type: 'json',
        subdir: '.',
        file: 'coverage.json',
      }, {
        type: 'lcov',
        subdir: '.'
      }, {
        type: 'text-summary'
      }]
    }
  });
};

这些配置都是什么意思呢?这里挨个说明一下:

  • frameworks: 使用的测试框架,这里依旧是咱们熟悉又亲切的Mocha

  • files:测试页面须要加载的资源,上面的test目录下已经没有test.html了,全部须要加载内容都在这里指定,若是是CDN上的资源,直接写URL也能够,不过建议尽量使用本地资源,这样测试更快并且即便没网也能够测试。这个例子里,第一行载入的是断言库Should.js,第二行是src下的全部代码,第三行载入测试代码

  • preprocessors:配置预处理器,在上面files载入对应的文件前,若是在这里配置了预处理器,会先对文件作处理,而后载入处理结果。这个例子里,须要对src目录下的全部资源添加覆盖率打点(这一步以前是经过gulp-istanbul来作,如今karma-coverage框架能够很方便的处理,也不须要钩子啥的了)。后面作React组件测试时也会在这里使用webpack

  • plugins:安装的插件列表

  • browsers:须要测试的浏览器,这里咱们选择了PhantomJS、FireFox、Chrome

  • reporters:须要生成哪些代码报告

  • coverageReporter:覆盖率报告要如何生成,这里咱们指望生成和以前同样的报告,包括覆盖率页面、lcov.info、coverage.json、以及命令行里的提示

好了,配置完成,来试试吧,运行./node_modules/karma/bin/karma start --single-run,能够看到以下输出:

run karma

能够看到,Karma首先会在9876端口开启一个本地服务,而后分别启动PhantomJS、FireFox、Chrome去加载这个页面,收集到测试结果信息以后分别输出,这样跨浏览器测试就解决啦。若是要新增浏览器就安装对应的浏览器插件,而后在browsers里指定一下便可,很是灵活方便。

那若是个人mac电脑上没有IE,又想测IE,怎么办呢?能够直接运行./node_modules/karma/bin/karma start启动本地服务器,而后使用其余机器开对应浏览器直接访问本机的9876端口(固然这个端口是可配置的)便可,一样移动端的测试也能够采用这个方法。这个方案兼顾了前两个方案的优势,弥补了其不足,是目前看到最优秀的前端代码测试方案了

React组件测试

去年React旋风通常席卷全球,固然天猫也在技术上紧跟时代脚步。天猫商家端业务已经全面切入React,造成了React组件体系,几乎全部新业务都采用React开发,而老业务也在不断向React迁移。React大红大紫,这里单独拉出来说一讲React+webpack的打包方案如何进行测试

这里只聊React Web,不聊React Native

事实上天猫目前并未采用webpack打包,而是Gulp+Babel编译React CommonJS代码成AMD模块使用,这是为了可以在新老业务使用上更加灵活,固然也有部分业务采用webpack打包并上线

叕一个煎蛋的栗子

这里建立一个React组件,目录结构大体这样(这里略过CSS相关部分,只要跑通了,集成CSS像PostCSS、Less都没啥问题):

.
├── demo
├── karma.conf.js
├── package.json
├── src
│   └── index.jsx
├── test
│   └── index_spec.jsx
├── webpack.dev.js
└── webpack.pub.js

React组件源码src/index.jsx大概是这个样子:

import React from 'react';
class Welcome extends React.Component {
  constructor() {
    super();
  }
  render() {
    return <div>{this.props.content}</div>;
  }
}
Welcome.displayName = 'Welcome';
Welcome.propTypes = {
  /**
   * content of element
   */
  content: React.PropTypes.string
};
Welcome.defaultProps = {
  content: 'Hello Tmall'
};
module.exports = Welcome;

那么对应的test/index_spec.jsx则大概是这个样子:

import 'should';
import Welcome from '../src/index.jsx';
import ReactDOM from 'react-dom';
import React from 'react';
import TestUtils from 'react-addons-test-utils';
describe('test', function() {
  const container = document.createElement('div');
  document.body.appendChild(container);
  afterEach(() => {
    ReactDOM.unmountComponentAtNode(container);
  });
  it('Hello Tmall', function() {
    let cp = ReactDOM.render(<Welcome/>, container);
    let welcome = TestUtils.findRenderedComponentWithType(cp, Welcome);
    ReactDOM.findDOMnode(welcome).textContent.should.be.eql('Hello Tmall');
  });
});

因为是测试React,天然要使用React的TestUtils,这个工具库提供了很多方便查找节点和组件的方法,最重要的是它提供了模拟事件的API,这能够说是UI测试最重要的一个功能。更多关于TestUtils的使用请参考React官网,这里就不扯了...

代码有了,测试用例也有了,接下就差跑起来了。karma.conf.js确定就和上面不同了,首先它要多一个插件karma-webpack,由于咱们的React组件是须要webpack打包的,不打包的代码压根就无法运行。另外还须要注意代码覆盖率测试也出现了变化。由于如今多了一层Babel编译,Babel编译ES六、ES7源码生成ES5代码后会产生不少polyfill代码,所以若是对build完成以后的代码作覆盖率测试会包含这些polyfill代码,这样测出来的覆盖率显然是不可靠的,这个问题能够经过isparta-loader来解决。React组件的karma.conf.js大概是这个样子:

'use strict';
const path = require('path');

module.exports = function(config) {
  config.set({
    frameworks: ['mocha'],
    files: [
      './node_modules/phantomjs-polyfill/bind-polyfill.js',
      'test/**/*_spec.jsx'
    ],
    plugins: ['karma-webpack', 'karma-mocha',, 'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-phantomjs-launcher', 'karma-coverage', 'karma-spec-reporter'],
    browsers: ['PhantomJS', 'Firefox', 'Chrome'],
    preprocessors: {
      'test/**/*_spec.jsx': ['webpack']
    },
    reporters: ['spec', 'coverage'],
    coverageReporter: {
      dir: 'coverage',
      reporters: [{
        type: 'json',
        subdir: '.',
        file: 'coverage.json',
      }, {
        type: 'lcov',
        subdir: '.'
      }, {
        type: 'text-summary'
      }]
    },
    webpack: {
      module: {
        loaders: [{
          test: /\.jsx?/,
          loaders: ['babel']
        }],
        preLoaders: [{
          test: /\.jsx?$/,
          include: [path.resolve('src/')],
          loader: 'isparta'
        }]
      }
    },
    webpackMiddleware: {
      noInfo: true
    }
  });
};

这里相对于以前的karma.conf.js,主要有如下几点区别:

  1. 因为webpack的打包功能,咱们在测试代码里直接import组件代码,所以再也不须要在files里手动引入组件代码

  2. 预处理里面须要对每一个测试文件都作webpack打包

  3. 添加webpack编译相关配置,在编译源码时,须要定义preLoaders,并使用isparta-loader作代码覆盖率打点

  4. 添加webpackMiddleware配置,这里noInfo做用是不须要输出webpack编译时那一大串信息

这样配置基本上就完成了,跑一把./node_modules/karma/bin/karma start --single-run

react karma

很好,结果符合预期。open coverage/lcov-report/index.html打开覆盖率页面:

react coverage

鹅妹子音!!!直接对jsx代码作的覆盖率测试!这样React组件的测试大致上就完工了

小结

前端的代码测试主要难度是如何模拟各类各样的浏览器环境,Karma给咱们提供了很好地方式,对于本地有的浏览器能自动打开并测试,本地没有的浏览器则提供直接访问的页面。前端尤为是移动端浏览器种类繁多,很难作到完美,但咱们能够经过这种方式实现主流浏览器的覆盖,保证每次上线大多数用户没有问题。

持续集成

测试结果有了,接下来就是把这些测试结果接入到持续集成之中。持续集成是一种很是优秀的多人开发实践,经过代码push触发钩子,实现自动运行编译、测试等工做。接入持续集成后,咱们的每一次push代码,每一个Merge Request都会生成对应的测试结果,项目的其余成员能够很清楚地了解到新代码是否影响了现有的功能,在接入自动告警后,能够在代码提交阶段就快速发现错误,提高开发迭代效率。

持续集成会在每次集成时提供一个几乎空白的虚拟机器,并拷贝用户提交的代码到机器本地,经过读取用户项目下的持续集成配置,自动化的安装环境和依赖,编译和测试完成后生成报告,在一段时间以后释放虚拟机器资源。

开源的持续集成

开源比较出名的持续集成服务当属Travis,而代码覆盖率则经过Coveralls,只要有GitHub帐户,就能够很轻松的接入Travis和Coveralls,在网站上勾选了须要持续集成的项目之后,每次代码push就会触发自动化测试。这两个网站在跑完测试之后,会自动生成测试结果的小图片

build result

Travis会读取项目下的travis.yml文件,一个简单的例子:

language: node_js
node_js:
  - "stable"
  - "4.0.0"
  - "5.0.0"
script: "npm run test"
after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls"

language定义了运行环境的语言,而对应的node_js能够定义须要在哪几个Node.js版本作测试,好比这里的定义,表明着会分别在最新稳定版、4.0.0、5.0.0版本的Node.js环境下作测试

而script则是测试利用的命令,通常状况下,都应该把本身这个项目开发所须要的命令都写在package.json的scripts里面,好比咱们的测试方法./node_modules/karma/bin/karma start --single-run就应当这样写到scripts里:

{
  "scripts": {
    "test": "./node_modules/karma/bin/karma start --single-run"
  }
}

而after_script则是在测试完成以后运行的命令,这里须要上传覆盖率结果到coveralls,只须要安装coveralls库,而后获取lcov.info上传给Coveralls便可

更多配置请参照Travis官网介绍

这样配置后,每次push的结果均可以上Travis和Coveralls看构建和代码覆盖率结果了

travis

coveralls

小结

项目接入持续集成在多人开发同一个仓库时候能起到很大的用途,每次push都能自动触发测试,测试没过会发生告警。若是需求采用Issues+Merge Request来管理,每一个需求一个Issue+一个分支,开发完成后提交Merge Request,由项目Owner负责合并,项目质量将更有保障

总结

这里只是前端测试相关知识的一小部分,还有很是多的内容能够深刻挖掘,而测试也仅仅是前端流程自动化的一部分。在前端技术快速发展的今天,前端项目再也不像当年的刀耕火种通常,愈来愈多的软件工程经验被集成到前端项目中,前端项目正向工程化、流程化、自动化方向高速奔跑。还有更多优秀的提高开发效率、保证开发质量的自动化方案亟待咱们挖掘。

相关文章
相关标签/搜索