在讲之前得说下 前端覆盖率的水真的是很深的,其实到目前为止还有很多未解之谜,由于对 babel 的编译以及 ast 了解的不是很多。所以确实分析问题起来很困难。
关于前端代码覆盖率还不了解这块内容的同学们,可以参考下以下几篇文章,这里就不做赘述了。
了解了上述两篇文章以后,你应该对前端的代码覆盖率有一定的了解了。那下来说下具体的方案吧。下面就是我们前端代码覆盖率的具体方案了(PS: 画的很潦草,不太专业,大家请将就下)
这里涉及到几个关键的部分要说明下:
由于我们公司的项目发布的流程是直接使用测试通过的镜像直接上线到正式环境去,所以如果在测试环境部署的代码直接是打包后插桩的内容,然后再上到正式环境话这个是个很糟糕的事情。 所以我们做了一个处理,前端在编译打包的时候需要打出两份内容: 一个是插桩后的 js 文件 (用于测试验证),另外一个是未插桩的 js 文件 (用于正式上线)。插桩后的 js 会上传到 cdn 或者说我们自己的一个私有服务上做保留。
不过目前 chrome 插件这个方案可能要被我们弃用掉了,因为 chrome 插件本身只能局限于 chrome 浏览器上,而我们现在更多的会有一些 h5 页面的情况,这些他就不能够满足,所以我们会将这部分的逻辑直接转到 fiddler 中,由 fiddler 来完成这块的工作。这样子就能满足移动端的测试覆盖率的问题了。
覆盖率后台 (node)
这块的实现我们没有直接使用 istanbul-middleware 这个方案,因为对于一个长达 5 年没有维护更新的项目,我还是持有一定的怀疑态度 (当然可能项目本身很优秀,完全没有问题)。所以我们把目光放到了 nyc 上,不过 nyc 更多是结合单元测试框架:jest 使用或者说直接通过命令行的方式进行调用。没有太多有涉及到如何使用它的 api 的方法上。幸运的是我们又找到了另外一个项目 Cypress 的 code-coverage,(这里做个小广告, cypress 是一个很优秀的前端自动化工具)。 在这个项目里你可以看到它就是通过调用 nyc 的 api 进行生成覆盖率的测试报告的。 所以这块我们毫不犹豫的选择了它做了一定的二次开发了。
理想总是很美好的,我们在一个简单的 demo 项目上实验了下,基本没啥问题。但是进入到真正的项目的时候,发现真的是困难重重。
这个问题我发现在其他文章里面都很少提到。因为使用 istanbul(最新的版本) 插桩的方案的话需要在 babel7 的版本进行,所以需要前端的项目做升级才行,而我们大部分的前端的项目都是停留在 babel6 的版本,这个升级过程就非常的痛苦,尤其前端的项目又用到了各种的脚手架。(不过痛着痛着就习惯了,经历过几次项目的 babel 版本升级,基本上遇到的问题也就那几个,google 基本都能够帮忙解决了)以下就附上 babel 升级要做的一些修改
Babel 6 | Babel 7 |
---|---|
babel-core | @babel/core |
babel-plugin-transform-class-properties | @babel/plugin-proposal-class-properties |
babel-plugin-transform-object-rest-spread | @babel/plugin-proposal-object-rest-spread |
babel-plugin-syntax-dynamic-import | @babel/plugin-syntax-dynamic-import |
babel-plugin-transform-object-assign | @babel/plugin-transform-object-assign |
babel-plugin-transform-runtime | @babel/plugin-transform-runtime |
babel-plugin-transform-decorators-legacy | @babel/plugin-proposal-decorators |
babel-preset-env | @babel/preset-env |
babel-preset-react | @babel/preset-react |
babel-loader@7 | babel-loader@8 |
当然还有 babelrc 文件的修改等等,这里就不说了。
babel-plugin-import 是一个 antd ui 库的按需加载的插件, 因为 antd 的使用非常的广泛, 基本上我们的前端项目都会使用到这个 ui 库, 所以注定这个问题会遇到了。问题如下图所示
相关的问题在 istanbul issue 中也可以找到 Does not work with babel-plugin-import 文中提到的解决方案有两种:
1.直接修改 babel-plugin-import 的源码。
2.修改自己引用 ui 库的方式。
上述两种都比较麻烦,然而我们在机缘巧合下发现 可以通过在 babelrc 中引入 @babel/plugin-transform-modules-commonjs 也可以解决这个问题。不过原因暂时还不清楚 (前端的打包真的太深奥了)
可以看下 基于 Istanbul 优雅地搭建前端 JS 覆盖率平台 评论区的内容
我们先看下 coverage 数据的对比情况
首先针对这个问题,我们需要一步步的去看,我们首先要确定的一点是为什么 babel-loader + ts-loader 的方式能够出现 inputSourceMap 的内容,而 babel-loader 却没有。 这两者主要的差别实际上就是在多了一个 ts-loader 上。所以我们首先的思路是去看下 ts-loader 这块做了什么事情。
function makeSourceMapAndFinish(
sourceMapText: string | undefined,
outputText: string | undefined,
filePath: string,
contents: string,
loaderContext: webpack.loader.LoaderContext,
fileVersion: number,
callback: webpack.loader.loaderCallback,
instance: TSInstance
) {
if (outputText === null || outputText === undefined) {
setModuleMeta(loaderContext, instance, fileVersion);
const additionalGuidance = isReferencedFile(instance, filePath)
? ' The most common cause for this is having errors when building referenced projects.'
: !instance.loaderOptions.allowTsInNodeModules &&
filePath.indexOf('node_modules') !== -1
? ' By default, ts-loader will not compile .ts files in node_modules.\n' +
'You should not need to recompile .ts files there, but if you really want to, use the allowTsInNodeModules option.\n' +
'See: https://github.com/Microsoft/TypeScript/issues/12358'
: '';
callback(
new Error(
`TypeScript emitted no output for ${filePath}.${additionalGuidance}`
),
outputText,
undefined
);
return;
}
const { sourceMap, output } = makeSourceMap(
sourceMapText,
outputText,
filePath,
contents,
loaderContext
);
setModuleMeta(loaderContext, instance, fileVersion);
callback(null, output, sourceMap);
}
这个地方是 ts-loader 最后处理后的回调,我们可以看到这里带了一个 sourceMap。 那这个 sourceMap 到底是什么呢?我们尝试用断点去看看。
这个确实就是我们在 coverage 数据里面看到的情况
所以顺着这个流程 ts-loader 讲数据传递给到了 babel-loader, babel-loader 则将这个数据给到了 istanbul。
既然讲到了 istanbul 我们来看下 istanbul 这块是怎么去获取 inputSouceMap 的吧。
export default declare(api => {
api.assertVersion(7)
const shouldSkip = makeShouldSkip()
const t = api.types
return {
visitor: {
Program: {
enter (path) {
this.__dv__ = null
this.nycConfig = findConfig(this.opts)
const realPath = getRealpath(this.file.opts.filename)
if (shouldSkip(realPath, this.nycConfig)) {
return
}
let { inputSourceMap } = this.opts
// 这里的条件可以看出来 inputSouceMap是空并且 this.file.inputMap是有内容的情况下 才会进行相应的InputSouceMap的赋值操作, 所以coverage数据中有否 inputSourceMap都是依赖file的inputMap中的内容。
if (this.opts.useInlineSourceMaps !== false) {
if (!inputSourceMap && this.file.inputMap) {
inputSourceMap = this.file.inputMap.sourcemap
}
}
const visitorOptions = {}
Object.entries(schema.defaults.instrumentVisitor).forEach(([name, defaultValue]) => {
if (name in this.nycConfig) {
visitorOptions[name] = this.nycConfig[name]
} else {
visitorOptions[name] = schema.defaults.instrumentVisitor[name]
}
})
this.__dv__ = programVisitor(t, realPath, {
...visitorOptions,
inputSourceMap
})
this.__dv__.enter(path)
},
exit (path) {
if (!this.__dv__) {
return
}
const result = this.__dv__.exit(path)
if (this.opts.onCover) {
this.opts.onCover(getRealpath(this.file.opts.filename), result.fileCoverage)
}
}
}
}
}
})
如上述所说的现在对 istanbul 来说最关键的字段是 inputMap。 那我们来看下 babel-loader 或者说 babel 里面是否有对 inputMap 做一个赋值的动作,分别在这两个仓库中查了下这个关键字,发现在 babel 中知道了。
关键的信息应该就是在 normalize-file 中了。我们看看这块的有一个逻辑
export default function* normalizeFile(
pluginPasses: PluginPasses,
options: Object,
code: string,
ast: ?(BabelNodeFile | BabelNodeProgram),
): Handler<File> {
code = `${code || ""}`;
if (ast) {
if (ast.type === "Program") {
ast = t.file(ast, [], []);
} else if (ast.type !== "File") {
throw new Error("AST root must be a Program or File node");
}
ast = cloneDeep(ast);
} else {
ast = yield* parser(pluginPasses, options, code);
}
let inputMap = null;
if (options.inputSourceMap !== false) {
// If an explicit object is passed in, it overrides the processing of
// source maps that may be in the file itself.
// 已经通过ts-loader处理以后 inputSouceMap是一个object对象了,所以直接做赋值了。
if (typeof options.inputSourceMap === "object") {
inputMap = convertSourceMap.fromObject(options.inputSourceMap);
}
// 这下边的部分逻辑都是在判断ast内容里面是否有包含soumap的字符串的信息,但是实际上如果是单独babel-loader处理的是不存在的。
if (!inputMap) {
const lastComment = extractComments(INLINE_SOURCEMAP_REGEX, ast);
if (lastComment) {
try {
inputMap = convertSourceMap.fromComment(lastComment);
} catch (err) {
debug("discarding unknown inline input sourcemap", err);
}
}
}
if (!inputMap) {
const lastComment = extractComments(EXTERNAL_SOURCEMAP_REGEX, ast);
if (typeof options.filename === "string" && lastComment) {
try {
// when `lastComment` is non-null, EXTERNAL_SOURCEMAP_REGEX must have matches
const match: [string, string] = (EXTERNAL_SOURCEMAP_REGEX.exec(
lastComment,
): any);
const inputMapContent: Buffer = fs.readFileSync(
path.resolve(path.dirname(options.filename), match[1]),
);
if (inputMapContent.length > LARGE_INPUT_SOURCEMAP_THRESHOLD) {
debug("skip merging input map > 1 MB");
} else {
inputMap = convertSourceMap.fromJSON(inputMapContent);
}
} catch (err) {
debug("discarding unknown file input sourcemap", err);
}
} else if (lastComment) {
debug("discarding un-loadable file input sourcemap");
}
}
}
// 这里的返回值就是我们看到的一个File的对象实例,里面就包含有inputMap.
return new File(options, {
code,
ast,
inputMap,
});
}
所以如果单独用 babel-loader 的情况 是没有办法拿到 inputSouceMap 的
以上就是大概解释了为什么 ts-loader+babel-loader 是由 inputSourceMap 然后单独的 babel-loader 是没有的。
至少从这个截图来了 ts-loader + babel-loader 的结果更正确点才对。
所以我们现在需要确认的一点是为什么 coverage 中的 statement 会有差别。
其实这里很容易有一个猜测的 ts-loader 处理后的内容其实已经不是真正的源码内容了,已经变化了才对。所以我们还是需要再去看下 normalize-file
因为我们注意到它的参数里面其实就包含有 ast 以及相应的 code。 所以一样的我们继续断点到这个地方看下数据的情况
ts-loader + babel-loader 的 code 及 ast
对于单独的 babel-loader 的情况
从上图的几个对比其实就已经能够知道为什么 coverage 数据的 statement 的数组的个数等都有区别了。
但是可能又有人会好奇的问题到,那为什么单独使用 ts-loader 编译。import 的语句都没有被计算进去呢,从 ats 来看,import 的语句命名也是被翻译过来为 ImportDeclaration
才对,
这块呢又要说到 istanbul 中的 code instrument 这块去了,由于我对这块的理解不深,只是通过断点的方式做了一些初步的判断做的一些猜想。
其实如果我们有些人细心的话就能够发现 原本是 import 的语句。
比如说
import * as React from "react";
经过 ts-loader 转换后,代码已经变成了
var React = require('react');
其实是被从 es6 转换成了 commonjs 了。 所以它的 ats 的转换也从 ImportDeclaration
变成了 VariableDeclaration
所以从这个过程可以看出来 VariableDeclaration
被识别了出来,但是ImportDeclaration
貌似不被 intrument 所认可,是这样子吗? 我们又要看下代码了。
const codeVisitor = {
ArrowFunctionExpression: entries(convertArrowExpression, coverFunction),
AssignmentPattern: entries(coverAssignmentPattern),
BlockStatement: entries(), // ignore processing only
ExportDefaultDeclaration: entries(), // ignore processing only
ExportNamedDeclaration: entries(), // ignore processing only
ClassMethod: entries(coverFunction),
ClassDeclaration: entries(parenthesizedExpressionProp('superClass')),
ClassProperty: entries(coverClassPropDeclarator),
ClassPrivateProperty: entries(coverClassPropDeclarator),
ObjectMethod: entries(coverFunction),
ExpressionStatement: entries(coverStatement),
BreakStatement: entries(coverStatement),
ContinueStatement: entries(coverStatement),
DebuggerStatement: entries(coverStatement),
ReturnStatement: entries(coverStatement),
ThrowStatement: entries(coverStatement),
TryStatement: entries(coverStatement),
VariableDeclaration: entries(), // ignore processing only
VariableDeclarator: entries(coverVariableDeclarator),
IfStatement: entries(
blockProp('consequent'),
blockProp('alternate'),
coverStatement,
coverIfBranches
),
ForStatement: entries(blockProp('body'), coverStatement),
ForInStatement: entries(blockProp('body'), coverStatement),
ForOfStatement: entries(blockProp('body'), coverStatement),
WhileStatement: entries(blockProp('body'), coverStatement),
DoWhileStatement: entries(blockProp('body'), coverStatement),
SwitchStatement: entries(createSwitchBranch, coverStatement),
SwitchCase: entries(coverSwitchCase),
WithStatement: entries(blockProp('body'), coverStatement),
FunctionDeclaration: entries(coverFunction),
FunctionExpression: entries(coverFunction),
LabeledStatement: entries(coverStatement),
ConditionalExpression: entries(coverTernary),
LogicalExpression: entries(coverLogicalExpression)
};
codeVisitor 中定义了各个表达式的处理,但是里面确实就不包括 ImportDeclaration
所以这里就应该是解释了为什么 import 语句没有显示被覆盖率的原因了
其实不是说不能主要是这里遇到了一些坑, 我们首先先看下官方的文档的说明
Including files within
node_modules
We always add
**/node_modules/**
to the exclude list, even if not >specified in the config.
You can override this by setting--exclude-node-modules=false
.For example,
"excludeNodeModules: false"
in the followingnyc
config will preventnode_modules
from being added to the exclude rules.
The set of include rules then restrict nyc to only consider instrumenting files found under thelib/
andnode_modules/@my-org/
directories.
The exclude rules then prevent nyc instrumenting anything in atest
folder and the filenode_modules/@my-org/something/unwanted.js
.
{
"all": true,
"include": [
"lib/**",
"node_modules/@my-org/**"
],
"exclude": [
"node_modules/@my-org/something/unwanted.js",
"**/test/**"
],
"excludeNodeModules": false
}
根据上述的信息, 我们在 package.json 中做相应的修改。重新进行打包后,coverage 的数据中并没有出现我们想要的 node_modules 的数据
带着疑问, 我们需要重新思考下:首先 node_modules 的内容被 babel 编译了吗?如果是编译了那 istanul 对这个对这个文件做插桩了吗? 我们需要先确定这两点。
首先我们先确认我们的 babel 的配置是正确的,即确实有指定 node_modules 也加入到编译中。
webpack.config
{
test: [/\.js$/, /\.tsx?$/],
use: ['babel-loader'],
include: [/src/]
},
{
test: [/\.js$/, /\.tsx?$/],
use: ['babel-loader'],
include: [ /node_modules\/@cvte\/seewoedu-video\/dist\//]
},
从这里看至少是对的,但是怎么确定文件确实是被 babel 以及 istanbul 处理到呢?
我们还是要从源码入手做一个控制台的打印来看看。
async function loader(source, inputSourceMap, overrides) {
const filename = this.resourcePath;
// 增加一个打印
console.log("babel loader", filename);
let loaderOptions = loaderUtils.getOptions(this) || {};
validateOptions(schema, loaderOptions, {
name: "Babel loader",
});
...
我们知道 webpack 打包会经过 babel-loader 所以我们先在这里打印下看下是否确实经过了处理。
export default declare(api => {
api.assertVersion(7)
const shouldSkip = makeShouldSkip()
const t = api.types
return {
visitor: {
Program: {
enter (path) {
this.__dv__ = null
this.nycConfig = findConfig(this.opts)
const realPath = getRealpath(this.file.opts.filename)
// 增加一个打印
console.log('istanbul, ', this.file.opts.filename)
if (shouldSkip(realPath, this.nycConfig)) {
return
}
....
我们重新看下打包过程的打印信息
从上述的信息来看, 我们的源码进入了 babel-loader, 并且也被 istanbul 处理了,但是 node_modules 确只是被 babel-loader 处理,但是并没有到 istanbul 中。
所以这里肯定是哪里的配置不正确导致的。
找了很多 istanbul 的配置都没有什么效果,知道搜索到了这个 issue 的回答 babel 7 can't compile in node_modules
http://babeljs.io/docs/en/config-files#6x-vs-7x-babelrc-loading 这里有了比较清晰的答案了。
Given that, it may be more desirable to rename the .babelrc to be a project-wide "babel.config.json". As mentioned in the project-wide section above, this may then require explicitly setting "configFile" since Babel will not find the config file if the working directory isn't correct.
所以我们只需要讲 babelrc 文件修改为 babel-config.json 即可。
我们重新来尝试下看下打包的打印
从这里看确实 node_modules 的处理已经进入到了 istanbul 处理的范围内了。
以上就是我们在调研跟实施代码覆盖率的时候遇到的一些问题跟分析的过程。由于前端代码覆盖率这块还刚起步,如果还有其他问题 我会继续更新这篇文章,解决其他同学在前端代码覆盖率上遇到的问题。
更新于 2020.06.18
同事在调研新的前端项目接入的时候跟我反馈到:打包 test 环境后能够正常插桩 (这个是现象,因为我们的配置 istanbul 的插件只是在 test 环境下才生效),然后再重新打包 pro 环境的时候,打包出来的也仍然含有插桩的内容。
问题很诡异,于是我这边也重新做了个验证,先打了 pro 环境的包发现没有插桩,很正常。重新切换到 test 环境进行打包结果竟然没有插桩。这个就真的很奇怪了。我一度怀疑是 package script 设置环境变量的问题
"test": "cross-env BABEL_ENV=test webpack --config ./webpack.config/webpack.prod.istanbul.js --progress",
这个是我们的 package.json 的 test 打包的脚本
尝试了修改 BABEL_ENV=test
为NODE_ENV=test
或者说BABEL_ENV=test webpack --config
多加一个 BABEL_ENV=test && webpack --config
结果还是一样。
这个时候只能使出终极大招了,我们默认 babelrc 插件的配置是这样子的。
"env": {
"test": {
"plugins": ["istanbul"]
}
}
直接去掉 env 的现在,让默认打包都带上插桩
{
"presets": [
"@babel/preset-react",
...
],
"plugins": [
"react-hot-loader/babel",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-modules-commonjs",
"istanbul"
...
就不信还插桩不成功。
结果真的是没成功。这个真的是没理由了。
突然想起来在上边的文章里面我们有提到可以在 istannbul 的源码里面打个日志看看是否有进入到 instabul 中。
export default declare(api => {
api.assertVersion(7)
const shouldSkip = makeShouldSkip()
const t = api.types
return {
visitor: {
Program: {
enter (path) {
this.__dv__ = null
this.nycConfig = findConfig(this.opts)
const realPath = getRealpath(this.file.opts.filename)
// 增加一个打印
console.log('istanbul, ', this.file.opts.filename)
if (shouldSkip(realPath, this.nycConfig)) {
return
}
....
还是之前打日志的地方,我们重新做了一次编译。结果可想而知,日志没有打印出来。
问题分析到这里了,就要考虑下这个可能是 js loader 在其中有问题了。
现在我们要回到 webpack 中去看下针对 js/jsx 等 loader 处理的过程
{
test: /\.tsx?$/,
include: /src/,
use: [{ loader: 'cache-loader' }, { loader: 'happypack/loader?id=babel' }, { loader: 'ts-loader' }]
},
我们看到的是这样子的内容,因为 loader 的处理过程是从右到左,所以依次是 ts-loader, babel-loader 再来是 cache-loader.
问题来了,这里多了多了一个 cache-loader 的处理。
所以现在最大可疑的就是它了。 然后最后跟开发讨论以及尝试去掉 cache-loader 以后,发现插桩真的就成功了。
至于 cache-loader 的作用呢,它会将编译后的内容缓存在 node_modules/.cache/loader-cache 中,都是以 json 的形式存在,所以一旦文件没有变化,cache-loader 可能就直接用上一次编译的结果使用了,这也是为什么我跟我的同事都出现那么诡异问题的原因了,主要是看第一次是否插桩了。
更新于 2020.06.29
预研新项目的时候又遇到问题了,这次主要卡壳的地方并不是打包插桩上,而是在插桩文件的替换上。
在上文的时候我们有提到过,我们会将浏览器请求的未插桩的 js 文件替换成插桩后的 js 文件。然后我们遇到了这样子的问题。
如上图所示,当我们请求 base.xxx.js 的文件后,会被重定向为 base_istanbul.xxx.js
但是每个 js 的文件都是请求失败的,chrome 的错误信息提示为: (failed) net::ERR_FAILED。 这个问题我们是完全没有预料到的,因为重定向这块基本没啥大的逻辑,同时这个 js 的重定向在另外一个项目上是完全没有问题的。
根据这个错误提示信息, google 了一番。 What can cause Chrome to give an net::ERR_FAILED on cached content against a server on localhost? 网上的答案大致都说到了跨域的问题,这个时候我们才重新把目光放回到浏览器控制台的报错信息中
类似于这样子。那跨域的问题就比较好解决了,我们重新改了下覆盖率后台的接口,让它允许跨域,这个问题就解决了。
但是问题结束了吗? 还没有,还记得我们刚才说过了,在另外一个项目的时候 ,我们这块是不需要允许跨域也是可以的,为什么现在到了这个项目就有这个问题呢?
所以还是要看下这个项目首页的静态资源加载是怎么写的了。
<script type="text/javascript" src="https://xxxx.xxx.com/xxx-web-live/statics/redux.dca712d2fa02c53d36087cd0ffe6917e.js" crossorigin="anonymous"></script>
我们看到了类似于这样子的内容,但是有个字段很陌生。 crossorigin="anonymous"
光看这个名字 就感觉是我们要找的内容了。
所以我们重新百度科普了下这个字段。
HTML5 新的规定,是可以允许本地获取到跨域脚本的错误信息,但有两个条件:一是跨域脚本的服务器必须通过 Access-Controll-Allow-Origin 头信息允许当前域名可以获取错误信息,二是当前域名的 script 标签也必须指明 src 属性指定的地址是支持跨域的地址,也就是 crossorigin 属性。
anonymous:如果使用这个值的话就会在请求中的 header 中的带上 Origin 属性,但请求不会带上 cookie 和其他的一些认证信息。
use-credentials:这个就同时会在跨域请求中带上 cookie 和其他的一些认证信息。
在使用这两个值时都需要 server 端在 response 的 header 中带上 Access-Control-Allow-Credentials 属性。
可以通过 server 的配置文件来开启这个属性:server 开启 Access-Control-Allow-Credentials
所以罪魁祸首实际上就是这个 crossOrigin 属性了。