📠

Jestの2つのカバレッジ収集方法をざっくり調べてみました

に公開

こんにちは。bun913と申します。

私のチームでは、Sonarqubeというツールでカバレッジレポートを管理しているのですが、ReactコンポーネントやHooksの一部で「テストは書いているはずなのに、出力されたカバレッジレポートではテストが不足している」という問題が発生しました。

後述しますが、解決方法としては以下のように JestcoverageProvider オプションを v8 に変更することで、同時にパフォーマンスも向上したように思います。

ただし、Jest のドキュメントには私が見る限り、これらのプロバイダーの違いについての詳細な説明がありませんでした。そこで私が今知りたい範囲で、それぞれのオプションで呼ばれているプロバイダがどのようにカバレッジを収集しているかを実際に動かして調べてみました。

さっそくまとめ

Babel(デフォルト)方式

以下のライブラリを利用しています。

https://github.com/istanbuljs/istanbuljs

本来のコードが以下のような JS ファイルだとします。

function calc(a, b) {
  if (a === 3) {
    return a;
  }
  if (a >= 9 && a <= 10) {
    return a + b;
  }
  return 0;
}

module.exports = { calc };

それを以下のように カバレッジ収集用の命令を組み込んだ状態でトランスパイル することで、各カバレッジデータを取得しているようです。(以下のコードはイメージしやすく私が書いたものです)

function calc(a, b) {
  coverageCounter.function[0]++;    // 関数が呼ばれたことをカウント

  coverageCounter.statement[0]++;   // 1つ目のif文に到達
  if (a === 3) {
    coverageCounter.branch[0][0]++; // 最初のif文がtrueの場合
    return a;
  } else {
    coverageCounter.branch[0][1]++; // 最初のif文がfalseの場合
  }

  coverageCounter.statement[1]++;   // 2つ目のif文に到達
  if (a >= 9 && a <= 10) {
    coverageCounter.branch[1][0]++; // 2つ目のif文がtrueの場合
    return a + b;
  } else {
    coverageCounter.branch[1][1]++; // 2つ目のif文がfalseの場合
  }

  coverageCounter.statement[2]++;   // return文に到達
  return 0;
}

module.exports = { calc };
リアルバージョン
// Initialize coverage
cov_d4c34qg3b();

// The actual function with coverage counters injected
function calc(a, b) {
  cov_d4c34qg3b().f[0]++;              // Function call counter
  cov_d4c34qg3b().s[0]++;              // Statement 0: if block
  if (a === 3) {
    cov_d4c34qg3b().b[0][0]++;         // Branch 0, path 0 (true)
    cov_d4c34qg3b().s[1]++;            // Statement 1: return a
    return a;
  } else {
    cov_d4c34qg3b().b[0][1]++;         // Branch 0, path 1 (false)
  }

  cov_d4c34qg3b().s[2]++;              // Statement 2: second if block
  if (
    (cov_d4c34qg3b().b[2][0]++, a >= 9) &&    // Branch 2, path 0: a >= 9
    (cov_d4c34qg3b().b[2][1]++, a <= 10)      // Branch 2, path 1: a <= 10
  ) {
    cov_d4c34qg3b().b[1][0]++;         // Branch 1, path 0 (true)
    cov_d4c34qg3b().s[3]++;            // Statement 3: return a + b
    return a + b;
  } else {
    cov_d4c34qg3b().b[1][1]++;         // Branch 1, path 1 (false)
  }

  cov_d4c34qg3b().s[4]++;              // Statement 4: return 0
  return 0;
}

cov_d4c34qg3b().s[5]++;                // Statement 5: module.exports
module.exports = { calc };

V8方式

一方、V8方式では以下のライブラリを利用しています。

https://github.com/SimenB/collect-v8-coverage

本気で理解しようとすると、V8のソースコードを読む必要があるので、以下のようにざっくりと理解しています。

  • 超ざっくりと言うと、V8 エンジンに組み込まれているプロファイラを利用して、生成されたバイトコードの実行を追跡することでカバレッジ情報を取得している。
  • バイトコードとは、以下の記事が非常にわかりやすかったですが、特定のマシンに機械語ほど依存しない、中間的な成果物のようなものと理解しています。

https://zenn.dev/canalun/articles/exec_javascript_beyond_ast

  • 成果物のイメージとしては、以下のように「元のソースコードの何バイト目から何バイト目の範囲が何回実行されたか」という情報を得ているようです。
{
  "scriptId": "108",
  "url": "file:///path/to/sample.js",
  "functions": [
    {
      "functionName": "calc",
      "ranges": [
        {
          "startOffset": 0,
          "endOffset": 119,
          "count": 3
        },
        {
          "startOffset": 37,
          "endOffset": 56,
          "count": 1
        },
        {
          "startOffset": 56,
          "endOffset": 70,
          "count": 2
        }
      ],
      "isBlockCoverage": true
    }
  ]
}

どちらを使うべきか

  • 私は今後は基本的にパフォーマンス面でも有利であると言われているV8をメインに使いつつ、問題が発生した際にはBabelを利用するなど、柔軟に対応していきたいと考えています
  • VitestではV8がデフォルトになっています
    • ただし、利用されているライブラリやコアなどはJestとは異なるため注意が必要です
    • Vitestには結構詳しいドキュメントがあるため、そちらも参考にすると良いかもしれません

今回の記事のきっかけ

私のチームでは、Sonarqube というツールを使って、静的解析やカバレッジレポート、Quality Gate を設定しています。

その運用の中で、一部 React のコンポーネントや Hooks のコードで、「テストコードを実装しているはずの箇所」でカバレッジが不足しているという問題が発生しました。

調査したところ、Jest の coverageProvider オプションをデフォルトの babel から v8 に変更することで、この問題は解決しました。

jest.config.js
module.exports = {
  coverageProvider: 'v8', // デフォルトは 'babel'
  // ... other settings
};

しかし、Jest の公式ドキュメントには、これらのプロバイダの違いについてあまり詳しい説明がありませんでした。

https://jestjs.io/docs/configuration#coverageprovider-string

https://jestjs.io/docs/configuration#collectcoverage-boolean

そこで、「なぜ v8 に変更したら解決したのか?」「それぞれのプロバイダはどのようにカバレッジを取得しているのか?」という疑問を解消するために、実際に動かして調査してみることにしました。

調査の結果

今回の調査では、2つのプロバイダが内部で利用しているライブラリを直接使って実験を行いました。

Babel (Istanbul) 方式の調査

Jest のデフォルトである Babel 方式は、内部で Istanbul.js というライブラリを利用しています。

今回は、このライブラリの中核である istanbul-lib-instrument を直接使って、どのようにコードが変換されるかを確認しました。

以下のようなシンプルな関数を用意します。

sample.js
function calc(a, b) {
  if (a === 3) {
    return a;
  }
  if (a >= 9 && a <= 10) {
    return a + b;
  }
  return 0;
}

module.exports = { calc };

この関数を istanbul-lib-instrument でトランスパイルすると、以下のようにカバレッジ収集用のカウンタが埋め込まれたコードが生成されます。

トランスパイル後のコード(実際の出力)
// Initialize coverage
cov_d4c34qg3b();

// The actual function with coverage counters injected
function calc(a, b) {
  cov_d4c34qg3b().f[0]++;              // Function call counter
  cov_d4c34qg3b().s[0]++;              // Statement 0: if block
  if (a === 3) {
    cov_d4c34qg3b().b[0][0]++;         // Branch 0, path 0 (true)
    cov_d4c34qg3b().s[1]++;            // Statement 1: return a
    return a;
  } else {
    cov_d4c34qg3b().b[0][1]++;         // Branch 0, path 1 (false)
  }

  cov_d4c34qg3b().s[2]++;              // Statement 2: second if block
  if (
    (cov_d4c34qg3b().b[2][0]++, a >= 9) &&    // Branch 2, path 0: a >= 9
    (cov_d4c34qg3b().b[2][1]++, a <= 10)      // Branch 2, path 1: a <= 10
  ) {
    cov_d4c34qg3b().b[1][0]++;         // Branch 1, path 0 (true)
    cov_d4c34qg3b().s[3]++;            // Statement 3: return a + b
    return a + b;
  } else {
    cov_d4c34qg3b().b[1][1]++;         // Branch 1, path 1 (false)
  }

  cov_d4c34qg3b().s[4]++;              // Statement 4: return 0
  return 0;
}

cov_d4c34qg3b().s[5]++;                // Statement 5: module.exports
module.exports = { calc };

このように、元のコードに カウンタを埋め込む形でコードを変換 することで、カバレッジ情報を取得していることがわかります。

また、Istanbul は以下のような「カバレッジマップ」も生成します。これにより、どのステートメントやブランチがどこにあるかを把握しています。

{
  "statementMap": {
    "0": { "start": { "line": 2, "column": 2 }, "end": { "line": 4, "column": 3 } },
    "1": { "start": { "line": 3, "column": 4 }, "end": { "line": 3, "column": 12 } }
    // ... 以下略
  },
  "fnMap": {
    "0": { "name": "calc", "decl": { "start": { "line": 1, "column": 9 } } }
  },
  "branchMap": {
    "0": { "loc": { "start": { "line": 2, "column": 2 } }, "type": "if" }
  }
}

ちなみに2018年ごろの npm の PdM の方がブログで説明されており、なぜv8のproviderが必要とされるようになったかや、メリット・デメリットについて書かれています。

https://medium.com/the-node-js-collection/rethinking-javascript-test-coverage-5726fb272949

メリット:

  • 古い Node.js バージョンでも動作する
  • 2012年ごろ?からある仕組みであり、テストなどもよくされている
    • いい意味で枯れた技術なのかと思いました

デメリット:

  • 元のコードを変更するため、パフォーマンスへの影響がある
  • 複雑な構文でカウンタを正しく埋め込めない可能性がある

V8 方式の調査

V8 方式は、collect-v8-coverage というライブラリを利用しています。

このライブラリは、Node.js の inspector モジュールを使って、V8 エンジンに組み込まれているプロファイラを呼び出しています。

collect-v8-coverageの実装(抜粋)
const { Session } = require('inspector');

class CoverageInstrumenter {
  async startInstrumenting() {
    this.session.connect();
    await this.postSession('Profiler.enable');
    await this.postSession('Profiler.startPreciseCoverage', {
      callCount: true,
      detailed: true,
    });
  }

  async stopInstrumenting() {
    const {result} = await this.postSession('Profiler.takePreciseCoverage');
    return result;
  }
}

この方式の最大の特徴は、元のソースコードを変更せず、V8 エンジンがバイトコード実行時に収集した情報を利用する という点です。

V8 Profiler からは、以下のような「バイト位置(offset)とカウント」形式の情報が返されます。

{
  "scriptId": "108",
  "url": "file:///path/to/sample.js",
  "functions": [
    {
      "functionName": "calc",
      "ranges": [
        {
          "startOffset": 0,
          "endOffset": 119,
          "count": 3
        },
        {
          "startOffset": 37,
          "endOffset": 56,
          "count": 1
        },
        {
          "startOffset": 56,
          "endOffset": 70,
          "count": 2
        }
      ],
      "isBlockCoverage": true
    }
  ]
}

このデータは「元のソースコードの何バイト目から何バイト目の範囲が何回実行されたか」を示しています。

V8 エンジンは内部でバイトコードを生成して実行していますが、このプロファイラはそのバイトコードレベルの実行を追跡し、元のソースコードの位置にマッピングして返してくれます。

V8が生成するバイトコードの例

以下は、calc 関数から V8 が生成したバイトコードです(node --print-bytecode --print-bytecode-filter="calc" sample.js で出力)。

Bytecode length: 32
Parameter count 3
Register count 0
Frame size 0
   24 S> 0x36bd50401e28 @    0 : 0d 03             LdaSmi [3]
   30 E> 0x36bd50401e2a @    2 : 70 03 00          TestEqualStrict a0, [0]
         0x36bd50401e2d @    5 : 9e 05             JumpIfFalse [5] (0x36bd50401e32 @ 10)
   43 S> 0x36bd50401e2f @    7 : 0b 03             Ldar a0
   52 S> 0x36bd50401e31 @    9 : ae                Return
   59 S> 0x36bd50401e32 @   10 : 0d 09             LdaSmi [9]
   65 E> 0x36bd50401e34 @   12 : 74 03 01          TestGreaterThanOrEqual a0, [1]
         0x36bd50401e37 @   15 : 9e 0f             JumpIfFalse [15] (0x36bd50401e46 @ 30)
         0x36bd50401e39 @   17 : 0d 0a             LdaSmi [10]
   75 E> 0x36bd50401e3b @   19 : 73 03 02          TestLessThanOrEqual a0, [2]
         0x36bd50401e3e @   22 : 9e 08             JumpIfFalse [8] (0x36bd50401e46 @ 30)
   88 S> 0x36bd50401e40 @   24 : 0b 04             Ldar a1
   97 E> 0x36bd50401e42 @   26 : 3b 03 03          Add a0, [3]
  101 S> 0x36bd50401e45 @   29 : ae                Return
  108 S> 0x36bd50401e46 @   30 : 0c                LdaZero
  117 S> 0x36bd50401e47 @   31 : ae                Return

V8 Profiler は、このバイトコード命令のどこが実行されたかを追跡します。

詳しい解説は以下のブログが参考になります(基本情報技術者レベルの知識があると理解しやすいです)。

https://kakts.dev/entry/2021/12/27/235512

メリット:

  • 元のコードを変更しないため、パフォーマンスへの影響が少ない
  • V8 エンジンの機能を直接利用しているため、新しい JavaScript 構文にも対応しやすい?

デメリット:

  • Node.js 10.16.0 以降が必要
  • V8 Engine が動作しない環境では利用できない

まとめ・感想

  • Jest では2つのカバレッジ収集方式があり、それぞれにメリット・デメリットが存在することがわかりました
  • Vitestでも似たようなオプションを提供していますが、もちろん利用するライブラリなどは異なります
    • Vitestでは、詳細なドキュメントとともに、v8の方をデフォルトとして推されています
    • Vitestでは同じv8という名前でも、異なるアプローチ(ASTベース)を採用していることが強調されています

私はお恥ずかしながら、カバレッジの収集の仕組みについてほとんど知らなかったので、さわりを理解するだけでも非常にためになりました。

この辺りは言語によってやり方に違いがありそうであるため、気になったら同じようにざっくりとだけでも理解できるように調査したいと思いました。

以上、最後までお読みいただきありがとうございました。

GitHubで編集を提案
Money Forward Developers

Discussion