品質保証 2026年7月19日

カバレッジ92%は緑なのにバグが出る。テストの質をミューテーションテストで測った話

XECIN テスト設計ミューテーションテストStryker技術検証

きっかけは、カバレッジのグラフがきれいな右肩上がりなのに、現場の実感が追いついてこない、という違和感でした。

社内のTypeScript製ロジック(見積り計算やフォームの入力チェックまわり)に、Claudeでテストをどんどん足していった時期があります。カバレッジは60%台から92%まで上がりました。数字だけ見れば、品質は着実に良くなっているはずでした。

ところが、本番でぽろぽろ出る小さな取りこぼし——消費税の端数がひとつずれる、上限金額ちょうどの入力が弾かれない——が、期待したほど減らなかった。正直なところ、最初は「テストの数が足りないだけだろう」と思っていました。もっとカバレッジを上げれば直ると。

今思えば、そこが勘違いだったんですよね。追う指標を間違えていた。

カバレッジは「量」であって「質」ではない

テストは目的ではなく手段です。私たちが本当に欲しいのは「バグを検出できること」であって、「コードが実行されたこと」ではありません。ところがカバレッジが測っているのは後者だけです。

よく使う例えで言うと、カバレッジは火災報知器のテストで「煙が部屋の92%まで届いた」と言っているようなものです。届いたのは分かった。でも肝心の「報知器が鳴るか」は別の話です。煙が来ても鳴らない報知器は、いくら煙を部屋中に回しても意味がない。

つまり、動いた=品質OK ではない。テストがそのコード行を通っていても、そこにまともなアサーションが無ければ、バグはすり抜けます。カバレッジ92%でも、バグ検出力は50%台、みたいな逆転はふつうに起こります。

「測れないものは改善できない」というのが私の信条なんですが、このときの私は肝心の「テストの質」をまったく測れていなかった。カバレッジという測りやすい代理指標で、測った気になっていただけでした。

テストの質を測る=わざとコードを壊してみる

テストの質を直接測る方法が、ミューテーションテストです。仕組みはいたってシンプルで、コードをわざと1箇所だけ壊して(ミュータントを埋め込んで)、テストがそれに気づくかを見る、それだけです。

  • テストが落ちた → そのバグは検出できた(killed
  • テストが全部通ってしまった → バグを見逃した(survived

埋め込んだミュータントのうち、どれだけ殺せたかを比率にしたのがミューテーションスコアです。

ミューテーションスコア = killed / (有効なミュータント総数) × 100

ツールはコードをAST(構文木)として理解して、+- に、>=> に、&&|| に……といった具合に機械的に書き換えたコピーを大量に作り、それぞれに対してテストを回します。JS/TSなら実質 Stryker 一択で、うちはテストランナーに Vitest を使っているのでそのまま噛ませられました。

設定はこんな最小構成から始めました。

// stryker.config.json
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "testRunner": "vitest",
  "coverageAnalysis": "perTest",
  "mutate": ["src/lib/estimate/**/*.ts"],
  "thresholds": { "high": 85, "low": 70, "break": 60 },
  "incremental": true,
  "incrementalFile": "reports/stryker-incremental.json"
}

coverageAnalysis: "perTest" が地味に効きます。ミュータントごとに「そこを通るテストだけ」を回してくれるので、無関係なテストを毎回全部実行せずに済む。最初にこれを知らずに全対象で回して、えらく待たされました。

測ってみたら、見積りモジュールが58%だった

最初の対象は、いちばん怖い見積り計算モジュールにしました。お金の計算はミスが即クレームになるので、優先度が高い。

回した結果が、カバレッジ92%に対してミューテーションスコア58%。埋め込んだミュータント約140個のうち、4割が生き残っていました。正直、この数字を見たときは少し背筋が寒くなりました。カバレッジのグラフを見て安心していた自分が、だいぶ間抜けに思えたんですよね。

生き残ったミュータントを一つずつ潰していくと、弱いテストの壊れ方はきれいに3パターンに分かれました。現場で共有するために表にしています。

生き残りの型なぜミュータントが殺せないか直し方
アサーション欠落呼ぶだけ・null でないことしか見ておらず、値を検証していない戻り値そのものを期待値と突き合わせる
トートロジー実装の出力をそのまま期待値にコピペしていて、絶対に落ちない期待値を手計算・仕様から独立に書き起こす
ハッピーパス偏重正常系しか叩かず、境界値・異常系を通らない境界のちょうど上下・0・上限超えを足す

いちばん多かったのが2番目のトートロジーでした。これはAIにテストを書かせると本当に量産されます。実装を読んで、その出力をそのまま expected に置く。人間が見てもパッと見は正しそうに見えるんですが、こういう構造になっている。

// 生き残るテストの典型(絶対に落ちない)
it("消費税込みの合計を計算する", () => {
  const cart = buildCart();
  const expected = calcTotal(cart);   // ← 実装の出力をそのまま期待値に
  expect(calcTotal(cart)).toBe(expected);
});

これだと calcTotal の中の Math.floorMath.ceil に書き換えても、期待値も一緒にズレるので永遠に通ります。テストしているようで、何も検証していない。

境界のほうも典型的でした。上限金額の判定が amount >= LIMIT だったんですが、ここを amount > LIMIT に変えても、テストが LIMIT ちょうどのケースを持っていなかったので、まるっと生き残っていた。ドキュメントには「上限以上は弾く」と書いてあるのに、上限ちょうどのテストが1本も無かったんです。

直すときは、境界を仕様から起こしてテーブル駆動で並べ直しました。

// 境界と異常系を仕様から独立に列挙する
describe("上限判定", () => {
  it.each([
    [LIMIT - 1, false],  // ちょうど下
    [LIMIT,     true],   // 境界そのもの(>= と > の差が出る)
    [LIMIT + 1, true],   // 超過
    [0,         false],  // 下限
  ])("amount=%i → 弾く=%s", (amount, blocked) => {
    expect(isOverLimit(amount)).toBe(blocked);
  });

  it("負数は不正としてエラー", () => {
    expect(() => isOverLimit(-1)).toThrow(RangeError);
  });
});

この調子で生き残りを潰していったら、スコアは58%から78%まで上がりました。カバレッジはほぼ変わっていません(もともと92%)。増えたのは行数ではなく、アサーションの中身のほうだった、というのがこの作業のいちばんの収穫でした。

重いのをどう飼いならすか

ミューテーションテストの弱点は、とにかく実行が重いことです。理屈上は「ミュータントの数 × テスト実行時間」がまるごとかかる。最初にモジュール全体を素で回したときは8分ほどかかりました。これを毎PRで回すのは非現実的です。

なので、割り切りをいくつか入れました。

まず coverageAnalysis: "perTest"incremental: true の併用。差分だけを対象にすると、2回目以降は90秒前後まで落ちます。CIでは incremental のキャッシュファイルを保存して、変更のあったコードだけ測り直す運用にしました。

# .github/workflows(抜粋)差分だけ測る
- uses: actions/cache@v4
  with:
    path: reports/stryker-incremental.json
    key: stryker-incremental-${{ github.ref_name }}
- run: npx stryker run --incremental

もうひとつが thresholds.break。スコアがこの値を下回ると Stryker が exit code 1 を返すので、CIがそのまま赤くなります。これで「テストの質」を初めてビルドの合否に組み込めました。ただ、いきなり厳しくすると既存コードで全部落ちるので、段階的に上げています。

フェーズbreak(CIを落とす下限)狙い
導入直後0(測るだけ・落とさない)現状スコアを可視化して慣らす
安定運用60目に見えて弱いテストで赤くする
重要モジュール70お金・データに触る箇所を厚くする

ここは正直、まだ改善の余地があります。上のCI設定は差分実行に寄せているぶん、モジュール全体を通した週次のフルスキャンをまだ組めていません。差分だけ追っていると、遠回しに壊れた依存を取りこぼす可能性がある。それと、書き換えても挙動が変わらない「等価ミュータント」(到達不可能な分岐の反転など)は理屈上どうしても生き残るので、これを人手で除外しているんですが、その判断がいまは属人的です。この2つは次の課題として残っています。

受け入れ基準に落とす

品質は工程で作り込むものなので、測って終わりでは意味がありません。チームで回すために、受け入れ基準を具体的な数字に落としました。

  • 新規に書く業務ロジック(お金・入力チェック・権限判定)は、ミューテーションスコア 70%未満ならマージ保留
  • 既存コードは、触ったモジュールだけ差分で測り、スコアを下げる変更はレビューで指摘
  • スコア100%は目指さない。80〜90%台で頭打ちになったら、そこから先は等価ミュータントの可能性が高いので無理に追わない

100%を追わないのは大事なところで、ここは好みが分かれるかもしれません。ただ、費用対効果を考えると、全コードを均一に締め上げるより、壊れると痛い場所に力を集中させるほうが現場では回ります。

振り返って

いちばん効いたのは、テストの質が「数字で見えるようになった」ことでした。これまで「このテスト、ちゃんとバグ見つけられるの?」というのはレビューでの感覚頼みだったのが、スコアという共通言語で話せるようになった。

AIにテストを書かせるのが当たり前になってきて、量はいくらでも増やせる時代になりました。だからこそ、増えたテストが本当にバグを捕まえられるのか、質のゲートを一段挟んでおきたい。カバレッジの緑を見て安心していた頃には、もう戻れないなと思っています。

まだ全モジュールには回せていないし、フルスキャンの運用も途中です。それでも、いちばん怖かった見積り計算まわりに「質の物差し」が入っただけで、だいぶ夜眠れるようになりました。次は等価ミュータントの扱いをちゃんと台帳化して、判断を属人化から外していきたいところです。