在講之前得說下 前端覆蓋率的水真的是很深的,其實到目前為止還有很多未解之謎,由于對babel的編譯以及ast了解的不是很多。所以確實分析問題起來很困難。
前端代碼覆蓋率方案
關(guān)于前端代碼覆蓋率還不了解這塊內(nèi)容的同學(xué)們,可以參考下一下幾篇文章,這里就不做贅述了。
基于 Istanbul 優(yōu)雅地搭建前端 JS 覆蓋率平臺
前端精準(zhǔn)測試探索:覆蓋率實時統(tǒng)計工具
了解了上述兩篇文章以后,你應(yīng)該對前端的代碼覆蓋率有一定的了解了。那下來說下具體的方案吧。下面就是我們前端代碼覆蓋率的具體方案了(PS: 畫的很潦草,不太專業(yè),大家請將就下)
這里涉及到幾個關(guān)鍵的部分要說明下:
-
react/vue 項目打包插樁:
由于我們公司的項目發(fā)布的流程是直接使用測試通過的鏡像直接上線到正式環(huán)境去,所以如果在測試環(huán)境部署的代碼直接是打包后插樁的內(nèi)容,然后再上到正式環(huán)境話這個是個很糟糕的事情。 所以我們做了一個處理,前端在編譯打包的時候需要打出兩份內(nèi)容: 一個是插樁后的js文件(用于測試驗證),另外一個是未插樁的js文件(用于正式上線)。插樁后的js會上傳到cdn或者說我們自己的一個私有服務(wù)上做保留。
-
chrome插件:
chrome插件在這里起到了兩個作用。
- 將我們的原本的為插樁的js文件請求替換成插樁后的js文件。
- 進行注入定時上報覆蓋率的數(shù)據(jù)的js腳本。
不過目前 chrome插件這個方案可能要被我們棄用掉了,因為chrome插件本身只能局限于chrome瀏覽器上,而我們現(xiàn)在更多的會有一些h5頁面的情況,這些他就不能夠滿足,所以我們會將這部分的邏輯直接轉(zhuǎn)到fiddler中,由fiddler來完成這塊的工作。這樣子就能滿足移動端的測試覆蓋率的問題了。
-
覆蓋率后臺(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庫, 所以注定這個問題會遇到了。問題如下圖所示

相關(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這塊做了什么事情。
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的吧。
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中了。我們看看這塊的有一個邏輯
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)可,是這樣子嗎? 我們又要看下代碼了。
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_modulesWe 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 followingnycconfig will preventnode_modulesfrom 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 atestfolder 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
}
根據(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處理到呢?
我們還是要從源碼入手做一個控制臺的打印來看看。
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)過了處理。
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é)在前端代碼覆蓋率上遇到的問題。