はじめに
テストを書いていると、必ずカバレッジの数字と向き合う場面が出てきます。
みなさんのチームでは、カバレッジの閾値ってどう決めてますか?「とりあえず80%」「新規コードだけ90%」あたりでCIのゲートを引いているところが多い気がしています。
私は基本的に、カバレッジは100%にしておくべきだと考えています。ただ、この記事で言いたいのはそれだけではなくて、「100%は何を保証していて、何を保証していないのか」の話までセットでしておきたいと思っています。この境界線を曖昧にしたまま100%を目指すと、わりと痛い目に遭う気がしているので。
量の軸と質の軸は別物という前提
カバレッジの話をする前に、一つ前提を置かせてください。
カバレッジは「そのコードが実行されたかどうか」しか見ません。実行された処理が正しい結果を返しているか、期待通りの振る舞いをしているかは、また別の話です。つまりカバレッジは量の指標であって、質の指標ではありません。
この記事で扱うのは、基本的に量の話です。
たとえば「このメソッドは今、こういう入力に対してこういう値を返す」という、今の挙動をそのまま記録して壊れていないかを見張るためのテストが対象です。Michael Feathersが『Working Effectively with Legacy Code』で「特性化テスト」と呼んでいるものに近いイメージです(正直に言うと原著は読んだことがなくて、あちこちで聞きかじった知識なんですけどね)。
一方で「このメソッドは仕様上こう動くべきだから、バグを仕様通りに直す」という、あるべき挙動を検証するテスト(仕様ベースのテスト)は、この記事のスコープ外です。
図にするとこんな感じの流れです。
なぜこの区別が要るかというと、特性化テストがないと、その先の仕様ベースのテストが薄くなるか、工数が跳ね上がるかのどちらかになるからです。今の振る舞いが凍結されていない状態でリファクタリングやバグ修正をすると、直したつもりが別の場所を壊していないか、毎回目視で確認する羽目になります。だからまず量を確保する、という順番で考えています。
100%を狙いたい理由
これはAIが出てくる前からずっとやってきたことでもあります。個人開発では基本的にカバレッジ100%にしていますし、会社に勤めていた頃も、テストを書くときは変更したファイルに対応する単体テストについて、ツール上のカバレッジが100%になるように書くのが基本の考えでした。C0(命令網羅)なのかC1(分岐網羅)なのかC2(条件網羅)なのかMCC(複合条件網羅)なのか、どの基準で見るかは現場によってまちまちでした(このへんの違いに触れ出すと長くなるので、今回は深入りしません)。ただ「ツールが100%と表示する状態」を目指すというスタンス自体はずっと変わっていません。
数年前まで、カバレッジの閾値を90%とか85%とかの中途半端な数字に設定しているのを、色んなところで見聞きしてきました。ネット記事でそういう運用が紹介されていたり、会社でもそう話している人がいたりしたのですが、正直「その数字に何の根拠があるんだろう」とずっと引っかかっていました。閾値を上げ下げする調整自体もメンテナンスコストになりますし、そもそもその数字で品質が保証できているのかも、はっきりしないままでした。多分あれは、工数の都合で「このへんで手を打とう」という妥協の産物だったんじゃないかと考えています。
今は状況が変わってきました。AIに任せれば、分岐ごとのテストケースを機械的に書き出すこと自体はそこまで大変な作業ではなくなっています。工数がボトルネックだった時代の「中途半端な数字」に、もうこだわる理由がなくなってきている気がします。
ただ、ここは包み隠さず書いておくと、AIに「カバレッジを上げて」とだけ頼むと、アサーションを増やすのではなく「とにかくその行を通す入力」を増やしがちです。量産できることと、意味のあるテストが増えることはイコールではありません。この話は記事の最後でもう一度触れます。
もう一つ、100%というキリのいい数字には別のメリットもあると思っています。90%のような閾値を狙うと、漏れている残り10%のどこまでなら許容できるかを、その都度判断する必要が出てきます。この判断に地味に時間を使うのがもったいないと感じています。
- 90%だと、残りの10%のどこが「許容していい漏れ」なのかを都度判断しないといけない
- 100%だと、そもそも判断すべきグレーゾーンが存在しない
100%の価値は、品質が高い証拠というより、この判断コストがかからないという点が大きい、というのが今の自分の感覚です。
それでも100%にならないところは、書き方や設定を疑う
とはいえ、実際にカバレッジを追いかけていると、どうしても100%にならない箇所に当たることがあります。そういうとき、テストを無理やり追加する前に、まず書き方や設定の側を疑うようにしています。同じ挙動でも、コードの構造や計測の設定次第でカバレッジの数字は変わるからです。
自分がよくやる手をいくつか紹介します。
1. ガード節に書き換える
ネストしたif文の中にreturnを埋め込むと、暗黙のelseパスがコードの外から見えづらい形で残ります。
// Before
function findDelimiterIndex(buffer: Uint8Array): number {
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === DELIMITER_BYTE) {
return i;
}
}
throw new Error('delimiter not found');
}
条件を反転してガード節にすると、分岐が一直線になります。
// After
function findDelimiterIndex(buffer: Uint8Array): number {
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] !== DELIMITER_BYTE) continue;
return i;
}
throw new Error('delimiter not found');
}
実際、自分のプロダクトでも「カバレッジ計測誤検知を解消」という名前でこのリファクタリングをしたコミットがあります。狙って直したというより、テストを書きながら「あれ、この分岐って本当に2パターンあるんだっけ」と気づいて直した、という方が近いです。
2. 分岐を型・構造で潰す
複合条件をifにベタ書きすると、組み合わせのパターンを網羅するテストが必要になります。
// Before
if (fieldName === 'id' || field.readonly || field.hidden) {
return;
}
述語関数に切り出すと、呼び出し側はtrueかfalseかの2択だけ見ればよくなります。
// After
const isSkippableField = (field: FieldDescriptor, fieldName: string): boolean =>
fieldName === 'id' || !!field.readonly || !!field.hidden;
if (isSkippableField(field, fieldName)) {
return;
}
同じ考え方は、型を使って「到達しないケース」自体をなくす方向にも広げられます。
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';
const getStatusLabel = (status: OrderStatus): string => {
switch (status) {
case 'pending':
return '注文受付中';
case 'paid':
return '支払い済み';
case 'shipped':
return '発送済み';
case 'cancelled':
return 'キャンセル済み';
}
};
OrderStatusは4値のUnionで、switchはそのすべてをカバーしています。defaultも例外送出も要らないので、「念のため」の分岐そのものが存在しません。値が増えたら型チェックが網羅漏れとして検知してくれます。istanbul ignoreのようなコメントで到達不能コードを隠すぐらいなら、まずこっちを疑ったほうがいいと思っています。
実はこの「述語関数への切り出し」、自分は運用ルールとして徹底しています。使っているカバレッジツールはC2(条件網羅)までは自動で計測してくれません。かといってif文に&&や||を直接書くと、複合条件の組み合わせパターンをテストで手動管理する必要が出てきて、これがかなりつらいです。
なのでESLintのカスタムルールで、if/while/for/三項演算子の条件式に&&・||を直接書くこと自体を禁止しています。さっきのisSkippableFieldのような書き方を、ルールで強制している形です。条件式に直接&&・||を書くと、こんなエラーが出ます。
複合条件(&&/||)は名前付き述語関数またはガード節に分解してください。
例: const isEligible = () => hasPermission && isActive;
if (isEligible()) { ... }
複合条件を許さない構造にしてしまえば、if文側は常に「関数呼び出し1つ」という単純な分岐になります。C2の組み合わせテストが必要なのは述語関数の中だけで、しかもそこは単体でテストできるので、実質C1(分岐網羅)だけ見ておけば済む形になります。C2が取りづらいツールでも、運用でC1に寄せてしまえばカバレッジの信頼性を落とさずに済む、というのが自分のやり方です。
3. test.eachでパラメータ化する
似たテストケースを1つずつ書くと、ケースが増えるたびにコピペが増えていきます。
it.each(['jpg', 'png', 'gif', 'webp'])(
'%s拡張子の画像ファイルを許可する',
(ext) => {
expect(isAllowedImageExtension(`sample.${ext}`)).toBe(true);
},
);
配列を1行足すだけでケースを追加できる形にしておくと、カバレッジを保つコストがかなり下がります。
4. DIやモックで到達困難な分岐を到達可能にする
catch節のような、正常系のテストだけでは通せない分岐もあります。
// Before
async function fetchUserName(id: string): Promise<string> {
try {
const res = await fetch(`/api/users/${id}`);
return (await res.json()).name;
} catch {
return 'unknown';
}
}
fetcherを注入できるようにしておくと、テスト側から失敗するfetcherを渡してcatch節を再現できます。
// After
async function fetchUserName(
id: string,
fetcher: typeof fetch = fetch,
): Promise<string> {
try {
const res = await fetcher(`/api/users/${id}`);
return (await res.json()).name;
} catch {
return 'unknown';
}
}
「テストが書きづらいから諦める」ではなく「テストが書けるように構造を変える」という、ここまでとは少し違う角度の一手です。
5. そもそも計測対象から外す
ここまではコードを書き換える話でしたが、対象範囲そのものを見直すという手もあります。
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
exclude: [
'src/main.ts',
'src/**/*.generated.ts',
'src/migrations/**',
],
},
},
});
エントリーポイントや自動生成コード、一度きりのマイグレーションスクリプトまで無理に分岐を潰しにいくのは、あまり意味がないと思っています。100%を目指すべき範囲を見直す、というのも立派な選択肢です。
カバレッジは必要条件であって、十分条件ではない
ここまで紹介した手、特に1番目と2番目は、よく見ると「分岐という分母そのものを減らす」操作でもあります。同じ挙動でも書き方次第でカバレッジの%が動くということは、裏を返すと「100%達成」がそのまま「全パターン検証済み」を意味するわけではない、ということです。この記事のテクニック集自体が、その事実をそのまま体現している構成になっているのが、書いていて少しおもしろかったところです。
なので自分の立場を正確に言うと、カバレッジは必要条件だと思っています。実行されなかったコードは、検証されようがありません。ただし十分条件ではありません。実行されたことと、正しく検証されたことは別問題だからです。
この隙間を埋めるのが、ミューテーションテストだと思っています。プロダクトコードに小さなバグ(ミュータント)をわざと仕込んで、テストがそれを検出できるかを確認する手法です。カバレッジが100%でも、expect(true).toBe(true)のようなアサーションのないテストが混ざっていれば、ミュータントは検出されずに生き残ります。実際、2026年に入ってから「AIが書いたユニットテストが怖くて、ミューテーションテストを入れてみた」というような記事をちらほら見かけるようになりました。AIでテストを量産できるようになったからこそ、その中身を疑う手段としてミューテーションテストの立ち位置が上がってきているんだと考えています。
図にすると、こんな2段構えのイメージです。
とはいえ、ミューテーションテストを通せば正しさが証明される、というほど強い話でもありません。あくまで確からしさを引き上げる追加のふるいで、テストスイートをミュータントの数だけ回すのでコストも軽くはなく、PRごとにフルで回すというよりは対象を絞って運用することになりそうです。
おわりに
まとめると、カバレッジは特性化テストの文脈での話であって、量を確保するための指標です。量が確保されていないと、その先の質を担保する仕様ベースのテストも工数が跳ね上がってしまうので、まずはここを固めておきたい、というのが自分のスタンスです。100%はゴールではなく、そこから先の議論をするためのスタートラインです。