测试覆盖率 聊聊前端代码覆盖率 (长文慎入)

saii · June 06, 2020 · Last by asfrank replied at October 15, 2020 · 16294 hits
本帖已被设为精华帖!

在讲之前得说下 前端覆盖率的水真的是很深的,其实到目前为止还有很多未解之谜,由于对babel的编译以及ast了解的不是很多。所以确实分析问题起来很困难。

前端代码覆盖率方案

关于前端代码覆盖率还不了解这块内容的同学们,可以参考下以下几篇文章,这里就不做赘述了。

基于 Istanbul 优雅地搭建前端 JS 覆盖率平台

前端精准测试探索:覆盖率实时统计工具

了解了上述两篇文章以后,你应该对前端的代码覆盖率有一定的了解了。那下来说下具体的方案吧。下面就是我们前端代码覆盖率的具体方案了(PS: 画的很潦草,不太专业,大家请将就下)

image

这里涉及到几个关键的部分要说明下:

  • react/vue 项目打包插桩:

由于我们公司的项目发布的流程是直接使用测试通过的镜像直接上线到正式环境去,所以如果在测试环境部署的代码直接是打包后插桩的内容,然后再上到正式环境话这个是个很糟糕的事情。 所以我们做了一个处理,前端在编译打包的时候需要打出两份内容: 一个是插桩后的js文件(用于测试验证),另外一个是未插桩的js文件(用于正式上线)。插桩后的js会上传到cdn或者说我们自己的一个私有服务上做保留。

  • chrome插件: chrome插件在这里起到了两个作用。
    • 将我们的原本的为插桩的js文件请求替换成插桩后的js文件。
    • 进行注入定时上报覆盖率的数据的js脚本。

不过目前 chrome插件这个方案可能要被我们弃用掉了,因为chrome插件本身只能局限于chrome浏览器上,而我们现在更多的会有一些h5页面的情况,这些他就不能够满足,所以我们会将这部分的逻辑直接转到fiddler中,由fiddler来完成这块的工作。这样子就能满足移动端的测试覆盖率的问题了。

  • 覆盖率后台(node)

    这块的实现我们没有直接使用 istanbul-middleware 这个方案,因为对于一个长达5年没有维护更新的项目,我还是持有一定的怀疑态度(当然可能项目本身很优秀,完全没有问题)。所以我们把目光放到了 nyc 上,不过nyc更多是结合单元测试框架:jest使用或者说直接通过命令行的方式进行调用。没有太多有涉及到如何使用它的api的方法上。幸运的是我们又找到了另外一个项目 Cypress 的 code-coverage, (这里做个小广告, cypress是一个很优秀的前端自动化工具)。 在这个项目里你可以看到它就是通过调用nyc的api进行生成覆盖率的测试报告的。 所以这块我们毫不犹豫的选择了它做了一定的二次开发了。

问题

理想总是很美好的,我们在一个简单的demo项目上实验了下,基本没啥问题。但是进入到真正的项目的时候,发现真的是困难重重。

问题1. babel的升级

这个问题我发现在其他文章里面都很少提到。因为使用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文件的修改等等,这里就不说了。

问题2. istanbul与babel-plugin-import 冲突

babel-plugin-import是一个antd ui库的按需加载的插件, 因为antd的使用非常的广泛, 基本上我们的前端项目都会使用到这个ui库, 所以注定这个问题会遇到了。问题如下图所示

image
相关的问题在istanbul issue中也可以找到 Does not work with babel-plugin-import 文中提到的解决方案有两种:

1.直接修改babel-plugin-import的源码。

2.修改自己引用ui库的方式。

上述两种都比较麻烦,然而我们在机缘巧合下发现 可以通过在babelrc中引入 @babel/plugin-transform-modules-commonjs 也可以解决这个问题。不过原因暂时还不清楚(前端的打包真的太深奥了)

可以看下 基于 Istanbul 优雅地搭建前端 JS 覆盖率平台 评论区的内容

PS: 以下部分涉及到真正去实践过程的问题分析,如果没有动手做过这块内容的同学可以忽略

问题3. 为什么通过babel-loader + ts-loader 生成的覆盖率数据(windows.coverage) 中带有inputSouceMap,但是直接通过babel-loader 生成的覆盖率数据就不带有

我们先看下coverage数据的对比情况

ts-loader + babel-loader

image

babel-loader

image

首先针对这个问题,我们需要一步步的去看,我们首先要确定的一点是为什么babel-loader + ts-loader 的方式能够出现inputSourceMap的内容,而babel-loader却没有。 这两者主要的差别实际上就是在多了一个ts-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到底是什么呢?我们尝试用断点去看看。
image

这个确实就是我们在coverage数据里面看到的情况

所以顺着这个流程 ts-loader讲数据传递给到了babel-loader, babel-loader则将这个数据给到了istanbul。

既然讲到了istanbul 我们来看下istanbul这块是怎么去获取inputSouceMap的吧。

babel-istanbul

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中知道了。
image

关键的信息应该就是在normalize-file中了。我们看看这块的有一个逻辑

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,
});
}

image
所以如果单独用babel-loader的情况 是没有办法拿到inputSouceMap的

以上就是大概解释了为什么ts-loader+babel-loader是由inputSourceMap 然后单独的babel-loader是没有的。

问题4. 通过ts-loader + babel-loader 生成的覆盖率数据与bable-loader 单独处理生成的数据 在statement等字段上数据有一定的差异,这个差异导致报告中部分语句覆盖会有所区别。
ts-loader + bable-loader

image

image

bable-loader

image

image

至少从这个截图来了ts-loader + babel-loader的结果更正确点才对。

所以我们现在需要确认的一点是为什么coverage中的statement会有差别。

其实这里很容易有一个猜测的 ts-loader处理后的内容其实已经不是真正的源码内容了,已经变化了才对。所以我们还是需要再去看下 normalize-file

因为我们注意到它的参数里面其实就包含有ast以及相应的code。 所以一样的我们继续断点到这个地方看下数据的情况

ts-loader + babel-loader 的code 及 ast

image

image

对于单独的babel-loader的情况
image

image

从上图的几个对比其实就已经能够知道为什么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所认可,是这样子吗? 我们又要看下代码了。

visitor.js

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语句没有显示被覆盖率的原因了

问题5. istanbul的插桩为什么不能够对node_modules中的代码进行插桩?

其实不是说不能主要是这里遇到了一些坑, 我们首先先看下官方的文档的说明

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 following nyc config will prevent node_modules from being added to the exclude rules.
The set of include rules then restrict nyc to only consider instrumenting files found under the lib/ and node_modules/@my-org/ directories.
The exclude rules then prevent nyc instrumenting anything in a test folder and the file node_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处理到呢?

我们还是要从源码入手做一个控制台的打印来看看。

babel-loader

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 所以我们先在这里打印下看下是否确实经过了处理。

babel-plugin-istanbul

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
}
....

我们重新看下打包过程的打印信息
image

从上述的信息来看, 我们的源码进入了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即可。
我们重新来尝试下看下打包的打印

image

从这里看确实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=testNODE_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中。

babel-plugin-istanbul

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文件。然后我们遇到了这样子的问题。

image

如上图所示,当我们请求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? 网上的答案大致都说到了跨域的问题,这个时候我们才重新把目光放回到浏览器控制台的报错信息中

image

类似于这样子。那跨域的问题就比较好解决了,我们重新改了下覆盖率后台的接口,让它允许跨域,这个问题就解决了。

但是问题结束了吗? 还没有,还记得我们刚才说过了,在另外一个项目的时候 ,我们这块是不需要允许跨域也是可以的,为什么现在到了这个项目就有这个问题呢?

所以还是要看下这个项目首页的静态资源加载是怎么写的了。

<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 属性。

crossorigin属性:

anonymous:如果使用这个值的话就会在请求中的header中的带上Origin属性,但请求不会带上cookie和其他的一些认证信息。

use-credentials:这个就同时会在跨域请求中带上cookie和其他的一些认证信息。
在使用这两个值时都需要server端在response的header中带上Access-Control-Allow-Credentials属性。
可以通过server的配置文件来开启这个属性:server开启Access-Control-Allow-Credentials

所以罪魁祸首实际上就是这个crossOrigin属性了。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 25 条回复 时间 点赞
simple 将本帖设为了精华贴 06 Jun 23:43

非常精彩哈

老师写的很赞啊,现在我公司不要求这个 但是我得学会

持续关注,感谢分享

真不错,给了思路和启发

大佬好,最近公司也要弄这个,能否告知下node端怎么插桩吗

saii #7 · June 09, 2020 作者
itimetime 回复

我们这边node端只是做请求转发到后台的作用而已,基本没有实现什么业务逻辑,所以node端我们是没有插桩的。这块我了解的不多。可能需要你去到nyc官网去看下,理论上应该是可以的。

saii #8 · June 09, 2020 作者
itimetime 回复

instrument-backend-code node端的插桩的话 参考下这里

saii 回复

那大佬前端插桩只需要修改.babelrc文件就好吗,然后就可以自动插桩吗

saii #10 · June 09, 2020 作者
itimetime 回复

如果说你的项目是babel,并且版本是7的话 那确实只需要修改babelrc就可以了

saii 回复

感谢大佬回复,不过我还是不知道怎么插桩,希望大佬能指点下。
就比如这个大佬之前回复别人举例的项目https://github.com/duxianwei520/react,以下是步骤,麻烦大佬看看。

  1. git clone https://github.com/duxianwei520/react 安装依赖库。
  2. npm npm install --save-dev babel-plugin-istanbul
  3. 修改 babelrc文件,添加Istanbul和"@babel/plugin-transform-modules-commonjs", 4,最后 npm run build。 这样算是插桩完成了吗,我看打包的过程中,并没有Istanbul出现,没有装全相关库还是步骤不对,望大佬指点。
saii #12 · June 11, 2020 作者
itimetime 回复

你在build的过程中有没有报错(记得还要安装npm install @babel/plugin-transform-modules-commonjs),如果没有其实就是没问题的,然后你要验证这个插桩是否成功的话直接打开对应的页面,然后F12, 控制台输入window.coverage 看看是否有内容,当然更便捷的就是直接打开打包后的js文件,查看里面有没有cov,branchMap的字样。

Author only

楼主,有兴趣来酷家乐吗?,我们对前端测试人才求贤如渴,如果有兴趣,欢迎随时联系我

saii #15 · July 10, 2020 作者
财宝 回复

哈哈,暂时没有考虑这个,不过很感谢😄

好文,mark给前端看看

好文,附送各框架的覆盖率配置案例

https://github.com/app-bootstrap/web-app-bootstrap


更新于2020.07.20

前端代码覆盖率又接入了新的项目,但是这次的前端用到了一个新的框架 easywebpack, 不过好在官方的文档是相当的详尽,所以在babel升级基本没有遇到什么大的问题。

原以为一切的会比较顺利的进行, 但是测试的同事反馈说出现几个文件的覆盖率数据明显不正确,并且那几个文件的数据都是一样的。我们来看下结果数据
image

这里的四个覆盖率数据竟然是完全一样的,我们再详细看下某个文件的详细覆盖率数据

image

很明显 从11行的地方我们发现这里的代码与覆盖率的数据其实是完全对应不上的,我们在11行处根本就不存在有IF的代码逻辑。

那现在的问题来了为什么会出现这种情况呢? 首先我们的覆盖率数据是有经过多次合并的,所以首先我们可能要怀疑是我们的覆盖率数据合并导致的问题。那我们就先验证下原生的网页上的覆盖率数据是否有问题吧

image

这里是从浏览器的控制台打印出来的结果,从上述的一些数据我们可以看出来,在11的时候,确实有一个if的语句,所以说明了一个问题 就是原本的文件覆盖率数据就已经错误了。

那问题到底出在哪里呢?这里还是需要从打包的地方去分析这个问题了。

以下是webpack的部分截图

image

这里分别了客户端渲染以及服务端渲染的打包,所以我们要重点看下loader.js里面做了啥事情。

module.exports = function() {
this.cacheable();
return `
import React from 'react';
import ReactDom from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import Entry from '${this.resourcePath.replace(/\\/g, '\\\\')}';
const state = window.__INITIAL_STATE__;
const render = (App)=>{
ReactDom.hydrate(EASY_ENV_IS_DEV ? <AppContainer><App {...state} /></AppContainer> : <App {...state} />, document.getElementById('app'));
};

if (EASY_ENV_IS_DEV && module.hot) {
module.hot.accept('${this.resourcePath.replace(/\\/g, '\\\\')}', () => { render(Entry) });
}
render(Entry);
`;
};

针对要处理的js文件,看起来这里的loader又重新进行render了一次,而且如果细心一点 我们可能注意到一个很关键的因素,这里从return方法往下的第11行 刚好是 if (EASY_ENV_IS_DEV && module.hot) { 一个if语句, 所以其实我们所有错误的js的覆盖率都被这段的逻辑所影响到了。那我们原本的jsx的文件有没有被插桩呢?所以还是需要看下插桩后的文件到底长什么样。

针对campCourse.jsx的打包文件

image

我们在6w多行的时候可以看到有这样的一个方法 针对的就是该文件的覆盖率数据

然后我们再观察
image

在11w多行的时候, 也出现了同样的覆盖率数据,而且该数据与我们从浏览器读到的数据是一致的也就是错误的数据。到这里应该就能有一定的总结的,也就是说源码文件其实是有被插桩的,只是说它的数据最终被另外一个相同对象给覆盖了导致我们拿到了错误的数据。

问题到了这里就要想着怎么解决了,其实istanbul提供另一些方式来过滤掉一些你不想插桩的代码。具体可以看 ignoring-code-for-coverage

所以我们需要做的是忽略到这个文件的插桩,不过这个ignore添加在哪里还是有一定的技巧的 我们来看下最后的改动代码吧。

module.exports = function() {
this.cacheable();
return `/* istanbul ignore file */
import React from 'react';
import ReactDom from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import Entry from '${this.resourcePath.replace(/\\/g, '\\\\')}';
const state = window.__INITIAL_STATE__;
const render = (App)=>{
ReactDom.hydrate(EASY_ENV_IS_DEV ? <AppContainer><App {...state} /></AppContainer> : <App {...state} />, document.getElementById('app'));
};

if (EASY_ENV_IS_DEV && module.hot) {
module.hot.accept('${this.resourcePath.replace(/\\/g, '\\\\')}', () => { render(Entry) });
}
render(Entry);
`;
};

我们需要将/* istanbul ignore file */的 内容添加在return 语句的后面,即第一行才行,如此下来的代码都不会被插桩处理了。

请问,Cypress的code coverage,能否在手工测试后统计覆盖率呢?istanbul已经弄好了,但是看Cypress好像依赖于它的自动化后才能生成覆盖率?这边现在没条件写前端的自动化。只想在手工测试后能看一下覆盖率,另外,楼主有做过前端的增量覆盖率嘛?

saii #20 · August 31, 2020 作者
zhou 回复

cypress那块更多的是基于自动化的方式的,但是它里面的实现逻辑是可以参考的。
增量这块我们其实也做了,但是我们目前只是做到了基于文件的情况,就是这个文件被修改到就要求被覆盖,还没有继续完善到基于行或者方法的覆盖率这种。

zailushang ReactNative 多端代码覆盖率调研及实践 中提及了此贴 13 Oct 17:44

请问lz遇到过覆盖率结果中,有函数被识别为匿名的情况吗?应该如何解决呢?(用的是nyc进行插桩)

newFunction(name, decl, loc) {
const f = this.meta.last.f;
name = name || '(anonymous_' + f + ')';
this.data.fnMap[f] = {
name,
decl: cloneLocation(decl),
loc: cloneLocation(loc),
// DEPRECATED: some legacy reports require this info.
line: loc && loc.start.line
};
this.data.f[f] = 0;
this.meta.last.f += 1;
return f;
}

查看了下istanbul-lib-instrument的源码,是name取不到的时候,会在函数名前添加“anonymous_”

saii #23 · October 13, 2020 作者
asfrank 回复

没有遇到过,不过可以分享下那个函数怎么样的吗,我这边也试试看?

Author only
25Floor has been deleted
Author only
saii #27 · October 14, 2020 作者
asfrank 回复

但是你这个匿名函数会有啥影响吗?或者方便的话 加个微信聊下?

Author only
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up