JS代码覆盖率工具instanbuljs及其思路介绍

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 | import { createInstrumenter } from 'istanbul-lib-instrument'; |
istanbul-lib-instrument利用babel实现了代码插桩。仓库暴露了3个API:
createInstrumenter
最主要的API。以入参作为配置项创建一个Instrumenter实例,配置项如下:
coverageVariable覆盖率全局变量名,可选,默认__coverage__preserveComments是否保留注释,可选,默认falseesModules是否插桩ES6代码,可选,默认falseproduceSourceMap是否为插桩前后代码生成sourcemap,可选,默认falsedebug是否打印详细信息,可选,默认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.jsopts,插桩配置项,可以参见文档介绍
在函数内部集中了插桩的细节实现,即对于特定类型语法树节点插入对应逻辑。
readInitialCoverage
配合Instrumenter的lastFileCoverage方法使用,读取文件的初始覆盖率。
上面这些API中,最常用和最核心的即instrumentSync方法。在实现上,方法分为下面几步:
- 根据
Instrumenter的配置项确定babel的配置项,同时,引入programVisitor作为babel的plugin,指定遍历节点的操作 - 使用
@babel/core的transformSyncAPI,得到生成的代码 - 更新
fileCoverage和sourcemap - 返回代码
istanbul-lib-hook
1 | import { hookRequire } from 'istanbul-lib-hook'; |
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 | const appendTransform = require('append-transform'); |
数据维护
在代码插桩的基础上,产出的覆盖率数据会积累在全局变量上。在数据可视化和统计结果展示前,还需要将数据封装成更贴近实际场景的形态,便于进行合并、输出、摘要等操作。
这部分工作在istanbul-lib-coverage中完成。
覆盖率数据结构
下面是采集到的原始数据的结构定义,这也是istanbul-lib-coverage的基础。
1 | interface CoverageMapData { |
istanbul-lib-coverage
1 | import libCoverage from 'istanbul-lib-coverage'; |
包暴露了3个API, 用来创建封装好的覆盖率数据。
createCoverageMap
传入原始数据(CoverageMapData类型)或CoverageMap类型,返回一个CoverageMap类型对象,在覆盖率数据基础上,提供了方法,包含:
merge,合并另一个CoverageMap对象或数据filter,传入filter函数,删除部分覆盖率数据toJSON,返回原始数据files,返回文件列表fileCoverageFor,返回特定文件的覆盖率addFileCoverage,增加特定文件的覆盖率getCoverageSummary,生成覆盖率摘要数据
摘要数据数据结构如下:
1 | interface CoverageSummaryData { |
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 | import { createSourceMapStore } from 'istanbul-lib-source-maps'; |
绝大多数情况下,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中获取指定文件的sourcemapaddInputSourceMapsSync,为当前覆盖率数据同步添加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 | import libReport from 'istanbul-lib-report'; |
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-100sourceFinder,通过文件路径返回代码的函数,默认为文件读取操作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 | class ContentWriter { |
字符串的输出类只有XMLWriter,位于xml-writer.js中,在其中定义了xml的开闭标签和缩进操作。
这个Context还有一些方法提供给reports使用:
getWriter/getXMLWriter,返回输出数据的writergetSource,通过sourceFinder寻找源码classForPercent,将评价转化为HTML类名getVisitor,获取一个遍历的vistor对象getTree,根据摘要风格返回一个用于遍历的树,
下面具体介绍vistor和tree的概念
Visitor和BaseTree
Visitor类和BaseNode类定义了遍历和节点的基本操作,其中Visitor使用代理人模式,在构造时传入一个回调函数对象,当visitor对象触发特定事件时,会将当前节点和状态交给回调函数,实现遍历效果。
1 | class Visitor { |
相对应的BaseNode类定义了用于遍历的visit方法
1 | class BaseNode { |
最后在BaseTree中从Root开始遍历即可:
1 | class BaseTree { |
不同的摘要风格形成的树状结构不同,它们都是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 | const _summarizer = Symbol('ReportBase.#summarizer'); |
在execute中传入context,即可把带有覆盖率信息的树形数据结构交给report代表的visitor遍历,逐个节点地打印出覆盖率报告。
其余类型的报告也是类似的原理。