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

saii · 2020年06月06日 · 最后由 Agenis 回复于 2024年08月28日 · 24451 次阅读
本帖已被设为精华帖!

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

前端代码覆盖率方案

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

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

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

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

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

  • 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 库, 所以注定这个问题会遇到了。问题如下图所示

相关的问题在 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

babel-loader

首先针对这个问题,我们需要一步步的去看,我们首先要确定的一点是为什么 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 到底是什么呢?我们尝试用断点去看看。

这个确实就是我们在 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 中知道了。

关键的信息应该就是在 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

bable-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 所认可,是这样子吗? 我们又要看下代码了。

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

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

从上述的信息来看, 我们的源码进入了 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=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 文件。然后我们遇到了这样子的问题。

如上图所示,当我们请求 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 属性。

crossorigin 属性:

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

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

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

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

非常精彩哈

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

持续关注,感谢分享

真不错,给了思路和启发

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

saii #7 · 2020年06月09日 Author
itimetime 回复

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

saii #8 · 2020年06月09日 Author
itimetime 回复

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

saii 回复

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

saii #10 · 2020年06月09日 Author
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 · 2020年06月11日 Author
itimetime 回复

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

仅楼主可见

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

saii #15 · 2020年07月10日 Author
财宝 回复

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

好文,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 · 2020年08月31日 Author
zhou 回复

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

在路上 ReactNative 多端代码覆盖率调研及实践 中提及了此贴 10月13日 17:44
22楼 已删除
saii #23 · 2020年10月13日 Author

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

匿名 · #24 · 2020年10月13日
仅楼主可见
25楼 已删除
匿名 · #26 · 2020年10月13日
仅楼主可见
saii #27 · 2020年10月14日 Author

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

匿名 · #28 · 2020年10月15日
仅楼主可见
仅楼主可见
saii #30 · 2020年10月29日 Author
Kepler-ZZ 回复

你微信多少? 我没怎么用 istanbul-middleware 不过可以一起看看

仅楼主可见

您好,请问有遇到过 js 代码中使用了装饰器,导致代码无法插桩的情况吗?

saii #33 · 2020年11月06日 Author
Liy 回复


加个 decorators 可以解决的。 你是用的 nyc 插桩吧

saii 回复

是的,使用的 nyc 插桩。目前使用的 nyc 版本是 14.1.1。在 “parser-plugins” 中加入 “decorators” 后,不使用装饰器的代码也无法插桩了,下面是我的配置

"nyc": {
  "extension": [
    ".js",
    ".jsx"
  ],
  "include": [
    "client/pages/**"
  ],
  "parser-plugins": [
    "decorators"
  ]
}
saii #35 · 2020年11月06日 Author
Liy 回复

你不能这么搞的 你这么处理的话就相当于 你的 babel 的 parser 只是用的装饰器那个插件的。复制下我这个吧

"parser-plugins": [
        "typescript",
        "jsx",
        "asyncGenerators",
        "bigInt",
        "classProperties",
        "classPrivateProperties",
        "dynamicImport",
        "importMeta",
        "objectRestSpread",
        "optionalCatchBinding",
        "decorators"
    ]
saii 回复

复制了您的配置,不过还是无法插桩,全部代码都没有插桩。我这边使用的 babel6,nyc@14.1.1,项目中使用的.jsx。

saii #37 · 2020年11月06日 Author
Liy 回复

或者你能不能提供一个单独的文件处理 针对他插桩试试看 ,如果不行能方便去掉一些敏感的代码 拿出一个文件 我们来看下?

saii 回复

好的,我准备一下。业务有些忙,我先按现在的思路看一下,如果解决不了再联系您。

saii #39 · 2020年11月10日 Author
Liy 回复

抱歉 上次我跟你说错了,

"parser-plugins": [
        "typescript",
        "jsx",
        "asyncGenerators",
        "bigInt",
        "classProperties",
        "classPrivateProperties",
        "dynamicImport",
        "importMeta",
        "objectRestSpread",
        "optionalCatchBinding",
        "decorators-legacy"
    ]

应该是这个才对,用 decorators-legacy

saii 回复

哇,使用这个成功了,感谢🙏

仅楼主可见
仅楼主可见
saii #43 · 2020年12月08日 Author
best 回复

你的多少 我加你 可以交流的 哈。

仅楼主可见
匿名 · #45 · 2021年01月26日
仅楼主可见

@rocky 你微信多少 可以聊下,

大佬,有没有后端代码覆盖率的工具推荐。

saii #48 · 2021年01月28日 Author

jacoco 就很通用了呀,我们这边都是用它

匿名 · #49 · 2021年01月29日
仅楼主可见


请问,有遇到过这样的问题吗

51楼 已删除
saii #52 · 2021年04月21日 Author
IMT 回复


你确认下你的目录下是否有这个文件?

saii 回复

我发现是这样的,就是覆盖率的文件路径的 key 值如果都是 \ 这样的,就没问题。但是一发到 Linux 机器上,路径变成 / 这样的时候,就找不到文件了。我去源码打印出来有个 fullname,好像是处理这个路径的。然后是在/ 这样的路径后面 处理成了 \ ,导致打开文件失败。感觉像!

然后再点击去查看 file 详情,就报这个错误。

IMT 回复

你微信多少,我加你看下 😀

仅楼主可见

楼主咨询您个问题:
“ webpack 中去看下针对 js/jsx 等 loader 处理的过程” 这块儿,设置是开发自己写的吗?在 webpack 这个包中没有找到呢

saii #57 · 2021年04月26日 Author
zhanglimin 回复

感觉有点没到点上,不是在 webpack 的包里面,是在开发的项目代码中,一般都有一个 webpack.prod.config 这样子的文件,前提是这个项目是一个用 webpack 打包编译的,然后你就能看到类似这样子的配置:

module: {
        rules: [
            {
                use: ['happypack/loader?id=babel'],
                test: /\.js$/,
                exclude: path.resolve(__dirname, 'node_modules'),
                include: path.resolve(__dirname, 'frontend'),
            },
      ]
}

这个只是举个栗子哈,

saii 关于 nodejs 覆盖率的探索 中提及了此贴 08月08日 23:41

你好,目前在用 istanbul-middleware 做服务端,但是查看覆盖率页面的渲染样式显示有问题,比对了官网 demo 的 sources,发现是少了一张图片,这里需要怎么修复

官网 demo 中有一张图片:

服务端相关代码:

saii #61 · 2021年09月09日 Author
echoYan 回复

信息量有点少,首先我个人觉得跟那个图片加载应该没有太多的关系。因为我用 istanbul-middleware 比较少,所以没有遇到过这样子的问题
我建议你这么尝试分析下看看

  1. 重点对比下 css 样式的内容的差异 (因为这个样式问题,按理就是 css 的加载问题才对)
  2. 网络请求加载的文件是否都一样的, 如果还没解决 可以加个微信 我们私聊下
saii 回复

和官网的 demo 刚比对了加载的两个 css 文件,内容都一样。然后覆盖率报告下载下来在本地打开是正常的

saii #63 · 2021年09月10日 Author
echoYan 回复

加个微信私聊下?

saii 回复

好的,谢谢

saii 回复

感谢@zsx10110指导,已解决该问题。
这里问题主要是在 istanbul-middleware 服务端代码中,设置跨域里面,把 css 文件当成了 json 格式的文件。注释掉该行就可以

楼主,你好,请问一下前端增量的覆盖率有吗

saii 回复

楼主,你好,这篇文章我看过了,很感谢,但是还是有一个疑问,我修改完 coverage,增量的报告要怎么展示,要自己画 UI 来实现吗?

saii #69 · 2022年12月29日 Author
zz123 回复

是的 我们都是自己做前端的覆盖率结果展示的,这块我没有详细研究去修改覆盖率源码的页面,

saii 回复

你好,我按照您说的操作了,但是增量覆盖率的结果为 100%,我不知道哪里出现的错误,还有我用的是 istanbul-middleware 的 download 接口生成的报告,使用的是报告里面的 coverage.json 文件

saii 回复

大佬,我可以加你微信,详聊一下吗

saii 回复

大佬,能否加个微信,我们公司也在弄这个插桩的东西,网上搜的东西太少了。

群主能否加个微信,指导一下,谢谢

群众可以加个微信不,了解个问题

楼主你好,我在实践过程中,报告生成部分是用 istanbul-middleware 做的,出现了染色位置不准确的现象,想请教楼主,您实践过程中用的这个包 Cypress 的 code coverage,看起来染色是准确的,用这个包生成报告这部分可以展开分享么,诚心求教

我 react 工程,采用的 webpack,esbulid-loader,没有用 babel,然后 cypress 做单测,想出覆盖率报告,一直没插桩成功,想问问楼主有啥建议么

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册