前端代码覆盖率遇到问题及解决(一)

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

前端代码覆盖率方案

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

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

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

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

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

  1. react/vue 项目打包插桩:

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

  2. chrome插件:

    chrome插件在这里起到了两个作用。

    • 将我们的原本的为插桩的js文件请求替换成插桩后的js文件。
    • 进行注入定时上报覆盖率的数据的js脚本。

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

  3. 覆盖率后台(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,
  });
}

前端代码覆盖率遇到问题及解决(一)

所以如果单独用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
前端代码覆盖率遇到问题及解决(一)
前端代码覆盖率遇到问题及解决(一)
前端代码覆盖率遇到问题及解决(一)
前端代码覆盖率遇到问题及解决(一)

从上图的几个对比其实就已经能够知道为什么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处理的范围内了。

总结

以上就是我们在调研跟实施代码覆盖率的时候遇到的一些问题跟分析的过程。由于前端代码覆盖率这块还刚起步,如果还有其他问题 我会继续更新这篇文章,解决其他同学在前端代码覆盖率上遇到的问题。

上一篇:理解ls -F1


下一篇:IOS Widget(5):小组件刷新机制