在講之前得說(shuō)下 前端覆蓋率的水真的是很深的扫皱,其實(shí)到目前為止還有很多未解之謎,由于對(duì)babel的編譯以及ast了解的不是很多叙凡。所以確實(shí)分析問(wèn)題起來(lái)很困難正驻。
前端代碼覆蓋率方案
關(guān)于前端代碼覆蓋率還不了解這塊內(nèi)容的同學(xué)們厌蔽,可以參考下一下幾篇文章牵辣,這里就不做贅述了。
基于 Istanbul 優(yōu)雅地搭建前端 JS 覆蓋率平臺(tái)
前端精準(zhǔn)測(cè)試探索:覆蓋率實(shí)時(shí)統(tǒng)計(jì)工具
了解了上述兩篇文章以后躺枕,你應(yīng)該對(duì)前端的代碼覆蓋率有一定的了解了服猪。那下來(lái)說(shuō)下具體的方案吧。下面就是我們前端代碼覆蓋率的具體方案了(PS: 畫(huà)的很潦草拐云,不太專業(yè)罢猪,大家請(qǐng)將就下)
這里涉及到幾個(gè)關(guān)鍵的部分要說(shuō)明下:
-
react/vue 項(xiàng)目打包插樁:
由于我們公司的項(xiàng)目發(fā)布的流程是直接使用測(cè)試通過(guò)的鏡像直接上線到正式環(huán)境去,所以如果在測(cè)試環(huán)境部署的代碼直接是打包后插樁的內(nèi)容叉瘩,然后再上到正式環(huán)境話這個(gè)是個(gè)很糟糕的事情膳帕。 所以我們做了一個(gè)處理,前端在編譯打包的時(shí)候需要打出兩份內(nèi)容: 一個(gè)是插樁后的js文件(用于測(cè)試驗(yàn)證)薇缅,另外一個(gè)是未插樁的js文件(用于正式上線)危彩。插樁后的js會(huì)上傳到cdn或者說(shuō)我們自己的一個(gè)私有服務(wù)上做保留。
-
chrome插件:
chrome插件在這里起到了兩個(gè)作用泳桦。
- 將我們的原本的為插樁的js文件請(qǐng)求替換成插樁后的js文件汤徽。
- 進(jìn)行注入定時(shí)上報(bào)覆蓋率的數(shù)據(jù)的js腳本。
不過(guò)目前 chrome插件這個(gè)方案可能要被我們棄用掉了灸撰,因?yàn)閏hrome插件本身只能局限于chrome瀏覽器上谒府,而我們現(xiàn)在更多的會(huì)有一些h5頁(yè)面的情況,這些他就不能夠滿足浮毯,所以我們會(huì)將這部分的邏輯直接轉(zhuǎn)到fiddler中完疫,由fiddler來(lái)完成這塊的工作。這樣子就能滿足移動(dòng)端的測(cè)試覆蓋率的問(wèn)題了债蓝。
-
覆蓋率后臺(tái)(node)
這塊的實(shí)現(xiàn)我們沒(méi)有直接使用 istanbul-middleware 這個(gè)方案壳鹤,因?yàn)閷?duì)于一個(gè)長(zhǎng)達(dá)5年沒(méi)有維護(hù)更新的項(xiàng)目,我還是持有一定的懷疑態(tài)度(當(dāng)然可能項(xiàng)目本身很優(yōu)秀饰迹,完全沒(méi)有問(wèn)題)芳誓。所以我們把目光放到了 nyc 上,不過(guò)nyc更多是結(jié)合單元測(cè)試框架:jest使用或者說(shuō)直接通過(guò)命令行的方式進(jìn)行調(diào)用啊鸭。沒(méi)有太多有涉及到如何使用它的api的方法上锹淌。幸運(yùn)的是我們又找到了另外一個(gè)項(xiàng)目 Cypress 的 code-coverage, 這里做個(gè)小廣告, cypress是一個(gè)很優(yōu)秀的前端自動(dòng)化工具。 在這個(gè)項(xiàng)目里你可以看到它就是通過(guò)調(diào)用nyc的api進(jìn)行生成覆蓋率的測(cè)試報(bào)告的莉掂。 所以這塊我們毫不猶豫的選擇了它做了一定的二次開(kāi)發(fā)了葛圃。
問(wèn)題
理想總是很美好的千扔,我們?cè)谝粋€(gè)簡(jiǎn)單的demo項(xiàng)目上實(shí)驗(yàn)了下憎妙,基本沒(méi)啥問(wèn)題库正。但是進(jìn)入到真正的項(xiàng)目的時(shí)候,發(fā)現(xiàn)真的是困難重重厘唾。
問(wèn)題1. babel的升級(jí)
這個(gè)問(wèn)題我發(fā)現(xiàn)在其他文章里面都很少提到褥符。因?yàn)槭褂胕stanbul(最新的版本)插樁的方案的話需要在babel7的版本進(jìn)行,所以需要前端的項(xiàng)目做升級(jí)才行抚垃,而我們大部分的前端的項(xiàng)目都是停留在babel6的版本喷楣,這個(gè)升級(jí)過(guò)程就非常的痛苦,尤其前端的項(xiàng)目又用不到了各種的腳手架鹤树。(不過(guò)痛著痛著就習(xí)慣了铣焊,經(jīng)歷過(guò)幾次項(xiàng)目的babel版本升級(jí),基本上遇到的問(wèn)題也就那幾個(gè)罕伯,google基本都能夠幫忙解決了)以下就附上babel升級(jí)要做的一些修改
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文件的修改等等曲伊,這里就不說(shuō)了。
問(wèn)題2. istanbul與babel-plugin-import 沖突
babel-plugin-import是一個(gè)antd ui庫(kù)的按需加載的插件, 因?yàn)閍ntd的使用非常的廣泛, 基本上我們的前端項(xiàng)目都會(huì)使用到這個(gè)ui庫(kù), 所以注定這個(gè)問(wèn)題會(huì)遇到了追他。問(wèn)題如下圖所示
相關(guān)的問(wèn)題在istanbul issue中也可以找到 Does not work with babel-plugin-import 文中提到的解決方案有兩種:
1.直接修改babel-plugin-import的源碼坟募。
2.修改自己引用ui庫(kù)的方式。
上述兩種都比較麻煩邑狸,然而我們?cè)跈C(jī)緣巧合下發(fā)現(xiàn) 可以通過(guò)在babelrc中引入 @babel/plugin-transform-modules-commonjs 也可以解決這個(gè)問(wèn)題懈糯。不過(guò)原因暫時(shí)還不清楚(前端的打包真的太深?yuàn)W了)
可以看下 基于 Istanbul 優(yōu)雅地搭建前端 JS 覆蓋率平臺(tái) 評(píng)論區(qū)的內(nèi)容
PS: 以下部分涉及到真正去實(shí)踐過(guò)程的問(wèn)題分析,如果沒(méi)有動(dòng)手做過(guò)這塊內(nèi)容的同學(xué)可以忽略
問(wèn)題3. 為什么通過(guò)babel-loader + ts-loader 生成的覆蓋率數(shù)據(jù)(windows.coverage) 中帶有inputSouceMap,但是直接通過(guò)babel-loader 生成的覆蓋率數(shù)據(jù)就不帶有
我們先看下coverage數(shù)據(jù)的對(duì)比情況
ts-loader + babel-loader
babel-loader
首先針對(duì)這個(gè)問(wèn)題单雾,我們需要一步步的去看赚哗,我們首先要確定的一點(diǎn)是為什么babel-loader + ts-loader 的方式能夠出現(xiàn)inputSourceMap的內(nèi)容,而babel-loader卻沒(méi)有铁坎。 這兩者主要的差別實(shí)際上就是在多了一個(gè)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);
}
這個(gè)地方是ts-loader最后處理后的回調(diào)硬萍,我們可以看到這里帶了一個(gè)sourceMap扩所。 那這個(gè)sourceMap到底是什么呢?我們嘗試用斷點(diǎn)去看看朴乖。
這個(gè)確實(shí)就是我們?cè)赾overage數(shù)據(jù)里面看到的情況
所以順著這個(gè)流程 ts-loader講數(shù)據(jù)傳遞給到了babel-loader, babel-loader則將這個(gè)數(shù)據(jù)給到了istanbul祖屏。
既然講到了istanbul 我們來(lái)看下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
// 這里的條件可以看出來(lái) inputSouceMap是空并且 this.file.inputMap是有內(nèi)容的情況下 才會(huì)進(jìn)行相應(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)
}
}
}
}
}
})
如上述所說(shuō)的現(xiàn)在對(duì)istanbul來(lái)說(shuō)最關(guān)鍵的字段是inputMap袁勺。 那我們來(lái)看下babel-loader或者說(shuō)babel里面是否有對(duì)inputMap做一個(gè)賦值的動(dòng)作,分別在這兩個(gè)倉(cāng)庫(kù)中查了下這個(gè)關(guān)鍵字畜普,發(fā)現(xiàn)在babel中知道了期丰。
關(guān)鍵的信息應(yīng)該就是在normalize-file中了。我們看看這塊的有一個(gè)邏輯
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)通過(guò)ts-loader處理以后 inputSouceMap是一個(gè)object對(duì)象了,所以直接做賦值了钝荡。
if (typeof options.inputSourceMap === "object") {
inputMap = convertSourceMap.fromObject(options.inputSourceMap);
}
// 這下邊的部分邏輯都是在判斷ast內(nèi)容里面是否有包含soumap的字符串的信息街立,但是實(shí)際上如果是單獨(dú)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");
}
}
}
// 這里的返回值就是我們看到的一個(gè)File的對(duì)象實(shí)例埠通,里面就包含有inputMap.
return new File(options, {
code,
ast,
inputMap,
});
}
所以如果單獨(dú)用babel-loader的情況 是沒(méi)有辦法拿到inputSouceMap的
以上就是大概解釋了為什么ts-loader+babel-loader是由inputSourceMap 然后單獨(dú)的babel-loader是沒(méi)有的赎离。
問(wèn)題4. 通過(guò)ts-loader + babel-loader 生成的覆蓋率數(shù)據(jù)與bable-loader 單獨(dú)處理生成的數(shù)據(jù) 在statement等字段上數(shù)據(jù)有一定的差異,這個(gè)差異導(dǎo)致報(bào)告中部分語(yǔ)句覆蓋會(huì)有所區(qū)別端辱。
ts-loader + bable-loader
bable-loader
至少?gòu)倪@個(gè)截圖來(lái)了ts-loader + babel-loader的結(jié)果更正確點(diǎn)才對(duì)梁剔。
所以我們現(xiàn)在需要確認(rèn)的一點(diǎn)是為什么coverage中的statement會(huì)有差別。
其實(shí)這里很容易有一個(gè)猜測(cè)的 ts-loader處理后的內(nèi)容其實(shí)已經(jīng)不是真正的源碼內(nèi)容了舞蔽,已經(jīng)變化了才對(duì)荣病。所以我們還是需要再去看下 normalize-file
因?yàn)槲覀冏⒁獾剿膮?shù)里面其實(shí)就包含有ast以及相應(yīng)的code。 所以一樣的我們繼續(xù)斷點(diǎn)到這個(gè)地方看下數(shù)據(jù)的情況
ts-loader + babel-loader 的code 及 ast
從上圖的幾個(gè)對(duì)比其實(shí)就已經(jīng)能夠知道為什么coverage數(shù)據(jù)的statement的數(shù)組的個(gè)數(shù)等都有區(qū)別了渗柿。
但是可能又有人會(huì)好奇的問(wèn)題到众雷,那為什么單獨(dú)使用ts-loader編譯。import的語(yǔ)句都沒(méi)有被計(jì)算進(jìn)去呢做祝,從ats來(lái)看砾省,import的語(yǔ)句命名也是被翻譯過(guò)來(lái)為 ImportDeclaration
才對(duì),
這塊呢又要說(shuō)到istanbul中的code instrument這塊去了,由于我對(duì)這塊的理解不深混槐,只是通過(guò)斷點(diǎn)的方式做了一些初步的判斷做的一些猜想编兄。
其實(shí)如果我們有些人細(xì)心的話就能夠發(fā)現(xiàn) 原本是import的語(yǔ)句。
比如說(shuō)
import * as React from "react";
經(jīng)過(guò)ts-loader轉(zhuǎn)換后声登,代碼已經(jīng)變成了
var React = require('react');
其實(shí)是被從es6轉(zhuǎn)換成了commonjs了狠鸳。 所以它的ats的轉(zhuǎn)換也從 ImportDeclaration
變成了 VariableDeclaration
所以從這個(gè)過(guò)程可以看出來(lái) VariableDeclaration
被識(shí)別了出來(lái),但是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中定義了各個(gè)表達(dá)式的處理,但是里面確實(shí)就不包括 ImportDeclaration
所以這里就應(yīng)該是解釋了為什么import語(yǔ)句沒(méi)有顯示被覆蓋率的原因了
問(wèn)題5. istanbul的插樁為什么不能夠?qū)ode_modules中的代碼進(jìn)行插樁脯厨?
其實(shí)不是說(shuō)不能主要是這里遇到了一些坑铅祸, 我們首先先看下官方的文檔的說(shuō)明
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 followingnyc
config will preventnode_modules
from 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 atest
folder 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ù)上述的信息, 我們?cè)趐ackage.json中做相應(yīng)的修改。重新進(jìn)行打包后,coverage的數(shù)據(jù)中并沒(méi)有出現(xiàn)我們想要的node_modules的數(shù)據(jù)
帶著疑問(wèn), 我們需要重新思考下:首先 node_modules的內(nèi)容被babel編譯了嗎合武?如果是編譯了那istanul對(duì)這個(gè)對(duì)這個(gè)文件做插樁了嗎临梗? 我們需要先確定這兩點(diǎn)。
首先我們先確認(rèn)我們的babel的配置是正確的稼跳,即確實(shí)有指定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\//]
},
從這里看至少是對(duì)的,但是怎么確定文件確實(shí)是被babel以及istanbul處理到呢汤善?
我們還是要從源碼入手做一個(gè)控制臺(tái)的打印來(lái)看看什猖。
async function loader(source, inputSourceMap, overrides) {
const filename = this.resourcePath;
// 增加一個(gè)打印
console.log("babel loader", filename);
let loaderOptions = loaderUtils.getOptions(this) || {};
validateOptions(schema, loaderOptions, {
name: "Babel loader",
});
...
我們知道webpack打包會(huì)經(jīng)過(guò)babel-loader 所以我們先在這里打印下看下是否確實(shí)經(jīng)過(guò)了處理票彪。
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)
// 增加一個(gè)打印
console.log('istanbul, ', this.file.opts.filename)
if (shouldSkip(realPath, this.nycConfig)) {
return
}
....
我們重新看下打包過(guò)程的打印信息
從上述的信息來(lái)看, 我們的源碼進(jìn)入了babel-loader, 并且也被istanbul處理了,但是node_modules確只是被babel-loader處理不狮,但是并沒(méi)有到istanbul中抹镊。
所以這里肯定是哪里的配置不正確導(dǎo)致的。
找了很多istanbul的配置都沒(méi)有什么效果荤傲,直到搜索到了這個(gè)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即可颈渊。
我們重新來(lái)嘗試下看下打包的打印
從這里看確實(shí)node_modules的處理已經(jīng)進(jìn)入到了istanbul處理的范圍內(nèi)了遂黍。
總結(jié)
以上就是我們?cè)谡{(diào)研跟實(shí)施代碼覆蓋率的時(shí)候遇到的一些問(wèn)題跟分析的過(guò)程。由于前端代碼覆蓋率這塊還剛起步俊嗽,如果還有其他問(wèn)題 我會(huì)繼續(xù)更新這篇文章雾家,解決其他同學(xué)在前端代碼覆蓋率上遇到的問(wèn)題。