代码覆盖率原理(一)

1,407 阅读3分钟

代码覆盖率是衡量代码库质量的一个有力的指标。代码测试套件是如何做到如下图这么神奇的结果?

1-Ejmkt_EHbdKENj65mgfP4Q.png

下面我们通过一个简单的示例来学习代码覆盖率是如何工作的。

首先放上需要测试的源代码

// 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;
};

下一次分享:

  1. 将我们的检测源代码与我们的测试运行程序集成
  2. 收集代码覆盖率指标并生成报告
  3. 考虑扩展我们的工具以涵盖块语句

参考

  1. www.semantics3.com/blog/unders…