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
是否保留注释,可选,默认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 | 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
,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 | 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遍历,逐个节点地打印出覆盖率报告。
其余类型的报告也是类似的原理。