前端代碼覆蓋率遇到問題及總結(jié)(一)

在講之前得說下 前端覆蓋率的水真的是很深的,其實到目前為止還有很多未解之謎,由于對babel的編譯以及ast了解的不是很多。所以確實分析問題起來很困難。

前端代碼覆蓋率方案

關(guān)于前端代碼覆蓋率還不了解這塊內(nèi)容的同學(xué)們,可以參考下一下幾篇文章,這里就不做贅述了。

基于 Istanbul 優(yōu)雅地搭建前端 JS 覆蓋率平臺

前端精準(zhǔn)測試探索:覆蓋率實時統(tǒng)計工具

了解了上述兩篇文章以后,你應(yīng)該對前端的代碼覆蓋率有一定的了解了。那下來說下具體的方案吧。下面就是我們前端代碼覆蓋率的具體方案了(PS: 畫的很潦草,不太專業(yè),大家請將就下)


在這里插入圖片描述

這里涉及到幾個關(guān)鍵的部分要說明下:

  1. react/vue 項目打包插樁:

    由于我們公司的項目發(fā)布的流程是直接使用測試通過的鏡像直接上線到正式環(huán)境去,所以如果在測試環(huán)境部署的代碼直接是打包后插樁的內(nèi)容,然后再上到正式環(huán)境話這個是個很糟糕的事情。 所以我們做了一個處理,前端在編譯打包的時候需要打出兩份內(nèi)容: 一個是插樁后的js文件(用于測試驗證),另外一個是未插樁的js文件(用于正式上線)。插樁后的js會上傳到cdn或者說我們自己的一個私有服務(wù)上做保留。

  2. chrome插件:

    chrome插件在這里起到了兩個作用。

    • 將我們的原本的為插樁的js文件請求替換成插樁后的js文件。
    • 進行注入定時上報覆蓋率的數(shù)據(jù)的js腳本。

    不過目前 chrome插件這個方案可能要被我們棄用掉了,因為chrome插件本身只能局限于chrome瀏覽器上,而我們現(xiàn)在更多的會有一些h5頁面的情況,這些他就不能夠滿足,所以我們會將這部分的邏輯直接轉(zhuǎn)到fiddler中,由fiddler來完成這塊的工作。這樣子就能滿足移動端的測試覆蓋率的問題了。

  3. 覆蓋率后臺(node)

    這塊的實現(xiàn)我們沒有直接使用 istanbul-middleware 這個方案,因為對于一個長達5年沒有維護更新的項目,我還是持有一定的懷疑態(tài)度(當(dāng)然可能項目本身很優(yōu)秀,完全沒有問題)。所以我們把目光放到了 nyc 上,不過nyc更多是結(jié)合單元測試框架:jest使用或者說直接通過命令行的方式進行調(diào)用。沒有太多有涉及到如何使用它的api的方法上。幸運的是我們又找到了另外一個項目 Cypress 的 code-coverage, 這里做個小廣告, cypress是一個很優(yōu)秀的前端自動化工具。 在這個項目里你可以看到它就是通過調(diào)用nyc的api進行生成覆蓋率的測試報告的。 所以這塊我們毫不猶豫的選擇了它做了一定的二次開發(fā)了。

問題

理想總是很美好的,我們在一個簡單的demo項目上實驗了下,基本沒啥問題。但是進入到真正的項目的時候,發(fā)現(xiàn)真的是困難重重。

問題1. babel的升級

這個問題我發(fā)現(xiàn)在其他文章里面都很少提到。因為使用istanbul(最新的版本)插樁的方案的話需要在babel7的版本進行,所以需要前端的項目做升級才行,而我們大部分的前端的項目都是停留在babel6的版本,這個升級過程就非常的痛苦,尤其前端的項目又用不到了各種的腳手架。(不過痛著痛著就習(xí)慣了,經(jīng)歷過幾次項目的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

當(dāng)然還有babelrc文件的修改等等,這里就不說了。

問題2. istanbul與babel-plugin-import 沖突

babel-plugin-import是一個antd ui庫的按需加載的插件, 因為antd的使用非常的廣泛, 基本上我們的前端項目都會使用到這個ui庫, 所以注定這個問題會遇到了。問題如下圖所示

image

相關(guān)的問題在istanbul issue中也可以找到 Does not work with babel-plugin-import 文中提到的解決方案有兩種:

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

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

上述兩種都比較麻煩,然而我們在機緣巧合下發(fā)現(xiàn) 可以通過在babelrc中引入 @babel/plugin-transform-modules-commonjs 也可以解決這個問題。不過原因暫時還不清楚(前端的打包真的太深奧了)

可以看下 基于 Istanbul 優(yōu)雅地搭建前端 JS 覆蓋率平臺 評論區(qū)的內(nèi)容

PS: 以下部分涉及到真正去實踐過程的問題分析,如果沒有動手做過這塊內(nèi)容的同學(xué)可以忽略

問題3. 為什么通過babel-loader + ts-loader 生成的覆蓋率數(shù)據(jù)(windows.coverage) 中帶有inputSouceMap,但是直接通過babel-loader 生成的覆蓋率數(shù)據(jù)就不帶有

我們先看下coverage數(shù)據(jù)的對比情況

ts-loader + babel-loader
在這里插入圖片描述
babel-loader
在這里插入圖片描述

首先針對這個問題,我們需要一步步的去看,我們首先要確定的一點是為什么babel-loader + ts-loader 的方式能夠出現(xiàn)inputSourceMap的內(nèi)容,而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最后處理后的回調(diào),我們可以看到這里帶了一個sourceMap。 那這個sourceMap到底是什么呢?我們嘗試用斷點去看看。


在這里插入圖片描述

這個確實就是我們在coverage數(shù)據(jù)里面看到的情況

所以順著這個流程 ts-loader講數(shù)據(jù)傳遞給到了babel-loader, babel-loader則將這個數(shù)據(jù)給到了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是有內(nèi)容的情況下 才會進行相應(yīng)的InputSouceMap的賦值操作, 所以coverage數(shù)據(jù)中有否 inputSourceMap都是依賴file的inputMap中的內(nèi)容。
          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)
          }
        }
      }
    }
  }
})

如上述所說的現(xiàn)在對istanbul來說最關(guān)鍵的字段是inputMap。 那我們來看下babel-loader或者說babel里面是否有對inputMap做一個賦值的動作,分別在這兩個倉庫中查了下這個關(guān)鍵字,發(fā)現(xiàn)在babel中知道了。


在這里插入圖片描述

關(guān)鍵的信息應(yīng)該就是在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.
    // 已經(jīng)通過ts-loader處理以后 inputSouceMap是一個object對象了,所以直接做賦值了。
    if (typeof options.inputSourceMap === "object") {
      inputMap = convertSourceMap.fromObject(options.inputSourceMap);
    }

    
    // 這下邊的部分邏輯都是在判斷ast內(nèi)容里面是否有包含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 生成的覆蓋率數(shù)據(jù)與bable-loader 單獨處理生成的數(shù)據(jù) 在statement等字段上數(shù)據(jù)有一定的差異,這個差異導(dǎo)致報告中部分語句覆蓋會有所區(qū)別。
ts-loader + bable-loader
在這里插入圖片描述
在這里插入圖片描述
bable-loader
在這里插入圖片描述

在這里插入圖片描述

至少從這個截圖來了ts-loader + babel-loader的結(jié)果更正確點才對。

所以我們現(xiàn)在需要確認(rèn)的一點是為什么coverage中的statement會有差別。

其實這里很容易有一個猜測的 ts-loader處理后的內(nèi)容其實已經(jīng)不是真正的源碼內(nèi)容了,已經(jīng)變化了才對。所以我們還是需要再去看下 normalize-file

因為我們注意到它的參數(shù)里面其實就包含有ast以及相應(yīng)的code。 所以一樣的我們繼續(xù)斷點到這個地方看下數(shù)據(jù)的情況

ts-loader + babel-loader 的code 及 ast


在這里插入圖片描述

在這里插入圖片描述

在這里插入圖片描述

在這里插入圖片描述

從上圖的幾個對比其實就已經(jīng)能夠知道為什么coverage數(shù)據(jù)的statement的數(shù)組的個數(shù)等都有區(qū)別了。

但是可能又有人會好奇的問題到,那為什么單獨使用ts-loader編譯。import的語句都沒有被計算進去呢,從ats來看,import的語句命名也是被翻譯過來為 ImportDeclaration才對,

這塊呢又要說到istanbul中的code instrument這塊去了,由于我對這塊的理解不深,只是通過斷點的方式做了一些初步的判斷做的一些猜想。

其實如果我們有些人細(xì)心的話就能夠發(fā)現(xiàn) 原本是import的語句。
比如說

import * as React from "react";

經(jīng)過ts-loader轉(zhuǎn)換后,代碼已經(jīng)變成了

var React = require('react');

其實是被從es6轉(zhuǎn)換成了commonjs了。 所以它的ats的轉(zhuǎn)換也從 ImportDeclaration變成了 VariableDeclaration

所以從這個過程可以看出來 VariableDeclaration被識別了出來,但是ImportDeclaration貌似不被intrument所認(rèn)可,是這樣子嗎? 我們又要看下代碼了。

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

所以這里就應(yīng)該是解釋了為什么import語句沒有顯示被覆蓋率的原因了

問題5. istanbul的插樁為什么不能夠?qū)ode_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
}

根據(jù)上述的信息, 我們在package.json中做相應(yīng)的修改。重新進行打包后,coverage的數(shù)據(jù)中并沒有出現(xiàn)我們想要的node_modules的數(shù)據(jù)

帶著疑問, 我們需要重新思考下:首先 node_modules的內(nèi)容被babel編譯了嗎?如果是編譯了那istanul對這個對這個文件做插樁了嗎? 我們需要先確定這兩點。

首先我們先確認(rèn)我們的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打包會經(jīng)過babel-loader 所以我們先在這里打印下看下是否確實經(jīng)過了處理。

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中。

所以這里肯定是哪里的配置不正確導(dǎo)致的。

找了很多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的處理已經(jīng)進入到了istanbul處理的范圍內(nèi)了。

總結(jié)

以上就是我們在調(diào)研跟實施代碼覆蓋率的時候遇到的一些問題跟分析的過程。由于前端代碼覆蓋率這塊還剛起步,如果還有其他問題 我會繼續(xù)更新這篇文章,解決其他同學(xué)在前端代碼覆蓋率上遇到的問題。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容