对于web开发者而言,单元测试是项目产品可靠性的重要保障。一个工程项目库的质量是否优秀,通过其在测试方面所做的工作就可以充分衡量。由于前端插件库落地场景繁杂,所面向的既有node环境,也有web环境(甚至将来可能还要考虑electron等),因此需要测试框架具备多情景测试的能力。
然而,多数测试框架并不是为业务场景所设计,这就需要我们通过一个hub来将不同功能定位的测试元件糅合到一起,这个hub便是Angular团队所开源的Karma。
一、什么是好的单测框架
经过几天的爬坑,以及对前端数据缓存库的单测编写实践,笔者认为一套完整的单测框架系统应当具备以下feature:
1. 自动化测试:支持script执行及file watch,可以集成到git hooks或grunt/gulp工作流中
2. web环境:适应基于webAPI研发的插件测试需求,也可提供文档流供dom组件测试,测试出现问题时,允许开发者在真实浏览器环境中调试,支持source-map
3. ES6语法支持:支持源码引入,npm包引入,测试用例编写体验的基本保障,捕捉异步代码异常时尤为明显(有关此方案下对ts的支持可参考本文Ref)
4. 本地代理&Mock:网络传输层相关测试必备
5. 代码覆盖率报告:衡量测试质量的重要标准,同时也是case编写者的重要参考
二、如何搭建自动测试流
需要Cover上述所有需求的单测框架,其执行逻辑应该如下图所示:
2. 浏览器环境的调起和console信息的捕获是整个自动化测试流程中最重要的部分,这也是笔者选择karma框架的原因,虽然依赖注入这种声明式的写法会使得配置文件越来越冗长,但是只要做好指定,这部分工作就可以由karma框架自动完成,工作流不会阻塞,调整起来也十分灵活。
理清整个流程后,我们就可以确认实现各个部分所需要的工具:
1. 代码改动触发:karma配置autoWatch = true, singleRun = false持续运行服务
2. Git提交触发:husky配置git hooks, 执行karma start --single-run,实现单次运行服务,失败时自动阻塞Git提交
3. 打包构建:webpack或rollup,(代码覆盖率统计方面,因rollup-istanbul-plugin存在代码覆盖标记定位不准确的问题,故只考虑webpack + istanbul-instrument-loader的方案,不考虑此需求可以使用rollup + babel的方案,karma同样支持)
4. 本地服务代理:karma自身集成,可注册中间件plugin实现mock
5. 调起浏览器环境:karma相关launcher包可以调起各家浏览器,这里推荐使用puppeteer(比phantomJS在api支持上更稳定)和karma-chrome-launcher来做,完全静默运行
6. 执行测试用例:mocha (测试框架), chai (断言库), chai-as-promised (异步断言扩展)
7. 捕获浏览器执行结果:karma自身集成,capture浏览器输出到终端
8. 生成测试报告:karma-mocha-reporter, karma-coverage-istanbul-reporter(输出代码覆盖率)
梳理之后,我们可以得到这样的一个package.json列表:
"chai": "^4.2.0", // BDD / TDD 风格断言库
"chai-as-promised": "^7.1.1", // chai异步断言扩展
"husky": "^3.0.9", // git钩子
"karma": "^4.4.1", // 测试运行环境
"karma-chai": "^0.1.0", // chai注入插件
"karma-chrome-launcher": "^3.1.0", // chrome浏览器调起插件
"karma-cli": "^2.0.0", // 命令行工具
"karma-coverage": "^2.0.1", // 代码覆盖率统计插件
"karma-mocha": "^1.3.0", // mocha注入插件
"karma-mocha-reporter": "^2.2.5", // mocha报告生成插件
"karma-webpack": "^4.0.2", // webpack预处理插件
"karma-coverage-istanbul-reporter": "^2.1.0", // istanbul代码覆盖率统计插件
"istanbul-instrumenter-loader": "^3.0.1", // istanbul计数webpack-loader
"mocha": "^6.2.2", // mocha测试框架,提供单测API
"puppeteer": "^1.20.0", // headless浏览器
"webpack": "^4.41.2" // 打包工具
看上去很多,那么把这些积木搭好后我们能得到一个怎样的效果呢?
首先,对ES6语法的支持使得我们编写测试用例时可以很方便的捕获webAPI的异步错误:
// 引入promise支持
import chaiPromise from 'chai-as-promised';
chai.use(chaiPromise);
...
// localStorage失效异常检测
it('EXCEPTION', async function() {
await setDataAsync('storage', 1);
const storage = indexedDB;
// 删除idb对象,强制触发读取数据异常
delete indexedDB;
// 捕获promise异常,需注意应当捕获reject而不是exception
await expect(getDataAsync('storage')).to.be.rejectedWith(Error);
// 还原对象,获取到相关值
![60969619-Screen Shot 2016-09-13 at 2.24.26 pm.png](https://upload-images.jianshu.io/upload_images/19978227-2f7799d6ff5e2f44.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
indexedDB = storage;
// 捕获promise resolve值
await expect(getDataAsync('storage')).to.eventually.equal(1);
});
...
接下来我们挂起测试服务,可以看到该用例正常得以执行:通过这个执行log,我们可以看到karma实际的运行流程基本与预期的一致,它会首先调用webpack对测试文件及源码进行打包构建,然后建立起自身服务,并调用launcher插件打开puppeteer,与其建立起socket通信,之后将各测试用例的执行结果以mocha的格式打印到终端,测试完成后输出代码覆盖率统计信息。
此外,我们还可以进一步查看输出的icov格式报告,查看更详细的统计结果:得益于整个流程的自动化及合理的测试用例设计,现在这样的问题将永远不会出现在code review中。
三、karma runtime环境配置
在前一章中,我们介绍了自动测试框架的运行流程和相关依赖,下面这张图是对其整体的一个梳理:
- 中间的整条管线就是karma的运行流,从输入到输出,karma本质上只是一个测试运行环境server;
- processor, framework, browser, middleware这些模块是karma的配置项,karma主要通过注入的方式实现和测试框架、浏览器的接合;
- reporter的相关插件注入是需要和对应框架并行配置的,例如:istanbul的代码覆盖率检测需要在processors里通过webpack写入计数器并实现source-map,在reporter里通过karma-istanbul-coverage解析计数器得到代码覆盖率报告,并通过source-map映射到源码位置;
mocha的测试用例报告需要在framework和report里分别注入'mocha', 并引入karma-mocha-reporter插件,这样才能在命令行中看到简介的mocha测试报告
实际上我们需要做的只有4步:
- npm install需要的库,它们在前一章节已经给出
- 执行karma init,并选择对应的框架和浏览器,生成karma.conf.js文件
- 手动配置该文件,把我们在图中提到的各个部分一一引入:
const path = require('path');
// 让chromeHeadLess指向puppeteer
process.env.CHROME_BIN = require('puppeteer').executablePath();
module.exports = function(config) {
config.set({
basePath: '',
// 添加mocha测试框架,chai断言库
frameworks: ['mocha', 'chai'],
files: ['test/*.spec.js'],
// 添加mock配置并通过plugins指向对应js文件
middleware: ['mock'],
plugins: ['karma-*', require('./test/mock/middleware')],
// 打包构建
preprocessors: {
"test/*.spec.js": ['webpack'],
},
// webpack相关配置,无需指定entry, karma视测试文件为入口
webpackMiddleware: {
// 屏蔽webpack log信息
noInfo: true,
stats: 'errors-only'
},
webpack: {
mode: 'development',
// 启用source-map
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.js?$/,
// 指定源码位置
include: [path.join(__dirname, 'src')],
enforce: 'post',
use: {
// 注入istanbul代码覆盖计数
loader: 'istanbul-instrumenter-loader',
options: { esModules: true }
}
}
],
},
resolve: {
extensions: ['.js', '.json']
}
},
// 添加相关报告插件
reporters: ['mocha', 'coverage-istanbul'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
// 自动监视文件改动并触发测试
autoWatch: true,
// 调起chromeHeadless浏览器环境,使用真实浏览器环境调试需要指定为Chrome
browsers: ['ChromeHeadless'],
// 为true时执行一次后结束,false时持续运行,用于监视文件改动时触发测试,或在浏览器环境中进行调试
singleRun: false,
// 浏览器调起阈值
concurrency: 6000,
// istanbul代码覆盖率测试报告配置,主要为输出lcov格式报告
coverageIstanbulReporter: {
reports: ['html', 'lcovonly', 'text-summary'],
// /%browser%/可实现调用不同浏览器测试时分别计算代码覆盖率
dir: path.join(__dirname, 'test/coverage/%browser%/'),
fixWebpackSourcePaths: true,
'report-config': {
html: { outdir: 'html' }
}
},
// 屏蔽浏览器log,仅显示测试用例执行情况
client: {
captureConsole: false
},
})
}
四、本地Mock实现
karma自身提供了一个middleware的mock factory接口,可以通过返回一个function来处理req, res, next三个参数,因此实现mock只需:
- 解析req中的路由
- 根据req请求的内容,重定向的本地文件,通过fs.readfileSync读取
- 将读取数据注入到res中返回,如遇异常则调用next()交由下文代码处理
以笔者项目结构为例,之前配置文件中所require的./test/mock/middleware正是下图中的middleware.js:
对应的实现逻辑:
function mockFactory(config) {
const mockUrl = config.mockUriStart || '/mock/';
return function (req, res, next) {
// 检测Url匹配, 获取mock指向的路由
if (req.url.indexOf(mockUrl) === 0) {
const path = req.url.slice(mockUrl.length);
try {
const processor = require(`./processors/api`);
processor(path, res);
}
catch (e) {
next();
}
}
else {
next();
}
};
};
module.exports = {
'middleware:mock': ['factory', mockFactory]
};
有关数据流的处理则收敛到processors/api.js的逻辑中:
module.exports = function (req, res) {
const file_path = path.join(DATA_SRC, `${req}.json`);
try {
// 获取data文件中对应的json数据
var data = fs.readFileSync(file_path);
res.end(data);
} catch (err) {
// Handle a file-not-found error
throw err;
}
};
至此,整套测试框架的配置已经完成,可以在test目录下编写自己的.spec.js单测文件了~
当然,如果希望这个测试服务可以集成到git precommit钩子上,我们只需要在package.json中通过husky声明:
"husky": {
"hooks": {
// 预编译钩子仅执行一次全部测试
"pre-commit": "karma start --signle-run"
}
}
五、其他细节&踩坑记录
- mocha测试框架中, it函数内调用的异步函数出现断言错误,是不会引发测试报警的,必须要将断言语句直接放在it内:
async function postData(cb) {
new Promise((resolve) => {
setTimeout(() => {
resolve('success');
}, 1e3);
}).then(cb);
}
...
it('TEST ASYNC', async () => {
await postData((resp) => {
expect(resp).to.be.equal('fail') // 内层断言失败,不判定case失败
});
await expect(new Promise((resolve) => {
postData((resp) => {
resolve(resp);
});
}).to.eventually.equal('fail'); // 外层断言失败,判定case失败
})
...
-
karma是多线程打印输出的,这意味着多个reporter同时运行时,log会重复打印在终端中,如果想capture client的console log,最好还是选择在browsers配置中选择调起Chrome浏览器测试,而不是使用ChromeHeadless:
如果使用rollup+babel,需要为babel添加transform-runtime插件并启用runtimeHelpers,babel应当如下配置,并注入到rollup的plugins中(preprocessor仅使用rollup)
{
exclude: '**/node_modules/**',
runtimeHelpers: true,
plugins: [
['@babel/transform-runtime', { regenerator: true, useESModules: true }],
],
}
编写多个测试用例文件的情况下,请注意,所有的测试文件共享一个浏览器环境的上下文,这意味着测试用例中的代码可能会对全局变量造成污染,虽然karmas-iframe支持为每个测试用例建立单独的window上下文,但该插件对karma做了一些破坏性改动,使得代码覆盖率测试的结果会受到影响,因此只能予以放弃
如果希望修改某个测试用例时,不用再触发其他所有case的执行,可以通过给测试用例添加it.only或describe.only来实现,不过需注意此种情况下,对应的代码覆盖率也会只统计该本轮测试中覆盖的内容,所以最好仅在编辑测试用例时或调试使用时only声明,最终提交时去除,以免影响代码覆盖率统计及测试质量
使用safari浏览器测试会强制弹窗要求用户选择重定向文件,该过程会阻塞测试,目前暂无办法有效解决
Reference
[1] webpack+mocha+karma搭建typescript测试环境教程:https://blog.crimx.com/2019/06/19/%E6%90%AD%E5%BB%BA-karma-mocha-chai-%E6%B5%8B%E8%AF%95-typescript-%E9%A1%B9%E7%9B%AE/
[2] karma实现mock: https://github.com/renaesop/blog/issues/18
[3] Vue单元测试(mocha+chai)://www.greatytc.com/p/34afa645487b
[4] karma + phantomjs使用:https://juejin.im/post/59807358518825563e037e3c
[5] jest + puppeteer单元测试:https://juejin.im/post/5af90988518825426a1fcc2e
[6] karma + sauce lab多端云测:https://github.com/karma-runner/karma-sauce-launcher
[7] mocha + chai单测用例编写教程:http://www.alloyteam.com/2013/12/hour-class-learning-costs-javascript-unit-testing-tool-matcha-mocha-and-chai/
[8] How to test with async/wait using mocha&chai: https://stackoverflow.com/questions/45466040/verify-that-an-exception-is-thrown-using-mocha-chai-and-async-await
[9] 轻量级react技术栈单测框架(mocha+chai+nyc):https://ole.michelsen.dk/blog/testing-reactjs-with-coverage-using-mocha-babel-istanbul.html
[10] karma相关issues