azl397985856 / fe-interview

宇宙最强的前端面试指南 (https://lucifer.ren/fe-interview)
Apache License 2.0
2.84k stars 260 forks source link

【每日一题】- 2019-09-03 - 单元测试的覆盖率是这么计算出来的(原理而不是计算公式) #29

Closed azl397985856 closed 4 years ago

azl397985856 commented 5 years ago

我们平时用单元测试的时候非常关心测试的覆盖率,如果很低,那其实还不如不做。

单元测试覆盖率的种类有很多,包括行覆盖率,分支覆盖率,方法覆盖率等,那么这些覆盖率是如何计算的呢?原理是什么?

azl397985856 commented 5 years ago

认领

azl397985856 commented 4 years ago

istanbul 是如何计算我们代码的测试覆盖率的?

istanbul 是一款用来计算 JS 代码的测试覆盖率的工具, 是前端测试覆盖率工具中非常流行,评价最好的的之一。 它可以用来计算行覆盖率,方法覆盖率以及分支覆盖率等。 本文我们来看下 istanbul 是如何来计算这些指标的。

对于 nodejs 而言, istanbul 其实是重写了 nodejs 的模块加载器,然后在模块加载前对代码进行修改,模块加载器是什么? 它具体是怎么修改的呢?我们继续往下看。

模块

看 nodejs 加载器之前,我们来看下什么是模块,知道的可以直接跳过这一节。

nodejs 中每一个文件就是一个单独的模块,模块内部可以引入其他模块,也可以将自己的内容进行导出。

我们使用关键字 require 引入别的模块,require 内部的机制, 以及 require 的缓存require.cache等等这里就不深究了。

其实模块并不是黑魔法,nodejs 模块代码被执行的之后,其实是被包裹了一层去执行的,类似这样:

(function(exports, require, module, __filename, __dirname) {
  // Module code actually lives in here
});

require.extensions 是一个废弃的 API, 而 istanbul 正是用了这个 API,

require.extensions的功能其实就是去处理不同的文件扩展。 比如 sjs 后缀被当然 js 文件来看待我们就可以这么写, require.extensions['.sjs'] = require.extensions['.js'];. 再比如我想处理vue, 文件或者jsx 文件也可以基于此进行扩展。

更多关于 node 模块的介绍,请参考nodejs 官方文档

第一个问题:istanbul 对我们的代码进行了怎么样的修改

istanbul 中的 instrument

对于如下 JS 代码:

function add(number1 = 0, number2 = 0) {
  if (typeof number1 !== 'number') return 0;
  if (typeof number2 !== 'number') return 0;
  return number1 + number2;
}

我们先使用 istanbul 的 instruct 来处理上面的代码,让我们来测试一下:

var istanbul = require('istanbul');
var instrumenter = new istanbul.Instrumenter();

var generatedCode = instrumenter.instrumentSync(`function add(number1 = 0, number2 = 0) {
    if (typeof number1 !== 'number') return 0;
    if (typeof number2 !== 'number') return 0;
    return number1 + number2
}`);

console.log(generatedCode);

当你执行上面的脚本的时候控制台会输出如下代码:

我测试的 istanbul 版本是 0.4.5 , node 版本 v8.9.3

var __cov_twf0CJaWcG8rV6dmHmHPGQ = Function('return this')();
if (!__cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__) {
  __cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__ = {};
}
__cov_twf0CJaWcG8rV6dmHmHPGQ = __cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__;
if (!__cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js']) {
  __cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js'] = {
    path: '1567251907601.js',
    s: { '1': 1, '2': 0, '3': 0, '4': 0, '5': 0, '6': 0 },
    b: { '1': [0, 0], '2': [0, 0] },
    f: { '1': 0 },
    fnMap: {
      '1': {
        name: 'add',
        line: 1,
        loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 39 } },
      },
    },
    statementMap: {
      '1': { start: { line: 1, column: 0 }, end: { line: 5, column: 1 } },
      '2': { start: { line: 2, column: 4 }, end: { line: 2, column: 46 } },
      '3': { start: { line: 2, column: 37 }, end: { line: 2, column: 46 } },
      '4': { start: { line: 3, column: 4 }, end: { line: 3, column: 46 } },
      '5': { start: { line: 3, column: 37 }, end: { line: 3, column: 46 } },
      '6': { start: { line: 4, column: 4 }, end: { line: 4, column: 28 } },
    },
    branchMap: {
      '1': {
        line: 2,
        type: 'if',
        locations: [
          { start: { line: 2, column: 4 }, end: { line: 2, column: 4 } },
          { start: { line: 2, column: 4 }, end: { line: 2, column: 4 } },
        ],
      },
      '2': {
        line: 3,
        type: 'if',
        locations: [
          { start: { line: 3, column: 4 }, end: { line: 3, column: 4 } },
          { start: { line: 3, column: 4 }, end: { line: 3, column: 4 } },
        ],
      },
    },
  };
}
__cov_twf0CJaWcG8rV6dmHmHPGQ = __cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js'];
function add(number1 = 0, number2 = 0) {
  __cov_twf0CJaWcG8rV6dmHmHPGQ.f['1']++;
  __cov_twf0CJaWcG8rV6dmHmHPGQ.s['2']++;
  if (typeof number1 !== 'number') {
    __cov_twf0CJaWcG8rV6dmHmHPGQ.b['1'][0]++;
    __cov_twf0CJaWcG8rV6dmHmHPGQ.s['3']++;
    return 0;
  } else {
    __cov_twf0CJaWcG8rV6dmHmHPGQ.b['1'][1]++;
  }
  __cov_twf0CJaWcG8rV6dmHmHPGQ.s['4']++;
  if (typeof number2 !== 'number') {
    __cov_twf0CJaWcG8rV6dmHmHPGQ.b['2'][0]++;
    __cov_twf0CJaWcG8rV6dmHmHPGQ.s['5']++;
    return 0;
  } else {
    __cov_twf0CJaWcG8rV6dmHmHPGQ.b['2'][1]++;
  }
  __cov_twf0CJaWcG8rV6dmHmHPGQ.s['6']++;
  return number1 + number2;
}

可以看出我们的代码被插入了很多__cov_twf0CJaWcG8rV6dmHmHPGQ, 这个就是用来做统计的, 然后实际代码执行的时候会往全局变量更新值,我们就可以根据这些统计数据去计算覆盖率了。

具体的实现感兴趣的可以去看下 istanbul 源码

第二个问题: 模块加载器是什么,在这起到了什么作用

其实上面我们讲模块的时候,已经提到了模块加载器,它是 nodejs 用来加载模块的。

值得一提的是 istanbul 对模块加载器进行了重写,不同于 node 默认的 readFile + compile, istanbul 的 js 加载器的过程是 readFile + instrument + compile,代码中的 fn/transformer 就是 intrument 函数。

核心的代码:

function hookRequire(matcher, transformer, options) {
  options = options || {};
  var extensions,
    fn = transformFn(matcher, transformer, options.verbose),
    postLoadHook =
      options.postLoadHook && typeof options.postLoadHook === 'function'
        ? options.postLoadHook
        : null;

  extensions = options.extensions || ['.js'];

  extensions.forEach(function(ext) {
    if (!(ext in originalLoaders)) {
      originalLoaders[ext] = Module._extensions[ext] || Module._extensions['.js'];
    }
    Module._extensions[ext] = function(module, filename) {
      // 这里先读文件,然后fn处理,最后调用module._compile
      var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
      if (ret.changed) {
        // 对于一些文件可以不处理,如果不处理,就用原来的模块加载器
        module._compile(ret.code, filename);
      } else {
        originalLoaders[ext](module, filename);
      }
      if (postLoadHook) {
        postLoadHook(filename);
      }
    };
  });
}

通过这样的修改,这个过程就变得透明了,你还是和以前一样引入模块就好了,整个过程用户无感知, 如果大家在做技术升级和推广,能做到这种程度相比是非常完美的。这部分的源码在这里

总结

本篇文章像大家介绍了istanbul是如何计算单元测试的覆盖率的,我们先是复习了模块的知识, 然后通过一个例子来说明istanbul是对我们的源码进行了一些处理,在我们的代码无数位置添加计数器, 然后代码执行到计数器+1,通过这样的方式我们就可以收集到统计数据,进而计算单元测试覆盖了。

另外我们知道了istanbul 通过修改nodejs的模块加载器,从而实现了对用户透明的效果。

关注我

最近我重新整理了下自己的公众号,并且我还给他换了一个名字《脑洞前端》,它是一个帮助你打开大前端新世界大门的钥匙🔑,在这里你可以听到新奇的观点,看到一些技术尝新,还会收到系统性总结和思考。

我会尽量通过图的形式来阐述一些概念和逻辑,帮助大家快速理解,图解前端是我的目标。

之后我的文章同步到微信公众号 脑洞前端 ,您可以关注获取最新的文章,或者和我进行交流。

qrcode