intanbuljs 是JS语言中最流行的覆盖率采集工具。其前身是个人发起的istanbul 项目。日常使用中,经常将相关的CLI工具nyc 配合着流行的前端测试框架 一起使用,达到在单元/集成/e2e测试的同时,输出测试覆盖率数据的效果。
当然,你也可以通过babel-plugin-istanbul 配合测试框架使用 。
和istanbul的对比 对于此前对前端测试覆盖率稍有了解了的人来说,可能更熟悉的是旧版的istanbul。istanbuljs在功能上和istanbul没有太大的区别,但在项目组织和实现细节上有着些许不同。
istanbuljs 使用monorepo组织整个项目,将相对独立的插桩、hook、覆盖率、sourcemap、报告等拆分成多个包独立实现和发版
使用babel实现源代码的AST解析和插桩(instrument)代码的生成
内置支持sourcemap
报告生成上取消了对express的依赖,只在本地产出报告
介绍 对于希望借助istanbuljs实现代码覆盖率相关功能的(nyc也基于istanbuljs实现),了解其各个包工作原理将会有所帮助。下面就按功能范畴介绍各个包的实现原理。
代码插桩 和hook 覆盖率产出建立在代码插桩 的基础上。根据插桩时机,分为编译时插桩 和运行时插桩 。
编译时插桩 ,即在代码转译过程中插入覆盖率采集代码,产出代码本身即拥有采集能力,譬如babel-plugin-istanbul
运行时插桩 ,即产出代码本身不具有采集能力,在运行时通过hook的方式在使用的代码中插入覆盖率采集代码,譬如hookRequire
不同于使用babel的编译时插桩,运行时插桩需要额外的hook一步。插桩和hook分别由istanbul-lib-instrument 和istanbul-lib-hook 实现。
istanbul-lib-instrument 1 2 3 4 5 6 7 8 9 10 11 import { createInstrumenter } from 'istanbul-lib-instrument' ;const instrumenter = createInstrumenter ({ coverageVariable, debug : options && options.debug }); const transformed = instrumenter.instrumentSync ( code, options.filename , sourceMap ? sourceMap.toObject () : undefined );
istanbul-lib-instrument利用babel实现了代码插桩。仓库暴露了3个API:
createInstrumenter最主要的API。以入参作为配置项创建一个Instrumenter实例,配置项如下:
coverageVariable 覆盖率全局变量名,可选,默认__coverage__
preserveComments 是否保留注释,可选,默认false
esModules 是否插桩ES6代码,可选,默认false
produceSourceMap 是否为插桩前后代码生成sourcemap,可选,默认false
debug 是否打印详细信息,可选,默认false
… 更多配置参见文档介绍
通常使用时,配置项均使用默认值即可。Instrumenter实例有下面几个重要的方法
instrumentSync
同步插桩代码,支持ES6和ES5,插桩ES6代码时,需要配置项中的esModules为true。解析代码出错时会抛出错误。方法返回插桩后的代码字符串,入参如下:
code 代码字符串
filename 文件名(包含路径)
inputSourceMap 用来将当前代码的覆盖率反映射到源文件中的覆盖率,需要是sourcemap格式。
在指定了inputSourceMap后,当前文件采集覆盖率时,会附带上传入的这个sourcemap,从而可以结合istanbul-lib-sourcemaps使用。
instrument
callback风格的插桩,此时抛出错误将通过回调的入参传入,而非直接抛出。插桩的代码也会在回调中传入而非直接返回。需要注意的是,回调的执行和语句的执行在同一个process tick中,而不是异步的。
函数入参即instrumentSync的第三个位置插入函数类型的callback入参。
剩余的两个方法:
lastFileCoverage,返回最近一次的文件覆盖率对象
lastSourceMap,返回最近一次的文件的sourcemap
programVisitorprogramVisitor是一个将babel用于插桩的适配器函数,该函数会返回一个具有enter以及exit方法的对象,这两个方法必须应用在Program的enter和exit属性上,实现插桩效果。istanbuljs内部也是通过programVisitor实现的功能。programVisitor内部并不依赖babel的状态,因此也可以用在babel以外的环境。
该函数支持以下入参:
types,babel-types实例,语法节点类型
sourceFilePath,文件路径,可选,默认为unknown.js
opts,插桩配置项,可以参见文档介绍
在函数内部集中了插桩的细节实现,即对于特定类型语法树节点插入对应逻辑。
readInitialCoverage配合Instrumenter的lastFileCoverage方法使用,读取文件的初始覆盖率。
上面这些API中,最常用和最核心的即instrumentSync方法。在实现上,方法分为下面几步:
根据Instrumenter的配置项确定babel的配置项,同时,引入programVisitor作为babel的plugin,指定遍历节点的操作
使用@babel/core的transformSyncAPI,得到生成的代码
更新fileCoverage和sourcemap
返回代码
istanbul-lib-hook 1 2 3 4 5 6 7 import { hookRequire } from 'istanbul-lib-hook' ;hookRequire (matcher, transformer, { verbose : true , postLoadHook : (filename: string ) => { } });
istanbul-lib-hook提供了下面一些API,用来hook JS中对代码的引用,其中后三个API都有对应的unhook API:
hookRequire,hook了require引入的代码
hookCreateScript,hook了vm.createScript引入的代码
hookRunInThisContext,hook了vm.runInThisContext引入的代码
hookRunInContext,hook了vm.runInContext引入的代码、
在其中最常用的是hookRequire,入参如下:
matcher,函数类型,接收文件的完整路径,返回bool类型,用来判断是否对文件插桩
transformer,函数类型,接收代码和文件路径,返回插桩后的代码,可以通过对上面提到的Instrumenter的instrumentSync封装得到
options,配置项
verbose,是否打印详细信息
postLoadHook,文件hook之后的回调
hookRequire借助Nodejs的Module加载机制实现,hook了后缀为.js文件的编译过程,在每次require的时候触发。详细的原理可以参见这里 。唯一的不同是,在istanbuljs中,这一实现被封装在append-transform 包中实现,来兼容异常情况。实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const appendTransform = require ('append-transform' );function hookRequire (matcher, transformer, options ) { const fn = transformFn (matcher, transformer, options.verbose ); const extensions = options.extensions || ['.js' ]; extensions.forEach (ext => { appendTransform ((code, filename ) => { const ret = fn (code, filename); if (postLoadHook) { postLoadHook (filename); } return ret.code ; }, ext); }); }
数据维护 在代码插桩的基础上,产出的覆盖率数据会积累在全局变量上。在数据可视化和统计结果展示前,还需要将数据封装成更贴近实际场景的形态,便于进行合并、输出、摘要等操作。
这部分工作在istanbul-lib-coverage 中完成。
覆盖率数据结构 下面是采集到的原始数据的结构定义,这也是istanbul-lib-coverage的基础。
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 interface CoverageMapData { [key : string ]: FileCoverageData ; } interface FileCoverageData { path : string ; statementMap : { [key : string ]: Range }; fnMap : { [key : string ]: FunctionMapping }; branchMap : { [key : string ]: BranchMapping }; s : { [key : string ]: number }; f : { [key : string ]: number }; b : { [key : string ]: number [] }; } interface Location { line : number ; column : number ; } interface Range { start : Location ; end : Location ; } interface BranchMapping { loc : Range ; type : string ; locations : Range []; line : number ; } interface FunctionMapping { name : string ; decl : Range ; loc : Range ; line : number ; }
istanbul-lib-coverage 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import libCoverage from 'istanbul-lib-coverage' ;const map = libCoverage.createCoverageMap (globalCoverageVar);const summary = libCoverage.createCoverageSummary ();map.merge (otherCoverageMap); map.files ().forEach ((f ) => { const fc = map.fileCoverageFor (f); const s = fc.toSummary (); summary.merge (s); }); console .log ('Global summary' , summary);
包暴露了3个API, 用来创建封装好的覆盖率数据。
createCoverageMap传入原始数据(CoverageMapData类型)或CoverageMap类型,返回一个CoverageMap类型对象,在覆盖率数据基础上,提供了方法,包含:
merge,合并另一个CoverageMap对象或数据
filter,传入filter函数,删除部分覆盖率数据
toJSON,返回原始数据
files,返回文件列表
fileCoverageFor,返回特定文件的覆盖率
addFileCoverage,增加特定文件的覆盖率
getCoverageSummary,生成覆盖率摘要数据
摘要数据数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 interface CoverageSummaryData { lines : Totals ; statements : Totals ; branches : Totals ; functions : Totals ; } interface Totals { total : number ; covered : number ; skipped : number ; pct : number ; }
createFileCoverage类似createCoverageMap,通过传入原始数据或对象的方式,创建FileCoverage对象。对象有如下方法:
getLineCoverage,返回每一行的执行次数
getUncoveredLines,返回未覆盖的行序号列表
getBranchCoverageByLine,获取每个分支按行计算的覆盖率比例
toJSON,返回原始数据
merge,合并另一个FileCoverage或原始数据
computeSimpleTotals,根据输入的属性,计算覆盖率摘要
computeBranchTotals,根据分支计算覆盖率摘要
resetHits,重置所有已执行的数据
toSummary,生成摘要数据
createCoverageSummary根据输入数据,创建摘要。方法较简单:
merge,合并一个摘要数据
toJSON,返回原始摘要数据
isEmpty,是否为空
整体来看istanbul-lib-coverage所做的工作,即按照约定好的格式,将数据组织起来,为可视化输出做了基础。
istanbul-lib-source-maps 1 2 3 4 5 6 7 8 9 10 import { createSourceMapStore } from 'istanbul-lib-source-maps' ;import libCoverage from 'istanbul-lib-coverage' ;const mapStore = createSourceMapStore ({});const coverageMap = libCoverage.createCoverageMap (coverageData);const transformed = await mapStore.transformCoverage (coverageMap);const transformedCoverage = transformed.data ;console .log (transformedCoverage);
绝大多数情况下,istanbuljs采集到的都是转译后目标文件的代码覆盖率,这个数据对于开发者来讲意义不大,需要将这里采集到的行列数据反映射到源文件的位置上。内置istanbul-lib-source-maps 包用来处理这种情况的。
istanbul-lib-source-maps只负责转换代码位置和目录,并不能负责收集代码或文件的sourcemap信息,这一步交给使用者自己实现。就像我们在instrumentSync一节提到的,通过第三个入参inputSourceMap可以产出满足istanbul-lib-source-maps需要的覆盖率数据,从而借助istanbul-lib-source-maps获取到源文件的覆盖率。
这个包本身只暴露createSourceMapStore这一个API,通过传入配置的方式,初始化一个sourcemap的store。配置项如下:
verbose,是否打印详细信息
baseDir,sourcemap文件的基础目录
SourceStore,一个SourceStore对象
sourceStoreOpts,初始化SourceStore对象的参数列表
初始化好的MapStore对象有如下方法:
registerURL,通过dataURL注册一个sourcemap到store中
registerMap,通过sourcemap对象注册一个sourcemap到store中
getSourceMapSync,从当前store中获取指定文件的sourcemap
addInputSourceMapsSync,为当前覆盖率数据同步添加store中的sourcemap数据
sourceFinder,寻找指定文件的源文件路径
transformCoverage,利用当前store中的sourcemap信息,将覆盖率对应到源文件上。覆盖率信息中有inputSourceMap字段时,优先使用inputSourceMap字段里的sourcemap信息
dispose,清除store数据
包中的其余文件也都基于MapStore实现诸如路径转换,数据转换等工具方法。更多细节,可以参见源文件 。
使用时,有两种方式:
先导入没有sourcemap的覆盖率信息,再逐个注册sourcemap到store中,最后执行transformCoverage
直接导入有inputSourceMap信息的覆盖率数据,执行transformCoverage
可以根据实际场景选择使用方式。例如,nyc中就使用的第2种方式,利用convert-source-map 采集到文件内的sourcemap信息,在代码插桩时传入。
可视化与报告 覆盖率工具的最终目的是向使用者呈现可读的数据样式,通常是以UI或文件的形式。因此,要将上一章中维护好的数据按使用者需求输出。可视化和报告由istanbul-lib-report 以及istanbul-reports 实现。
其中,前者定义了产出报告的抽象行为,后者实现了各种具体的报告形态。两个包结合在一起使用的方式如下:
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 import libReport from 'istanbul-lib-report' ;import reports from 'istanbul-reports' ;const coverageMap;const configWatermarks = { statements : [50 , 80 ], functions : [50 , 80 ], branches : [50 , 80 ], lines : [50 , 80 ] }; const context = libReport.createContext ({ dir : 'report/output/dir' , defaultSummarizer : 'nested' , watermarks : configWatermarks, coverageMap, }); const report = reports.create ('json' , { skipEmpty : configSkipEmpty, skipFull : configSkipFull }); report.execute (context);
istanbul-lib-report 维护好的覆盖率数据会和具体的文件节点绑定,形成树状数据结构。遍历每个树节点就是产出报告的基本步骤,只不过不同报告格式,遍历节点的操作也不同。istanbul-lib-report中定义了基本的树状结构和遍历规则。
包暴露了3个API,
createContext,创建一个包含产出报告配置信息context对象
getDefaultWatermarks,返回默认的评级指标,默认是0到50到80,分别对应差中好
ReportBase,所有报告必须继承的基类
其中context和ReportBase是配合实现生成报告的。先来看简单的ReportBase,ReportBase类中简单地描述了管理摘要树的方法和定义了通用的execute方法来生成报告。具体会在istanbul-reports中提到。
再来看Context类。Context入参配置项包含:
dir,产出报告的目标目录
watermarks,评级指标,默认使用0-50-80-100
sourceFinder,通过文件路径返回代码的函数,默认为文件读取操作
coverageMap,覆盖率数据
defaultSummarizer,摘要树生成的风格,有flat,pkg,nested3种选择
flat,所有文件全部打平到1层里,挂载到最近的公共祖先上
pkg,所有文件向上追溯1层文件夹,挂载到最近的公共祖先上
nested,所有文件向上追溯,直到追溯到最近的公共祖先上
构建好的Context有一个writer属性挂载在this和this.data上,它决定了报告的产出形式,目前这个包提供了两种产出形式:文件 和字符串 ,后者只用在产出XML数据时。其余报告基本都通过文件的形式产出,这也是istanbuljs和istanbul的一大不同。在istanbul中,html可以借助express依赖以HTML响应的形式返回。
文件的输出类定义在file-writer.js中,其中的FileWriter定义了基本的copyFile,writerForDir,writeFile操作用来递归的复制和写入文件。另外在writeFile中,根据目标是否是file,选择使用ConsoleWriter或者FileContentWriter。这两者继承自基本的ContentWriter。
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 41 42 43 44 45 class ContentWriter { colorize (str ) { return str; } println (str ) { this .write (`${str} \n` ); } close ( ) {} } class FileContentWriter extends ContentWriter { constructor (fd ) { super (); this .fd = fd; } write (str ) { fs.writeSync (this .fd , str); } close ( ) { fs.closeSync (this .fd ); } } class ConsoleWriter extends ContentWriter { write (str ) { if (capture) { output += str; } else { process.stdout .write (str); } } colorize (str, clazz ) { const colors = { low : '31;1' , medium : '33;1' , high : '32;1' }; if (supportsColor.stdout && colors[clazz]) { return `\u001b[${colors[clazz]} m${str} \u001b[0m` ; } return str; } }
字符串的输出类只有XMLWriter,位于xml-writer.js中,在其中定义了xml的开闭标签和缩进操作。
这个Context还有一些方法提供给reports使用:
getWriter/getXMLWriter,返回输出数据的writer
getSource,通过sourceFinder寻找源码
classForPercent,将评价转化为HTML类名
getVisitor,获取一个遍历的vistor对象
getTree,根据摘要风格返回一个用于遍历的树,
下面具体介绍vistor和tree的概念
Visitor和BaseTreeVisitor类和BaseNode类定义了遍历和节点的基本操作,其中Visitor使用代理人模式,在构造时传入一个回调函数对象,当visitor对象触发特定事件时,会将当前节点和状态交给回调函数,实现遍历效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Visitor { constructor (delegate ) { this .delegate = delegate; } } ['Start' , 'End' , 'Summary' , 'SummaryEnd' , 'Detail' ] .map (k => `on${k} ` ) .forEach (fn => { Object .defineProperty (Visitor .prototype , fn, { writable : true , value (node, state ) { if (typeof this .delegate [fn] === 'function' ) { this .delegate [fn](node, state); } } }); });
相对应的BaseNode类定义了用于遍历的visit方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class BaseNode { visit (visitor, state ) { if (this .isSummary ()) { visitor.onSummary (this , state); } else { visitor.onDetail (this , state); } this .getChildren ().forEach (child => { child.visit (visitor, state); }); if (this .isSummary ()) { visitor.onSummaryEnd (this , state); } } }
最后在BaseTree中从Root开始遍历即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class BaseTree { constructor (root ) { this .root = root; } getRoot ( ) { return this .root ; } visit (visitor, state ) { if (!(visitor instanceof Visitor )) { visitor = new Visitor (visitor); } visitor.onStart (this .getRoot (), state); this .getRoot ().visit (visitor, state); visitor.onEnd (this .getRoot (), state); } }
不同的摘要风格形成的树状结构不同,它们都是ReportTree类型,而ReportTree正是继承自BaseTree。ReportTree由继承自BaseNode的ReportNode构建。在ReportNode上定义了和覆盖率或文件操作相关的一些方法,如getFileCoverage,getCoverageSummary等。
一个context内的visitor和tree可以通过getVisitor和getTree得到
istanbul-reports istanbul-reports包中定义了种类繁多的导出格式,在入口文件通过指定的配置项选择使用,如上面样例中的const report = reports.create('json', {/* ... */})即使用json/lib/index.js下导出的JsonReport类。istanbul-reports所有格式都基于istanbul-lib-report中基类的定义。这里以简单的json格式为例。
其中定义了对于onStart,onDetail,onEnd的定义和上面BaseNode中介绍的回调函数相对应,在遍历ReportTree的各个阶段被触发,通过context的writer去输出。而writer从ReportBase中可以发现是通过context确定的,在context中默认是filewriter。而遍历是如何执行的呢?
回头看下ReportBase的实现。
1 2 3 4 5 6 7 8 9 10 11 const _summarizer = Symbol ('ReportBase.#summarizer' );class ReportBase { constructor (opts = {} ) { this [_summarizer] = opts.summarizer ; } execute (context ) { context.getTree (this [_summarizer]).visit (this , context); } }
在execute中传入context,即可把带有覆盖率信息的树形数据结构交给report代表的visitor遍历,逐个节点地打印出覆盖率报告。
其余类型的报告也是类似的原理。
更多