代码覆盖率是衡量代码库质量的一个有力的指标。代码测试套件是如何做到如下图这么神奇的结果?
下面我们通过一个简单的示例来学习代码覆盖率是如何工作的。
首先放上需要测试的源代码
// https://github.com/amarnus/learning-code-coverage/blob/master/source.js
const { keys, values, sum, each, round, isObject } = require('lodash');
/**
* Splits an amount among multiple people based on a percentage criterion specified.
*
* @param {float} amount - Amount to be split
* @param {object} percentagesByPeople - An object whose each key is a
* person identifier and value is a number between 0 and 1 indicating the
* person's split.
* @return {object} - An object whose each key is a person identifier as specified in
* the input. Each value indicates the person's split amount.
*/
module.exports = (amount, percentagesByPeople) => {
const people = keys(percentagesByPeople);
const percentages = values(percentagesByPeople);
let splitAmountByPeople = {};
if (amount <= 0) {
throw new Error('amount cannot be zero or negative');
}
if (!isObject(percentagesByPeople)) {
throw new Error('percentage splits must be an object');
}
if (sum(percentages) !== 1) {
throw new Error('percentages must total to 1');
}
each(people, person => {
splitAmountByPeople[person] = round(
(percentagesByPeople[person] * amount), 2
);
});
return splitAmountByPeople;
};
我们先思考下
如何检验以上代码块中的每行代码都可以被单元测试覆盖?
我们编写的单元测试需要这个代码块中的每个语句都被执行到,就是要走遍所有可能的分支逻辑。 那么每个分支逻辑怎么样才算是被执行了?测试套件是如何知道每个语句都被执行了,每个分支都运行了, 最简单的想法就是在每个语句运行前加入埋点信息,当语句被执行时,埋点的代码也会被执行到。 那么如何在语句执行前进行埋点呢?手工加入埋点逻辑是不可能的,这辈子都不可能的。
这里需要引入一个知识点AST(抽象语法树)。我们需要在抽象语法树中增加一些内容。以下两个函数起了关键作用
onEachPath
// https://github.com/amarnus/learning-code-coverage/blob/master/src/instrument.ts
let statementCounter = 0;
let coverage: any = {};
const onEachPath = (path: NodePath) => {
// 如果当前的节点是一个语句,执行if语句块
if (isStatement(path)) {
// 语句计数器加 1
const statementId = ++statementCounter;
coverage = coverage || {};
coverage.c = coverage.c || {};
// 记录语句被执行的状态,0:未执行,1:执行
coverage.c[statementId] = 0;
coverage.statementMap = coverage.statementMap || {};
// 语句的位置信息,记录起始的行列位置
coverage.statementMap[statementId] = toPlainObjectRecursive(path.node.loc);
// 语句被执行,__coverage__.c[statementId] 就会自增
// 如果发现 __coverage__.c[statementId] 为 0,即为该语句未被执行到,需要在报告中重点提示(标红或标黄)
path.insertBefore(template(`
__coverage__.c["${ statementId }"]++
`)());
}
};
onExitProgram
// https://github.com/amarnus/learning-code-coverage/blob/master/src/instrument.ts
// 当遍历完整颗树后,收集语句的位置信息和计数器状态,放置到代码的顶部
const onExitProgram = (path: NodePath) => {
path.node.body.unshift(template(`
__coverage__ = COVERAGE
`)({
'COVERAGE': valueToNode(coverage)
}));
};
主程序逻辑
// https://github.com/amarnus/learning-code-coverage/blob/master/src/instrument.ts
// 读取源代码文件
const source: string = readCode();
// 转换源代码文件字符串为抽象语法树
const tree: File = parseCode(source);
// 遍历抽象语法树,在每个节点增加语句调用计数器
traverse(tree, onEachPath, onExitProgram);
// 打印生成的代码
console.log(generateCode(tree));
修改AST后生成的代码如下
__coverage__ = {
c: {
"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0,
"8": 0, "9": 0, "10": 0, "11": 0, "12": 0, "13": 0, "14": 0
},
statementMap: {
"1": {
start: { line: 1, column: 0 },
end: { line: 1, column: 71 }
},
"2": {
start: { line: 10, column: 0 },
end: { line: 32, column: 2 }
},
"3": {
start: { line: 11, column: 4 },
end: { line: 11, column: 45 }
},
"4": {
start: { line: 12, column: 4 },
end: { line: 12, column: 52 }
},
"5": {
start: { line: 13, column: 4 },
end: { line: 13, column: 33 }
},
"6": {
start: { line: 15, column: 4 },
end: { line: 17, column: 5 }
},
"7": {
start: { line: 16, column: 8 },
end: { line: 16, column: 61 }
},
"8": {
start: { line: 19, column: 4 },
end: { line: 21, column: 5 }
},
"9": {
start: { line: 20, column: 8 },
end: { line: 20, column: 63 }
},
"10": {
start: { line: 23, column: 4 },
end: { line: 25, column: 5 }
},
"11": {
start: { line: 24, column: 8 },
end: { line: 24, column: 55 }
},
"12": {
start: { line: 27, column: 4 },
end: { line: 29, column: 7 }
},
"13": {
start: { line: 28, column: 8 },
end: { line: 28, column: 93 }
},
"14": {
start: { line: 31, column: 4 },
end: { line: 31, column: 31 }
}
}
};
__coverage__.c["1"]++;
const { keys, values, sum, each, round, isObject } = require('lodash');
__coverage__.c["2"]++;
module.exports = (amount, percentagesByPeople) => {
__coverage__.c["3"]++;
const people = keys(percentagesByPeople);
__coverage__.c["4"]++;
const percentages = values(percentagesByPeople);
__coverage__.c["5"]++;
let splitAmountByPeople = {};
__coverage__.c["6"]++;
if (amount <= 0) {
__coverage__.c["7"]++;
throw new Error('amount cannot be zero or negative');
}
__coverage__.c["8"]++;
if (!isObject(percentagesByPeople)) {
__coverage__.c["9"]++;
throw new Error('percentage splits must be an object');
}
__coverage__.c["10"]++;
if (sum(percentages) !== 1) {
__coverage__.c["11"]++;
throw new Error('percentages must total to 1');
}
__coverage__.c["12"]++;
each(people, person => {
__coverage__.c["13"]++;
splitAmountByPeople[person] = round(percentagesByPeople[person] * amount, 2);
});
__coverage__.c["14"]++;
return splitAmountByPeople;
};
下一次分享:
- 将我们的检测源代码与我们的测试运行程序集成
- 收集代码覆盖率指标并生成报告
- 考虑扩展我们的工具以涵盖块语句
参考