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
programVisitor
programVisitor
是一个将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
的transformSync
API,得到生成的代码
更新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
,nested
3种选择
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
和BaseTree
Visitor
类和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遍历,逐个节点地打印出覆盖率报告。
其余类型的报告也是类似的原理。
更多