コードベース全体に単一のmutation scoreを課すのは、チームにテストを嫌わせる最も効率的な方法だ。

典型的なリポジトリに対してPITやStrykerを実行すれば、いつも同じパターンが見える:認証モジュールは40%、文字列ユーティリティは95%、ORMレイヤーは60%台のどこかに落ち着く。条件反射的な対応は、例えば70%というglobal gateを設定し、それを下回るPRをすべてブロックすることだ。2スプリント後には、誰かがCIのチェックを無効にし、「flaky mutators」のせいにする。

本当の問題はツールではない。mutantが生き残ったときのblast radiusが、すべてのコードで同じだと見なしていることだ。

mutation testingが実際に測定しているもの

code coverageはどの行が実行されたかを教えてくれる。mutation testingは、それらの行が変更された場合にテストが気づくかどうかを教えてくれる。

mutation frameworkはソースに小さな欠陥(mutants)を導入する。><に反転させたり、メソッド呼び出しを削除したり、戻り値を変更したりするかもしれない。test suiteが変更を検出すれば、mutantはkilledされる。それでもテストが通れば、mutantはsurvivesする。mutation scoreとは、killedされたmutantの割合だ。

パスワードハッシュの比較でsurviving mutantがいるのは、プロダクションで待ち受けるセキュリティバグだ。capitalizeFirstLetterヘルパーでsurviving mutantがいても、最悪の場合、少し変なUIラベルに過ぎない。

これらを同じように扱うのが、チームが間違えるところだ。

なぜauthには90%以上のgateが必要なのか

認証・認可コードには、積極的なmutation testingに最適な2つの特性がある。

第一に、ロジックは通常離散的でステートマシンのようである。トークンは期限切れか?ロールは許可されたセットに含まれているか?署名は検証されたか?各branchには明確なセキュリティ上の意味があり、それぞれをテストすべきだ。

第二に、surviving mutantのコストは壊滅的だ。ロールチェックで1つのbooleanが反転すれば、admin endpointが晒される。トークン検証ルーチンでnotが欠けていれば、偽造されたJWTを受け入れてしまう。これらは理論上の話ではない。CVEデータベースは、mutation testingで検出できたはずのロジックエラーによるauth bypassで溢れている。

Sentryでは、authn/およびauthz/モジュールのすべてに対して90%のmutation scoreを課している。それを下回ればCIは失敗する。overrideも、「次のスプリントで直す」という言い訳もない。モジュールは十分小さいため、1行のプロダクションコードに対して40行のテストを書かなくても達成可能だ。

実際の例を見てみよう。これは簡略化されたJWT検証ルーチンだ:

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

mutation frameworkは、有効期限チェックで>>=に反転させるかもしれない。now + leewayのちょうどその時点で期限切れになるトークンを使うテストがなければ、そのmutantはsurvivesする。つまり、テストは実際には境界を検証していないということだ。90%のmutation coverageがあれば、そのテストは存在する。

ユーティリティコードは60%で十分

StringUtilsDateHelpersMathExtensionsは、スペクトルの真逆の端にある。

これらのモジュールは純粋で、多用され、推論しやすい傾向にある。truncate(str, maxLen)>>=に変更するsurviving mutantがいても、1文字多く切り詰めるかもしれない。それはUIの癖であって、セキュリティインシデントではない。

リスクと報酬の計算が変わる。これらのモジュールには数十の小さな関数がある。90%のmutation coverageを追い求めるということは、padLeftのすべてのoff-by-one variantに対してテストを書くことを意味する。テストは保護対象のコードより長くなり、メンテナンスの負担が価値を上回り始める。

ユーティリティモジュールには60%のfloorを設定している。これにより、明らかな欠陥(欠落したnull checks、誤った戻り値)は検出でき、チームに文字列スライシングのあらゆる組み合わせを網羅的にテストすることを強制しない。

重要なのは、60%が何を意味するか正直になることだ。それは「一般的なケースと明らかな失敗をテストした」という意味だ。「このコードは重要ではない」という意味ではない。ユーティリティ関数がセキュリティに敏感なパスで使用されている場合、呼び出し側から高いthresholdを継承する。

中間地帯:business logic

大半のコードは、この両極端の間にある。支払い処理、データ検証、ワークフローオーケストレーション。これらのモジュールは正確性とユーザーの信頼に影響するが、1つのsurviving mutantが通常、攻撃者にデータベースを手渡すことはない。

我々は階層的なシステムを使用している:

モジュールタイプMutation threshold根拠
AuthN / AuthZ90%blast radiusが高く、離散的なロジック
Business logic75%正確性が重要で、中程度の複雑さ
Utilities / helpers60%blast radiusが低く、再利用が多く、シンプルな関数
Generated / boilerplateExcluded自分が書いていないコードはテストしない

これは硬いルールではない。支払い計算モジュールは85%に引き上げるかもしれない。広く使われているJSONヘルパーがauthコードから呼ばれている場合は75%に引き上げることもある。階層は出発点であり、檻ではない。

階層的なmutation gateの実装方法

StrykerとPITは両方ともモジュールごとの設定をサポートしている。以下は、カスタム設定でmutmutを使ったPythonプロジェクトに組み込む方法だ:

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

CIでは、小さなスクリプトがこの設定を読み込み、モジュールごとにmutation testerを実行する:

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

検証スクリプトは各モジュールのスコアをthresholdと照合する。src/authn/が87%なら、ビルドは明確なメッセージと共に失敗する:authn/ scored 87%, threshold is 90%

Stryker(JavaScript/TypeScript)の場合は、stryker.conf.jsでmutator groupsを使う:

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

Strykerをスクリプトでラップし、異なるパスglobsで3回実行し、実行ごとにディレクトリごとのthresholdを強制している。少し不格好だが、動作する。

100%追い求める罠

一部のチームはmutation testingを勝ち抜くゲームと見なす。彼らは、振る舞いを検証するのではなく、mutantをkillするためにだけ存在するテストを書く。

最悪の例は、特定の例外メッセージが部分文字列を含むことをテストすることだ。メッセージテキストを変更するmutantをkillするためだけにある。そのテストは何の価値も加えない。例外が正しいタイミングで投げられるか、正しい型が発生するかを検証しない。文字列だけを検証する。

パーセンテージを押し上げるために純粋にテストを書いていると気づいたら、目的を逆転させている。mutation testingは診断ツールであり、leaderboardではない。スコアはどこを見るべきかを教えてくれる。いつ終わるかは教えてくれない。

苦労して学んだこと

我々はglobal 80% gateから始めた。1か月以内に、3つのチームがフィーチャーブランチで「一時的に」無効にした。そのうち2つは、一時的な無効化が恒久的になった。

問題は数字ではなかった。80%はauthコードにとって低すぎ(ステージングに到達したロールチェックのバグを見逃した)、4,000行のユーティリティモジュールにとっては高すぎた(チームはisValidEmailのvariantのテストを書くのに2週間費やした)のだ。

階層に分けた後、定着した。認証チームはスコープが限定されていたため90%の基準を受け入れた。プラットフォームチームは、狂気なしで達成可能だったためユーティリティの60%を受け入れた。階層的アプローチは、mutation testingを罰からリスクに関する対話へと変えた。

どこから始めるか

既存のコードベースにmutation testingを導入する場合、最初の週にgateを設定してはならない。ツールを実行し、スコアを確認し、こう問う:surviving mutantがどこで最も痛手を与えるだろうか?

authから始めろ。そこで90%を設定し、グリーンにして価値を証明する。チームが信号を信頼したらbusiness logicに拡張する。ユーティリティは低い基準にするか、習慣が身につくまで完全に除外してもよい。

そして覚えておけ:正直なテストでの60%スコアは、mutatorを欺くために書かれたテストでの95%スコアを上回る。目標は本物のバグを捕まえることであり、metrics dashboardを魅了することではない。

自分で試してみたい場合、Python用のmutmutとJavaScript用のStrykerは両方とも、上記で説明したディレクトリごとのパターンをサポートしている。小さく始めろ。1つのauthモジュール。1週間。何がsurvivesするか見てみろ。