Code Coverage 98%でも本番環境にバグが流出した理由
半年ほど前、私たちのチームは5万行を超えるTypeScriptで書かれたECサイトの決済モジュールをリファクタリングしていました。Jestのレポートを開くと、チーム全員が安堵のため息をつきました。Line coverage(行カバレッジ)は98%、Branch coverage(分岐カバレッジ)は94%に達していたのです。誰もがコードベースは完全に保護されていると信じ切っていました。
しかし、現実は甘くありませんでした。最初のリリース直後、本番環境で深刻なロジックバグが発生しました。VIP顧客にクーポンが二重適用され、本来200万ドンの注文金額が0ドンになってしまったのです。皮肉なことに、CIパイプライン上では全420件のテストケースがすべてパス(グリーン)のままでした。
テストファイルを1つずつ詳しく調べてみて、致命的な落とし穴に気づきました。Code coverageは、そのコード行が実行されたかどうかを記録しているにすぎません。ロジックが破綻したときに、expect()やassert()が正しくエラーを検知できるかどうかについては、何一つ保証してくれないのです。
多くのテストケースは形式的に関数を呼び出しているだけで、無秩序にモックを行い、レポート上のカバレッジKPIを良く見せるためだけに戻り値のアサーションすら書いていないものもありました。そこで私は、テストスイートの真の品質を白日の下に晒すため、Strykerを用いたMutation Testing(ミューテーションテスト)をプロジェクトに導入することを決めました。
基本概念:Mutation Testingの仕組みとは?
Unit Testが「コードがビジネスロジック通りに正しく動作するか」を検証するものだとすれば、Mutation Testingはその逆を行います。あえてコードを意図的に破壊し、既存のテストスイートがその異常を検知できるかを検証するのです。
プロセスは明確な3つのステップで構成されます:
- 変異の生成(Mutants): StrykerはTypeScriptのAST(抽象構文木)を操作し、意図的に細かなバグを埋め込みます。例えば、
>を>=に変更したり、a + bをa - bに変更したり、関数呼び出しを削除したり、trueをfalseに変えたりします。この意図的に壊されたコード片をそれぞれMutant(変異体)と呼びます。 - テストスイートの実行: Strykerは変更されたコードに関連するUnit Testを再実行します。
- 結果の評価:
- Killed(撃破): テストスイートがFAIL(失敗)すること。これは望ましい結果です。コードが壊れた瞬間にテストが正しく検知したことを意味します。
- Survived(生存): コードのロジックが改ざんされたにもかかわらず、テストがPASS(成功)してしまうこと。これはテストケースがバグを見逃しているか、アサーションが甘いことの明確な証拠です。
- Timeout(タイムアウト): Mutantによって無限ループが発生し、テスト実行がタイムアウト閾値を超えた状態(通常はKilledとして扱われます)。
この結果から得られる核となる指標がMutation Scoreです:
Mutation Score = (KilledされたMutants数 / 全Mutants数) * 100%
Code Coverageが95%を誇るプロジェクトであっても、実際のMutation Scoreを測定すると35%〜40%程度にとどまるケースは珍しくありません。これは、ビジネスロジックの半分以上がテストによって実質的に保護されていないことを意味します。
実践:TypeScriptプロジェクトへのStrykerの導入と設定
以下は、Vitestを採用しているコードベースに私が実際に適用した手順です(Jestでも同様に利用可能です)。
ステップ1:テストの盲点を浮き彫りにするサンプルモジュールの作成
会員ランクに応じて割引額を計算する関数がsrc/discount.tsにあると仮定します:
export interface Customer {
tier: 'SILVER' | 'GOLD' | 'PLATINUM';
yearsActive: number;
}
export function calculateDiscount(orderAmount: number, customer: Customer): number {
if (orderAmount <= 0) {
return 0;
}
let discountRate = 0;
if (customer.tier === 'PLATINUM' || customer.yearsActive >= 5) {
discountRate = 0.2; // 20%割引
} else if (customer.tier === 'GOLD') {
discountRate = 0.1; // 10%割引
}
return orderAmount * discountRate;
}
そして、カバレッジ稼ぎのために急ぎ足で書かれたテストファイルtests/discount.test.tsがこちらです:
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../src/discount';
describe('calculateDiscount', () => {
it('ロジックの各分岐を通過すること', () => {
// orderAmount <= 0 の分岐をテスト
expect(calculateDiscount(-10, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
// PLATINUMの分岐をテスト
const platinumResult = calculateDiscount(100, { tier: 'PLATINUM', yearsActive: 1 });
expect(platinumResult).toBeGreaterThan(0);
// GOLDの分岐をテスト
const goldResult = calculateDiscount(100, { tier: 'GOLD', yearsActive: 2 });
expect(goldResult).toBe(10);
});
});
vitest run --coverageを実行すると、レポートは100%のオールグリーンを表示します。しかし、PLATINUM以外の顧客に対してcustomer.yearsActive >= 5の条件が個別に検証されていません。さらに深刻なことに、PLATINUMのアサーションは正確な値である20を検証せず、単にtoBeGreaterThan(0)でお茶を濁しています。
ステップ2:Stryker Mutatorのインストール
コアパッケージと対応するテストランナーをインストールします:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
プロジェクトでJestを使用している場合は、@stryker-mutator/jest-runnerに変更してください。
ステップ3:設定ファイル stryker.config.json の作成
ルートディレクトリにstryker.config.jsonを作成します:
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"reporters": ["html", "clear-text", "progress"],
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"mutate": [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/index.ts"
],
"thresholds": {
"high": 80,
"low": 60,
"break": 60
}
}
特に注目すべき3つの設定項目:
coverageAnalysis: "perTest":必須の最適化オプションです。Strykerは変異が注入されたコード行をカバーしているテストのみを再実行します。これにより、実行時間を15分から2分未満へと大幅に短縮できます。mutate:ビジネスロジックを含むファイルのみを対象とし、型定義(types)、インターフェース、barrel export(index.ts)などは完全に除外します。thresholds.break:Mutation Scoreが60%を下回った場合、プロセスが終了コード1を返し、コードのマージを阻止します。
ステップ4:実行とエラーレポートの分析
package.jsonにスクリプトを追加します:
"scripts": {
"test:mutate": "stryker run"
}
コマンドを実行します:
npm run test:mutate
ターミナルに生き残った(Survived)2つのMutantが即座に指摘されます:
#1. [Survived] EqualityOperator
src/discount.ts:12:47
- customer.yearsActive >= 5
+ customer.yearsActive > 5
#2. [Survived] BinaryExpression
src/discount.ts:13:20
- discountRate = 0.2;
+ discountRate = 0.0;
Strykerは>= 5を> 5に書き換え、割引率0.2を0.0に変更しました。それでもテストスイート全体がPASSしてしまいました。コードが破壊されたにもかかわらず、テストは何も検知できなかったのです。
ステップ5:生き残ったMutantを撃破するためのテストの書き直し
境界値を適切にカバーするテストケースをtests/discount.test.tsに追加します:
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from '../src/discount';
describe('calculateDiscount', () => {
it('注文金額が0以下の場合は0を返すこと', () => {
expect(calculateDiscount(0, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
expect(calculateDiscount(-100, { tier: 'SILVER', yearsActive: 1 })).toBe(0);
});
it('PLATINUM会員には正確に20%の割引を適用すること', () => {
const result = calculateDiscount(100, { tier: 'PLATINUM', yearsActive: 1 });
expect(result).toBe(20); // toBeGreaterThanを使わず、正確な値を検証
});
it('PLATINUM以外でも利用年数が5年以上の顧客には20%の割引を適用すること', () => {
// 5年目の境界値をテスト
expect(calculateDiscount(100, { tier: 'SILVER', yearsActive: 5 })).toBe(20);
// 4年目の顧客にはこの特典は適用されない
expect(calculateDiscount(100, { tier: 'SILVER', yearsActive: 4 })).toBe(0);
});
it('5年未満のGOLD会員には10%の割引を適用すること', () => {
expect(calculateDiscount(100, { tier: 'GOLD', yearsActive: 2 })).toBe(10);
});
});
再度npm run test:mutateを実行します:
Mutation score based on treated mutants: 100.00%
All mutants were killed!
reports/mutation/mutation.htmlを開くと、ディレクトリツリー全体が鮮やかなグリーンに変わっています。不正なロジック改変がすべてテストによって捕捉された証拠です。
実運用6ヶ月で得られた実践的なノウハウ
チームの実運用パイプラインにStrykerを組み込んで得られた、3つの貴重な教訓を紹介します:
- リポジトリ全体をスキャンしない: Mutation Testing最大の弱点はリソース消費の激しさです。5万行のコード全体に対して実行すると40分以上かかり、CPU使用率も100%に達してしまいます。これは非常に非効率です。
- コアビジネスロジックにスコープを絞る:
src/modules/pricingやsrc/modules/payment、src/modules/authといった極めて重要なロジックを含むディレクトリにのみStrykerを設定します。このように範囲を絞ることで、テスト全体をわずか90秒で完了させることができます。 - CIへの賢い組み込み方: すべてのコミットで実行するのではなく、GitHub Actionsで
--since origin/mainフラグを活用します。Pull Requestで変更があったファイルのみをStrykerの対象にすることで、各PRのビルド時間の増加を2分未満に抑えています。
高いCode Coverageは、単に熱心にテストコードを書いたことを証明しているにすぎません。障害が発生した際に、そのテストが本当にシステムを守ってくれるかを測る真の指標こそが、Mutation Testingなのです。
