この記事は NTT ドコモソリューションズ Advent Calendar 2025 15 日目の記事です。
はじめに
NTT ドコモソリューションズの桑畑です。
普段は社内プロジェクトの品質向上のための技術支援に取り組んでいます。
最近の業務にて、JavaScript/TypeScript のテスティングフレームワークである Jest を使用する機会がありました。
Jest では --coverage オプションを指定すると次のようなカバレッジレポートが出力でき、Jest に限らず何らかのテスティングフレームワークを使用した経験のある方にとっては馴染みのある表示ではないでしょうか。
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
index.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
詳細は割愛しますが、カバレッジレポートの各項目はそれぞれ Stmts(命令網羅率)、Branch(分岐網羅率)、Funcs(関数網羅率)、Lines(行網羅率)を指しており、単語からどのような指標であるかはイメージしやすいかと思います。
今回の記事では、Stmts と Lines に着目したいと思います。
経験上これまでテスト結果を確認する際、Stmts と Lines は同じ値を示していることがほとんどで、些細な点ですがこれらの網羅率の計測方法は内部的にどのような違いがあるのか気になっていました。
そこで、本記事では Jest がカバレッジを出力するまでの動作から、Stmts と Lines がどのような根拠で算出されているか確認しようと思います。
本記事で使用している各パッケージのバージョンは次のとおりです。
- Jest v30.2.0
- istanbul-lib-coverage v3.0.0
- nyc v17.1.0
Stmts と Lines が異なる場合
Stmts と Lines の値が異なる値となるパターンとして、1 行に複数の命令があり、そのうちのいくつかが実行されていない場合が挙げられます。
次の例では、return 文と同じ行に記述されている console.log() は実行されません。
そのため、行の一部が実行されることで Lines はカウントされますが、console.log() にあたる部分の Stmts はカウントされません。
(静的解析ツールの普及により、このようなコードが混入する可能性は低いとは思いますが…)
function discount(price, rate) {
if (rate < 0 || rate > 1) {
return -1;
}
// 2 つ目の命令(console.log)は実行されず、Stmts にカウントされない
return Math.floor(price - price * rate); console.log('never run');
}
discount(1000, 0.1);
discount(1000, 1.5);
module.exports = { discount };
実際に次のようなテストコードを実行すると、Lines は 100% となる一方で Stmts は 85.71% に留まっていることを確認できます。
const { discount } = require('./index');
describe('index.js', () => {
test('割引後の金額が取得できること', () => {
const price = 1000;
const rate = 0.2;
const result = discount(price, rate);
expect(result).toBe(800);
});
test('無効な割引率の場合に -1 が返ること', () => {
const price = 1000;
const invalidRate = 1.5;
const result = discount(price, invalidRate);
expect(result).toBe(-1);
});
});
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 85.71 | 100 | 100 | 100 |
index.js | 85.71 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
カバレッジの計測方法を確認する
カバレッジ収集コードの埋め込み
前述の例をもとに、Lines と Stmts それぞれの計測方法を確認していきます。
Jest におけるカバレッジ計測は、istanbul によってテスト対象コードにカバレッジ収集コードを埋め込むデフォルトの方式と、Jest 25 以降であれば V8 エンジンのコードカバレッジ機能を使用する方式がサポートされています。
今回は検証目的のため、よりカバレッジ計測の流れを確認しやすい istanbul を用いた方式をベースに確認していきます。
はじめに、istanbul の CLI ツールである nyc を使用して前述のテスト対象コードにカバレッジ収集コードを埋め込みます。
nyc を npm i -D nyc 等でインストールして次のコマンドを実行すると、--cache-dir オプションで指定したディレクトリにカバレッジ収集コードが埋め込まれたソースファイルを生成します。
npx nyc --cache-dir .nyc_cache node index.js
- 元のソースファイルが
index.jsの場合、index-[hash].jsのような名称で生成されています -
index-[hash].jsは改行等が削除された状態のため、適宜 Prettier 等で整形すると確認しやすいです
カバレッジ収集コードが埋め込まれた箇所は次のとおりです(生成された index-[hash].js から抜粋)。
cov_qgu7xavc6();
function discount(price, rate) {
cov_qgu7xavc6().f[0]++;
cov_qgu7xavc6().s[0]++;
if (
(cov_qgu7xavc6().b[1][0]++, rate < 0) ||
(cov_qgu7xavc6().b[1][1]++, rate > 1)
) {
cov_qgu7xavc6().b[0][0]++;
cov_qgu7xavc6().s[1]++;
return -1;
} else {
cov_qgu7xavc6().b[0][1]++;
}
cov_qgu7xavc6().s[2]++;
return Math.floor(price - price * rate);
cov_qgu7xavc6().s[3]++;
console.log('never run');
}
cov_qgu7xavc6().s[4]++;
discount(1000, 0.1);
cov_qgu7xavc6().s[5]++;
discount(1000, 1.5);
cov_qgu7xavc6().s[6]++;
module.exports = { discount };
元のソースコードの構造をもとに、関数(function)/ 分岐(branch)/ 命令(statement)の実行を記録するカウンタが追加されていることを確認できます。
(関数: cov_qgu7xavc6().f, 分岐: cov_qgu7xavc6().b, 命令: cov_qgu7xavc6().s が該当します。)
このとき、行の実行を直接記録するカウンタ(例えば cov_qgu7xavc6().l のようなイメージです)が存在しないことから、Lines はそれ以外の網羅率とは異なる仕組みで算出されていると推測できます。
Stmts の算出方法
Lines の算出方法について確認する前に、nyc コマンドを実行した際にデフォルトで作成される .nyc_output ディレクトリ配下に実行結果の JSON が生成されているので、そちらを確認します。
この JSON には statementMap というキーが含まれ、次のようにソースファイル上における各命令の開始/終了位置が行番号(line)と桁数(column)で示されています。
元の index.js で同じ 5 行目に含まれる命令(return 文と console.log)は、JSON 上の "2" と "3" に分割される形で正しく解釈されていることがわかります。
他にも、元の discount 関数の冒頭に存在する if 文は JSON 上の "0" に該当し、start と end の値からブロック全体(2 - 4 行目)が 1 つの命令として扱われていること等も読み取れます。
"statementMap": {
"0": {
"start": { "line": 2, "column": 2 },
"end": { "line": 4, "column": 3 }
},
"1": {
"start": { "line": 3, "column": 4 },
"end": { "line": 3, "column": 14 }
},
"2": {
"start": { "line": 5, "column": 2 },
"end": { "line": 5, "column": 30 }
},
"3": {
"start": { "line": 5, "column": 31 },
"end": { "line": 5, "column": 56 }
},
"4": {
"start": { "line": 8, "column": 0 },
"end": { "line": 8, "column": 20 }
},
"5": {
"start": { "line": 9, "column": 0 },
"end": { "line": 9, "column": 20 }
},
"6": {
"start": { "line": 11, "column": 0 },
"end": { "line": 11, "column": 30 }
}
}
この statementMap に含まれる各命令の呼び出し回数を保持しているのが、同じ JSON 内に存在する s キーです。
"s": { "0": 2, "1": 1, "2": 1, "3": 0, "4": 1, "5": 1, "6": 1 }
これにより、5 行目の return 文直後の到達不可能な console.log("3")の呼び出し回数は 0 回であり、それ以外の命令は 1 回以上呼び出されていることを確認できます。
カウントが 1 以上の命令は全体の 7 つのうち 6 つのため 6 / 7 ≒ 0.8571 となり、
この値は前述のカバレッジレポートで出力された Stmts の値と一致しています。
生成された JSON 全体
{
"/path/to/index.js": {
"path": "/path/to/index.js",
"statementMap": {
"0": {
"start": { "line": 2, "column": 2 },
"end": { "line": 4, "column": 3 }
},
"1": {
"start": { "line": 3, "column": 4 },
"end": { "line": 3, "column": 14 }
},
"2": {
"start": { "line": 5, "column": 2 },
"end": { "line": 5, "column": 30 }
},
"3": {
"start": { "line": 5, "column": 31 },
"end": { "line": 5, "column": 56 }
},
"4": {
"start": { "line": 8, "column": 0 },
"end": { "line": 8, "column": 20 }
},
"5": {
"start": { "line": 9, "column": 0 },
"end": { "line": 9, "column": 20 }
},
"6": {
"start": { "line": 11, "column": 0 },
"end": { "line": 11, "column": 30 }
}
},
"fnMap": {
"0": {
"name": "discount",
"decl": {
"start": { "line": 1, "column": 9 },
"end": { "line": 1, "column": 17 }
},
"loc": {
"start": { "line": 1, "column": 31 },
"end": { "line": 6, "column": 1 }
},
"line": 1
}
},
"branchMap": {
"0": {
"loc": {
"start": { "line": 2, "column": 2 },
"end": { "line": 4, "column": 3 }
},
"type": "if",
"locations": [
{
"start": { "line": 2, "column": 2 },
"end": { "line": 4, "column": 3 }
},
{ "start": {}, "end": {} }
],
"line": 2
},
"1": {
"loc": {
"start": { "line": 2, "column": 6 },
"end": { "line": 2, "column": 26 }
},
"type": "binary-expr",
"locations": [
{
"start": { "line": 2, "column": 6 },
"end": { "line": 2, "column": 14 }
},
{
"start": { "line": 2, "column": 18 },
"end": { "line": 2, "column": 26 }
}
],
"line": 2
}
},
"s": { "0": 2, "1": 1, "2": 1, "3": 0, "4": 1, "5": 1, "6": 1 },
"f": { "0": 2 },
"b": { "0": [1, 1], "1": [2, 2] },
"_coverageSchema": "1a1c01bbd47fc00a2c39e90264f33305004495a9",
"hash": "3437363d3b9aa7ba9bbeb144016cb229081baaa8",
"contentHash": "61b4b3a2f8cccbdf457a3472f5bc1a10671d7f6fec51cb40bc6cff6e21b98bd1"
}
}
Lines の算出方法
Stmts の値の根拠を確認できたため、続いて Lines の算出方法も確認します。
istanbul のリポジトリ全体から関連しそうな箇所を探してみると、内包されるパッケージのひとつである istanbul-lib-coverage に getLineCoverage という関数が見つかりました。
上記箇所の実装によると、先ほども取り上げた statementMap や s から各行の呼び出し回数を算出しているようです。
-
sに含まれる各命令のキー("0", "1", "2",...)と呼び出し回数の組み合わせでループ実行 - 1 で取得した各命令のキーを使用し、その命令の開始行を
statementMapから取得 - 2 で取得した行番号をキーにして、
lineMapに実行回数(最大回数)を格納する - 算出結果として
lineMapを返す
実際に、今回の例で出力された JSON の statementMap および s を getLineCoverage 関数に渡すと、次のような結果が得られます。
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ 2 │ 2 │
│ 3 │ 1 │
│ 5 │ 1 │
│ 8 │ 1 │
│ 9 │ 1 │
│ 11 │ 1 │
└─────────┴────────┘
到達不可能な console.log が含まれていた 5 行目も、直前の return 文が実行されていることで lineMap[5] は 1 となり、命令が存在する 6 行すべてのカウントが 1 以上であることから 6 / 6 = 1.0 となります。
Lines が 100% となっている理由はこの算出方法が根拠と言えそうです。
まとめ
Jest のカバレッジレポートで出力される、Stmts と Lines の算出方法について調べた内容をまとめました。
今回は Stmts と Lines の差異に着目して調べ始めましたが、それ以外の Branch や Funcs に対するカバレッジ算出や、istanbul がカバレッジ収集コードを埋めこむ際に行っている解析の内容なども追っていくとよりカバレッジ計測に対する理解が深まりそうな印象です。
なお、istanbul のリポジトリオーナが過去に回答しているとおり、Stmts と Lines の両方を出力する理由は lcov 等の他のカバレッジツールとの互換性や過去の仕様との後方互換性のためとされています。
-
What's the difference between statements and lines? #639
- こちらの Issue が投稿されているリポジトリはアーカイブされていますが、2025 年 12 月時点では istanbuljs/istanbuljs として開発が継続されています
Stmts と Lines のどちらを指標として採用するかは開発プロジェクトによる部分もあるかと思いますので、どちらの網羅率がより優れているかを一概に論じるのは難しいことを最後に補足させていただきます。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。