Claude CodeでStrykerのミューテーションテストを回し弱いテストを補強する
Strykerの生存ミュータントをClaude Codeに渡してテストを補強させ、mutation scoreの下限をStop hookで完了条件にする手順です。設定例とCLAUDE.mdの書き方を載せます。
Claude Codeに「テストを書いて」と頼むと、行カバレッジは上がります。ただしそのテストが、コードの間違いを本当に検出できるかは別の話です。StrykerJSのミューテーションテストは、本番コードに小さな改変(ミュータント)を入れて、テストが落ちるかどうかを見ます。落ちなかった改変が「生存ミュータント」で、テストの穴の場所をそのまま示します。この一覧をClaude Codeに渡し、mutation scoreの下限を完了条件にすれば、弱いテストの補強を自動で回せます。
生存ミュータントは「テストの穴」の一覧になる
Strykerはテストを実行し、各ミュータントについて結果を出します。テストが失敗して改変を検出できればKilled、検出できなければSurvivedです。mutation scoreは、この検出できた割合を指します。
行カバレッジは「その行が実行されたか」しか見ません。アサーションが甘いテストでも、行を通れば100%になります。ミューテーションテストは、実行された行の結果をテストが本当に確かめているかを測る点が違います。
Strykerは、カバレッジ分析を有効にするとSurvivedとNoCoverageを区別します。
SurvivedとNoCoverageの違い
Survived
ミュータントの箇所をテストが実行したのに、失敗しなかった状態です。既存テストの確認が甘いので、期待値や境界の検証を足します。
NoCoverage
どのテストも、その箇所を実行していない状態です。新しいテストケースで、その分岐に入る入力を作ります。
Claudeへの指示も分けたほうが、意図に沿った修正が返ってきます。
Strykerを設定する
セットアップは公式のとおり、次のコマンドで始めます。初期化の質問に答えるとstryker.config.mjsが作られます。
npm init stryker@latest
npx stryker run以降はVitestを使うプロジェクトを例にします。VitestランナーはStryker v7.0から使えるプラグインで、@stryker-mutator/vitest-runnerを入れてtestRunnerにvitestを指定します。
{
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.spec.ts"],
"reporters": ["clear-text", "json"],
"coverageAnalysis": "perTest",
"incremental": true,
"thresholds": { "high": 80, "low": 60, "break": 60 }
}各キーの役割は次のとおりです。
| キー | 効果 | 既定値 |
|---|---|---|
mutate | 効果改変する本番コードの範囲。テストファイルは含めない | 既定値src・lib配下のjs系ファイル(テストを除く) |
reporters | 効果結果の出力先 | 既定値clear-text, progress, html |
incremental | 効果前回結果を再利用し、変更箇所だけ再実行する | 既定値false |
thresholds.break | 効果mutation scoreがこれを下回ると終了コード1 | 既定値null(ビルドを落とさない) |
thresholdsは3つの値をすべて指定する形式で、1つだけの指定はできません。breakの既定はnullなので、何も書かなければscoreが低くても失敗しません。完了条件に使うには、ここに数値を入れることが前提になります。
clear-textレポーターは、既定でミュータントの一覧とスコア表を出力します。jsonレポーターの出力先は既定でreports/mutation/mutation.jsonです。Claudeに読ませる素材として、標準出力の一覧か、このJSONのどちらかを使えます。
弱いテストに回すと、生存ミュータントはこう出る
StrykerJS 9.6.1とVitestで、数量割引の関数に弱いテストを当てて回しました。10個以上で1割引、50個以上で2割引になる関数です。
export function discountedPrice(price: number, quantity: number): number {
let rate = 0;
if (quantity >= 10) {
rate = 0.1;
}
if (quantity >= 50) {
rate = 0.2;
}
return price * quantity * (1 - rate);
}テストは「1個で通常価格」と「20個で1割引」の2件だけです。行カバレッジなら、50個の分岐以外はほぼ通ります。
[NoCoverage] BlockStatement
src/pricing/discount.ts:6:23
- if (quantity >= 50) {
- rate = 0.2;
- }
+ if (quantity >= 50) {}
[Survived] EqualityOperator
src/pricing/discount.ts:3:7
- if (quantity >= 10) {
+ if (quantity > 10) {
discount.ts | 71.43 | 76.92 | killed 10 | survived 3 | no cov 114個のミュータントのうち、倒せたのは10個です。10÷14が71.43、NoCoverageを除いた10÷13が76.92で、表のtotalとcoveredの2列はこの違いです。
境界値が>=から>に変わっても、2件のテストは落ちません。9個と10個、49個と50個を確かめるテストがないためです。
同じ関数での補強の経過
- 1
弱いテスト2件
mutation scoreは71.43で、
breakが60なので実行は成功します。完了条件が60だと、このテストでも通ってしまいます。 - 2
境界のテストを4件追加
9個・10個・49個・50個の期待値を足すと、14個すべてがKilledになり、scoreは100.00です。
- 3
何も変えずに再実行
incrementalが有効だと、ログに14 of 14 mutant result(s) are reusedと出て、14個とも前回の結果を使い回します。
生存ミュータントをClaudeに渡して補強させる
全ファイルを一度に改変すると時間がかかります。改変対象は、--mutateで1ファイルか1ディレクトリに絞って渡します。
npx stryker run --mutate src/pricing/discount.ts出力にSurvivedのミュータントが残ったら、そのままClaude Codeに貼ります。例えば次のように指示します。
src/pricing/discount.ts の Stryker 結果を貼ります。
Survived を 1 件ずつ見て、次の順で対応してください。
1. そのミュータントで挙動が変わる入力を特定する
2. 既存テストに足りないアサーションか、テストケースを追加する
3. npx stryker run --mutate src/pricing/discount.ts を再実行して
Killed に変わったことを確認する
本番コード(discount.ts)は変更しないでください。最後の一文が要点です。生存ミュータントを消す近道は、本番コードを書き換えてミュータント自体をなくすことです。テストの補強が目的なら、変更してよい範囲を明示しておきます。
再実行の待ち時間は、incrementalが縮めてくれます。Killedだった結果は、そのミュータントを倒したテストが残って変更もなければ再利用されます。Killedでないミュータントも、新しいテストがそれを覆わず、テストが変更されていなければ再利用対象です。逆に言えば、Claudeが足したテストで判定が変わるミュータントは、再実行の対象に入ります。CTRL+Cなどで実行を途中で止めた場合も、それまでの結果は増分ファイルに保存され、次の増分実行はその続きから始まります。
Claudeに渡す出力を絞る
出力が長いと、貼る量が増えて文脈を圧迫します。clearTextReporterのオプションで、渡す内容を絞れます。
{
"clearTextReporter": {
"reportTests": false,
"logTests": false,
"reportScoreTable": true,
"skipFull": true
}
}reportTestsとlogTestsをfalseにすると、実行したテストの一覧が出力から外れます。生存ミュータントの修正に、全テストの列挙は要りませんskipFullをtrueにすると、スコア表からmutation score 100%のファイルが消えます。要対応のファイルだけが残りますreportMutantsは既定でtrueです。生存ミュータントの一覧が必要なので、切らないでください
TypeScriptのプロジェクトでは、disableTypeChecksも知っておくと役立ちます。Strykerは改変を入れる際に型エラーを作ってしまうため、型チェックを無効にしたうえで動きます。v7.0以降の既定はtrueで、対象ファイルの先頭に// @ts-nocheckが挿入されます。型エラーを検出したい場合の設定は別にあるので、まず既定のまま動かして生存ミュータントの一覧を得るのが出発点です。
CLAUDE.mdに補強のルールを書く
毎回プロンプトを書く代わりに、ルールをCLAUDE.mdへ置きます。公式は、CLAUDE.mdを1ファイル200行未満に保つことと、検証できる具体的な書き方を勧めています。また、特定の場所でだけ効く指示はパス指定のルールに分ける構成が案内されています。ミューテーション関連の記述は、src配下の作業でだけ読まれれば足ります。
次は.claude/rules/mutation-testing.mdの例です。中身は公式のパス指定ルールの書式に沿った、この記事での一例です。
---
paths:
- "src/**/*.ts"
---
# ミューテーションテスト
- 対象ファイルを変更したら
`npx stryker run --mutate <変更したファイル>` を実行する
- Survived のミュータントは、テスト側にアサーションかケースを足して倒す
- 本番コードを、ミュータントを消す目的で変更しない
- `// Stryker disable` コメントは、人の承認なしに追加しない
- mutation score は 60 以上を保つ(設定の thresholds.break と同じ値)「60以上」のように数値を書くと、Claudeが守れているかを確認できます。「テストを十分に強くする」のような書き方だと、公式が指摘する曖昧さの問題に当たります。
ただし、CLAUDE.mdは強制ではありません。公式は、CLAUDE.mdをシステムプロンプトではなくユーザーメッセージとして渡される文脈と説明し、厳密な遵守は保証されないとしています。特に「必ずこの時点で実行する」種類の指示は、フックにするよう案内されています。
Stop hookでmutation scoreを完了条件にする
Stop hookは、Claudeが応答を終えるたびに発火します。ここでStrykerを走らせ、thresholds.breakを下回っていたら差し戻す構成にします。Strykerはscoreがbreak未満なら終了コード1で終わるので、コマンドの成否がそのまま判定になります。
.claude/settings.jsonには次のように登録します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/check-mutation-score.sh",
"timeout": 900
}
]
}
]
}
}スクリプトは、公式が示すStopフックの型に従います。標準入力のJSONからstop_hook_activeを読み、trueならすぐ終了します。差し戻しは、トップレベルのdecision: "block"とreasonを返す形式です。reasonはClaudeに戻され、作業が続きます。
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! OUT=$(npx stryker run --incremental 2>&1); then
jq -n --arg r "mutation score が下限未満です。Survived を補強してください。$(echo "$OUT" | tail -n 40)" \
'{decision: "block", reason: $r}'
fi
exit 0--mutateを付けていないので、設定ファイルのmutate範囲すべてが対象です。範囲が広いプロジェクトでは、変更ファイルだけを渡す運用にしたほうが現実的です。
前節のデモでbreakを80にし、テストを弱い2件に戻して、このスクリプトに{"stop_hook_active":false}を渡しました。Strykerは終了コード1で終わり、スクリプトはdecisionがblockのJSONを返します。reasonには、NoCoverageとSurvivedの一覧を含むログの末尾が入ります。
1件のミュータントは出力で5行前後を使います。生存が8件を超えるあたりから、tail -n 40では一覧が収まりません。行数は調整してください。
このスクリプトには考慮点が3つあります。
stop_hook_activeで抜ける形は、差し戻しが1回で終わります。補強が1往復で終わらないなら、この判定を外して回数を伸ばせます。ただしClaude Codeは、Stopフックが続行を8回連続で指示すると、次のブロックを上書きして応答を終えます。上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられますtimeoutに達したcommandフックは取り消され、出力は捨てられます。Strykerが900秒を超えると差し戻しは返らず、応答がそのまま終わります。範囲を--mutateで絞るか、timeoutを延ばします- Stopフックは、ユーザーが中断したときには発火しません。完了条件の最後の砦とは言い切れないため、CIでも同じ
stryker runを実行しておくと二重に守れます
テスト実行をフックに載せる基本形は、Claude Codeでテストを自動実行するhooksの記事にあります。PostToolUseとStopのどちらに置くかの判断もそこで扱っています。Strykerは1回の実行が長いので、PostToolUseではなくStopに置く構成が向いています。
つまずきやすい点
テストだけを通すコードを書かせない
Survivedを倒す目的で、Claudeが特定の入力にだけ反応する分岐を足す余地は残ります。指示に「一般式で書く」と明記するのが基本で、詳しくはClaudeがテストだけ通すハードコードを防ぐプロンプトの書き方にまとめています。
Vitestのrelatedが効かない構成がある
Vitestランナーは、既定で改変ファイルに関連するテストだけを走らせます。テストが本番コードを直接importせず、HTTP経由でサーバーを呼ぶ統合テストでは、関連が見つかりません。その場合はvitest.relatedをfalseにします。Vitestのブラウザモードは、このランナーで未対応です。
incrementalが見ない変更がある
incrementalが検出するのは、改変対象ファイルとテストファイルの変更です。それ以外のファイル、更新した依存、環境変数、.snapファイルの変更は検出されません。設定や依存を大きく変えたら、--incrementalを外すか、--forceを付けて全件を回し直します。--forceは増分ファイルの内容にかかわらず範囲内のミュータントを再実行します。
commandランナーは遅い
テストランナー用のプラグインがない環境では、npm testを実行するcommandランナーが既定です。カバレッジ分析ができないため、すべてのミュータントで全テストを走らせることになります。
タイムアウトで落ちるミュータントがある
改変が無限ループを生むことがあるため、Strykerはテスト実行に時間制限を設けます。上限はnetTimeMs * timeoutFactor + timeoutMS + overheadMsで計算され、timeoutMSの既定は5000です。負荷の高いマシンで誤判定が出るなら、timeoutMSを延ばします。
改変対象にテストファイルを入れない
mutateに指定するのは本番コードで、テストではありません。Vitestのin-source testingを使う場合は、テストが同じファイルに入るため、// Stryker disable allのコメントでテスト部分を除外する必要があります。
使い分けの早見表
| 場面 | 向いているやり方 |
|---|---|
| 1ファイルを改修中 | 向いているやり方--mutate <file> を手動で実行し、結果を貼る |
| 反復して補強を回したい | 向いているやり方incremental: true とCLAUDE.mdのルール |
| 完了条件として強制したい | 向いているやり方Stop hook + thresholds.break |
| プルリクエストごとの品質ゲート | 向いているやり方CIで同じ stryker run |
行カバレッジを埋めるところまでは、Claude Codeでテストを書かせる実践手順の流れが使えます。その後にStrykerを重ねると、「書いたテストが実際に効いているか」まで確かめられます。
まとめ
Strykerの生存ミュータントは、テストの穴を具体的な差分で示します。この一覧をClaude Codeに渡せば、補強は「何をどう足すか」がはっきりした作業になります。完了条件にするならthresholds.breakに数値を入れ、Stop hookで強制します。ただしhookにも上限と例外があるので、CIでの同じ検査も併せて置くと安心です。