Stryker・PIT・mutmutで始めるミューテーションスコアの可視化と品質トレンド分析
この記事でわかること
- ミューテーションテストの8つのミュータント状態とメトリクス計算式の正確な理解
- Stryker(TypeScript)・PIT(Java)・mutmut(Python)の3ツールでレポートを生成し、可視化する手順
- ミューテーションスコアをCI/CDパイプラインで時系列記録し、Grafanaダッシュボードでトレンド分析する方法
- モジュール・チーム横断での品質比較と、テスト改善の優先度を判断する実践パターン
- カバレッジ率では発見できないテストの弱点を、ミューテーションスコアで特定・改善した事例
対象読者
- 想定読者: テストカバレッジの数字に疑問を感じているMLエンジニア・ソフトウェアエンジニア
-
必要な前提知識:
- Python / TypeScript / Java いずれかの基礎文法
- pytest / Vitest / JUnit などのテストフレームワークの基本的な使い方
- CI/CDパイプライン(GitHub Actions等)の基本理解
- MLエンジニアの方へ: pytestでの単体テスト経験があれば十分です。TypeScript/Javaのセクションはコード例にPythonの類似概念をコメントで補足しています
結論・成果
ミューテーションテストの結果を可視化し、時系列でトレンド分析することで、テストスイートのバグ検出能力を定量的かつ継続的にモニタリングできるようになります。Sentryの事例では、StrykerJS導入後にCore SDKのミューテーションスコア62%を計測し、カバレッジでは見えなかったテストの弱点を特定したと公式ブログで報告しています。また、Metaの事例では、LLMベースのミューテーション生成(ACHシステム)を10,795のKotlinクラスに適用し、9,095個のミュータントと571個のテストケースを生成、プライバシーエンジニアによるテスト受入率は73%に達したとEngineering Blogで報告されています。
本記事では、ツール標準のHTMLレポートからカスタムダッシュボードによるチーム横断の品質トレンド分析まで、可視化の3段階を実践的に解説します。
ミューテーションテストのCI自動化や品質ゲート設計については、関連記事「Stryker・cargo-mutantsで実践するミューテーションテストCI自動化」も参照してください。
ミューテーションテストのメトリクス体系を理解する
ミューテーションテストの可視化を始める前に、何を計測し、何を可視化するのかを正確に理解しておく必要があります。ここではStrykerのメトリクス定義に基づいて、8つのミュータント状態とメトリクス計算式を整理します。
8つのミュータント状態を把握する
ミュータント(コードに意図的に埋め込まれた変異)は、テスト実行後に以下の8状態のいずれかに分類されます。
| 状態 | 意味 | メトリクス上の分類 |
|---|---|---|
| Killed | テストが変異を検出し、失敗した | Detected |
| Timeout | テスト実行がタイムアウトした | Detected |
| Survived | 全テストがパスした(変異を検出できず) | Undetected |
| No Coverage | テストがそもそもカバーしていない | Undetected |
| Runtime Error | 実行時エラーが発生 | Invalid(スコアから除外) |
| Compile Error | コンパイルエラーが発生 | Invalid(スコアから除外) |
| Ignored | 意図的にスキップされた | 除外 |
| Pending | 未実行(処理中の一時状態) | 除外 |
MLエンジニアの方は、Killed/Survivedを機械学習の混同行列(Confusion Matrix)に置き換えると理解しやすいかもしれません。Killedは「バグを正しく検出できた」(True Positive)、Survivedは「バグを見逃した」(False Negative)に対応します。
メトリクス計算式を確認する
Strykerのドキュメントでは、以下の計算式が定義されています。
Detected = Killed + Timeout
Undetected = Survived + No Coverage
Valid = Detected + Undetected
Invalid = Runtime Errors + Compile Errors
Mutation Score = Detected / Valid × 100
Coverage-Based Score = Detected / (Detected + Survived) × 100
Mutation Score(ミューテーションスコア)は「有効なミュータントのうち、テストで検出できた割合」を示します。Coverage-Based Score はカバレッジのあるコード内での検出率を示し、No Coverageのミュータントを除外した指標です。
注意点: この2つのスコアの差が大きい場合、テストカバレッジ自体が不足しています。たとえばMutation Score = 50%、Coverage-Based Score = 90%であれば、カバーされたコードのテスト品質は高いが、カバーされていないコードが多いことを意味します。
カバレッジ率とミューテーションスコアの違いを理解する
「カバレッジ100%なのにバグが出る」という現象は、多くのエンジニアが経験しているでしょう。カバレッジはコードが実行されたことを示しますが、テストが正しく検証したことは保証しません。
# tests/test_discount.py
# カバレッジ100%だがミューテーションテストで弱点が露呈する例
def calculate_discount(price: int, is_member: bool) -> int:
"""会員なら10%割引を適用する"""
if is_member:
return int(price * 0.9)
return price
def test_discount_member():
# カバレッジ的には is_member=True のブランチを通過 → カバレッジ貢献
result = calculate_discount(1000, True)
# しかしアサーションがない! ミュータント "price * 0.9 → price * 0.0" が生き残る
assert result is not None # 弱いアサーション
def test_discount_non_member():
result = calculate_discount(1000, False)
assert result == 1000 # 正確なアサーション → ミュータントを殺せる
上記の test_discount_member はカバレッジには貢献しますが、0.9 を 0.0 に変異させても result is not None は引き続きパスします。ミューテーションテストはこのアサーションの甘さを検出します。
修正後のテストでは、ミュータントを確実にKillできます。
def test_discount_member_fixed():
result = calculate_discount(1000, True)
assert result == 900 # 具体的な期待値 → ミュータントを殺せる
よくある間違い: 「カバレッジが高ければテスト品質は十分」と考えてしまうことです。実際にはカバレッジ100%でもミューテーションスコアが50%以下ということは珍しくありません。カバレッジはテストの量、ミューテーションスコアはテストの質を測る指標だと理解してください。
3つのツールでミューテーションテストレポートを生成する
ここでは、Python・TypeScript・Javaの3言語について、それぞれの主要ツールでレポートを生成する手順を解説します。自分の言語環境に合ったセクションを参照してください。
mutmut(Python)でHTMLレポートを生成する
mutmutはPython向けのミューテーションテストツールで、使いやすさに重点を置いています。mutmut 2.5.0(2025年安定版)を使用します。
# インストール
pip install mutmut==2.5.0
# ミューテーションテスト実行
# --paths-to-mutate でテスト対象を指定
mutmut run --paths-to-mutate src/
# 結果の確認(テキスト)
mutmut results
# HTMLレポート生成
mutmut html
# → htmlcov/ ディレクトリにレポートが生成される
mutmutの出力は以下のような形式です。
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be killed by timeout.
🙁 Survived. This means your tests need to be expanded.
🔇 Skipped. Skipped.
--- Results ---
Total: 142
Killed: 98 (69%)
Survived: 32 (23%)
Timeout: 8 (6%)
Suspicious: 4 (3%)
なぜmutmutか:
- Pythonエコシステムでの採用率が高く、ドキュメントが充実している
- インクリメンタル実行をサポートし、前回の結果をキャッシュできる
- Cosmic Rayはより高度なカスタマイズが可能だが、セットアップの手間がかかる
注意点:
mutmutはDjangoやFastAPIなどのフレームワークコードに対しても使用できますが、DB接続やネットワークアクセスを伴うテストでは実行時間が大幅に増加します。
--paths-to-mutateでビジネスロジック層に限定することを推奨します。
StrykerJS(TypeScript)でダッシュボード連携する
StrykerJSはTypeScript/JavaScript向けのミューテーションテストフレームワークです。HTMLレポートに加えて、Stryker Dashboardへの連携が特徴です。
# インストール(Vitest利用の場合)
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
# 初期設定
npx stryker init
設定ファイル stryker.config.mjs でレポーター設定を行います。
// stryker.config.mjs
// Pythonの pytest.ini や setup.cfg に相当する設定ファイル
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
export default {
testRunner: "vitest",
reporters: [
"html", // ローカルHTMLレポート → reports/mutation/mutation.html
"json", // JSON出力(後述のメトリクス抽出で使用)
"dashboard", // Stryker Dashboard連携
"progress", // ターミナル進捗表示
],
htmlReporter: {
fileName: "reports/mutation/mutation.html",
},
jsonReporter: {
fileName: "reports/mutation/mutation.json",
},
dashboard: {
// 環境変数 STRYKER_DASHBOARD_API_KEY が必要
reportType: "full",
},
// パフォーマンス最適化: 対象を限定
mutate: ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.d.ts"],
};
# ミューテーションテスト実行
npx stryker run
# 生成物:
# - reports/mutation/mutation.html (ブラウザで開いて確認)
# - reports/mutation/mutation.json (メトリクス抽出用)
Stryker Dashboardに連携すると、GitHub上のプロジェクトのミューテーションスコアをバッジとして表示できます。ダッシュボードはOSSプロジェクトなら無料で利用可能です。
なぜStrykerか:
- mutation-testing-elementsというWeb Components ベースの統一可視化フレームワークを提供している
- HTMLレポートでソースコード上にミュータントをインライン表示でき、Killed/Survivedのフィルタリングが可能
- JSON出力を使えば、後述のカスタムダッシュボードへの連携も容易
PIT(Java)でマルチモジュール集約レポートを生成する
PIT(PITest)はJava/JVM向けのミューテーションテストツールで、MavenとGradleの両方に対応しています。特にマルチモジュールプロジェクトでのレポート集約機能が強力です。
Mavenプロジェクトの場合、pom.xml に以下を追加します。
<!-- pom.xml -->
<!-- Pythonの pyproject.toml に相当するビルド設定ファイル -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.17.4</version>
<configuration>
<!-- HTMLとXML両方を出力(XML は集約レポートで必要) -->
<outputFormats>
<value>HTML</value>
<value>XML</value>
</outputFormats>
<!-- カバレッジ情報もエクスポート(集約に必要) -->
<exportLineCoverage>true</exportLineCoverage>
<!-- 対象パッケージを限定してパフォーマンス改善 -->
<targetClasses>
<param>com.example.app.*</param>
</targetClasses>
<targetTests>
<param>com.example.app.*Test</param>
</targetTests>
</configuration>
</plugin>
# 単一モジュールの実行
mvn org.pitest:pitest-maven:mutationCoverage
# マルチモジュール集約レポートの生成
mvn org.pitest:pitest-maven:mutationCoverage \
org.pitest:pitest-maven:report-aggregate
PITのHTMLレポートでは、Line Coverage(行カバレッジ)とMutation Coverage(ミューテーションカバレッジ)が色分けで表示されます。
| 色 | 意味 |
|---|---|
| 薄い緑 | Line Coverageあり |
| 濃い緑 | Mutation Coverageあり(ミュータントがKilled) |
| 薄いピンク | Line Coverageなし |
| 濃いピンク | Mutation Coverageなし(ミュータントがSurvived) |
Gradleプロジェクトでのマルチモジュール集約は、gradle-pitest-pluginを使用します。
// build.gradle(ルートプロジェクト)
// Pythonの pyproject.toml + マルチパッケージ構成に相当
plugins {
id 'info.solidsoft.pitest.aggregator' version '1.15.0'
}
subprojects {
apply plugin: 'info.solidsoft.pitest'
pitest {
testPlugin = 'junit5'
outputFormats = ['XML'] // 集約にはXML必須
exportLineCoverage = true
timestampedReports = false // 集約時はタイムスタンプなし推奨
}
}
# Gradleでの実行と集約
./gradlew pitest pitestReportAggregate
# → build/reports/pitest/ に集約レポートが出力
なぜPITか:
- Java/JVMエコシステムで事実上の標準ツール(GitHub Stars 1,700+)
- マルチモジュール集約レポートにネイティブ対応しており、大規模プロジェクトでの運用に適している
- Stryker Dashboardへのレポートアップロードにも対応(言語横断の可視化が可能)
制約条件:
PITはクラス単位でミュータントを生成するため、Kotlinのデータクラスやsealed classとの相性が良くない場合があります。Kotlin中心のプロジェクトではKureなどのKotlin専用ツールの検討も必要です。
ミューテーションスコアを時系列で記録・可視化する
ツール標準のHTMLレポートは「今のスナップショット」を見るには十分ですが、テスト品質が改善しているのか、劣化しているのかを判断するには時系列データが必要です。ここでは、CIパイプラインでメトリクスを抽出し、Prometheusに記録してGrafanaで可視化する方法を解説します。
CIでメトリクスを抽出するスクリプトを実装する
まず、各ツールの出力からメトリクスを抽出するPythonスクリプトを作成します。
# scripts/extract_mutation_metrics.py
"""ミューテーションテスト結果からメトリクスを抽出するスクリプト"""
import json
import sys
from pathlib import Path
from dataclasses import dataclass
@dataclass
class MutationMetrics:
"""ミューテーションテストのメトリクス"""
total: int
killed: int
survived: int
no_coverage: int
timeout: int
runtime_errors: int
compile_errors: int
@property
def detected(self) -> int:
return self.killed + self.timeout
@property
def undetected(self) -> int:
return self.survived + self.no_coverage
@property
def valid(self) -> int:
return self.detected + self.undetected
@property
def mutation_score(self) -> float:
if self.valid == 0:
return 0.0
return (self.detected / self.valid) * 100
@property
def coverage_based_score(self) -> float:
covered = self.detected + self.survived
if covered == 0:
return 0.0
return (self.detected / covered) * 100
def parse_stryker_json(path: Path) -> MutationMetrics:
"""StrykerのJSON出力を解析する"""
data = json.loads(path.read_text())
killed = survived = no_cov = timeout = runtime = compile_err = 0
for file_data in data.get("files", {}).values():
for mutant in file_data.get("mutants", []):
status = mutant["status"]
if status == "Killed":
killed += 1
elif status == "Survived":
survived += 1
elif status == "NoCoverage":
no_cov += 1
elif status == "Timeout":
timeout += 1
elif status == "RuntimeError":
runtime += 1
elif status == "CompileError":
compile_err += 1
total = killed + survived + no_cov + timeout + runtime + compile_err
return MutationMetrics(
total=total,
killed=killed,
survived=survived,
no_coverage=no_cov,
timeout=timeout,
runtime_errors=runtime,
compile_errors=compile_err,
)
def output_prometheus_format(metrics: MutationMetrics, project: str, module: str) -> str:
"""Prometheus Pushgateway用のメトリクス形式で出力する"""
lines = [
f'mutation_score{{project="{project}",module="{module}"}} {metrics.mutation_score:.2f}',
f'mutation_coverage_based_score{{project="{project}",module="{module}"}} {metrics.coverage_based_score:.2f}',
f'mutation_killed{{project="{project}",module="{module}"}} {metrics.killed}',
f'mutation_survived{{project="{project}",module="{module}"}} {metrics.survived}',
f'mutation_no_coverage{{project="{project}",module="{module}"}} {metrics.no_coverage}',
f'mutation_timeout{{project="{project}",module="{module}"}} {metrics.timeout}',
f'mutation_total_valid{{project="{project}",module="{module}"}} {metrics.valid}',
]
return "\n".join(lines)
if __name__ == "__main__":
if len(sys.argv) < 4:
print("Usage: python extract_mutation_metrics.py <json_path> <project> <module>")
sys.exit(1)
json_path = Path(sys.argv[1])
project = sys.argv[2]
module = sys.argv[3]
metrics = parse_stryker_json(json_path)
print(f"Mutation Score: {metrics.mutation_score:.1f}%")
print(f"Coverage-Based Score: {metrics.coverage_based_score:.1f}%")
print(f"Killed: {metrics.killed}, Survived: {metrics.survived}, No Coverage: {metrics.no_coverage}")
print()
print("--- Prometheus Format ---")
print(output_prometheus_format(metrics, project, module))
GitHub Actionsでメトリクスを記録するワークフローを構築する
以下のワークフローは、PRごとにミューテーションテストを実行し、メトリクスをアーティファクトとして保存する例です。
# .github/workflows/mutation-test.yml
name: Mutation Test & Metrics
on:
pull_request:
branches: [main]
schedule:
- cron: '0 3 * * 1' # 毎週月曜3:00 UTCに定期実行
jobs:
mutation-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run Stryker
run: npx stryker run
- name: Extract metrics
run: |
python scripts/extract_mutation_metrics.py \
reports/mutation/mutation.json \
my-project \
core \
> metrics_output.txt
cat metrics_output.txt
- name: Upload mutation report
uses: actions/upload-artifact@v4
with:
name: mutation-report-${{ github.sha }}
path: |
reports/mutation/mutation.html
reports/mutation/mutation.json
metrics_output.txt
# Prometheus Pushgatewayへの送信(オプション)
- name: Push metrics to Prometheus
if: github.event_name == 'schedule'
run: |
# Prometheusメトリクスの抽出と送信
grep -E '^mutation_' metrics_output.txt | \
curl --data-binary @- \
"${{ secrets.PUSHGATEWAY_URL }}/metrics/job/mutation-test/instance/my-project"
なぜ定期実行とPR実行を分けるか:
- PR実行: 差分のみのミューテーションテストで高速フィードバック
- 定期実行(週次): プロジェクト全体のメトリクスを記録してトレンド分析に使用
ハマりポイント:
Prometheus Pushgatewayへの送信では、ジョブ名とインスタンス名の組み合わせが一意でないとメトリクスが上書きされます。モジュール名をラベルに含めることで、マルチモジュールプロジェクトでも正しくメトリクスを区別できます。
Grafanaダッシュボードでトレンドを可視化する
Prometheusに記録されたメトリクスをGrafanaで可視化します。以下はダッシュボードのJSON定義の主要部分です。
{
"dashboard": {
"title": "Mutation Testing Quality Trends",
"panels": [
{
"title": "Mutation Score Trend",
"type": "timeseries",
"targets": [
{
"expr": "mutation_score{project=\"my-project\"}",
"legendFormat": "{{module}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": {
"steps": [
{ "color": "red", "value": 0 },
{ "color": "orange", "value": 60 },
{ "color": "green", "value": 80 }
]
}
}
}
},
{
"title": "Survived Mutants by Module",
"type": "barchart",
"targets": [
{
"expr": "mutation_survived{project=\"my-project\"}",
"legendFormat": "{{module}}"
}
]
},
{
"title": "Score vs Coverage Gap",
"type": "timeseries",
"targets": [
{
"expr": "mutation_coverage_based_score{project=\"my-project\"} - mutation_score{project=\"my-project\"}",
"legendFormat": "Gap ({{module}})"
}
]
}
]
}
}
ダッシュボードに含めるべき3つのパネルは以下の通りです。
| パネル | 目的 | 閾値の目安 |
|---|---|---|
| Mutation Score Trend | スコアの時系列推移 | 赤: 0-60%、黄: 60-80%、緑: 80%+ |
| Survived Mutants by Module | モジュール別の生存ミュータント数 | 多いモジュールから改善優先 |
| Score vs Coverage Gap | Mutation ScoreとCoverage-Based Scoreの乖離 | Gap > 20%でカバレッジ不足アラート |
制約条件:
この構成はPrometheus + Grafanaのインフラが前提です。小規模チームでは、GitHub Actions のアーティファクトにJSON を蓄積し、定期的にPythonスクリプトで集計する軽量な代替手段もあります。Grafanaのセットアップコストが見合わない場合は、次のセクションで紹介する軽量アプローチを検討してください。
チーム横断でテスト品質を比較・改善する
ミューテーションスコアの可視化が単一プロジェクトで動き始めたら、次はチーム・モジュール横断の品質比較と改善優先度の判断に活用します。
モジュール別スコアヒートマップで改善優先度を判断する
複数モジュールのミューテーションスコアをヒートマップ形式で一覧表示すると、テスト改善の優先度が一目で判断できます。
# scripts/generate_heatmap.py
"""モジュール別ミューテーションスコアのヒートマップを生成する"""
import json
from pathlib import Path
def generate_markdown_heatmap(modules: dict[str, float]) -> str:
"""モジュール別スコアをMarkdownテーブルで出力する"""
def score_emoji(score: float) -> str:
if score >= 80:
return "🟢"
elif score >= 60:
return "🟡"
else:
return "🔴"
lines = ["| Module | Score | Status | Action |"]
lines.append("|--------|-------|--------|--------|")
# スコアの低い順にソート(改善優先度が高い順)
for module, score in sorted(modules.items(), key=lambda x: x[1]):
emoji = score_emoji(score)
if score < 60:
action = "テスト追加を優先"
elif score < 80:
action = "Survivedミュータント確認"
else:
action = "維持"
lines.append(f"| {module} | {score:.1f}% | {emoji} | {action} |")
return "\n".join(lines)
# 使用例
modules = {
"auth": 85.2,
"payment": 72.1,
"notification": 45.3,
"user-profile": 91.0,
"data-pipeline": 58.7,
}
print(generate_markdown_heatmap(modules))
出力例:
| Module | Score | Status | Action |
|---|---|---|---|
| notification | 45.3% | 🔴 | テスト追加を優先 |
| data-pipeline | 58.7% | 🔴 | テスト追加を優先 |
| payment | 72.1% | 🟡 | Survivedミュータント確認 |
| auth | 85.2% | 🟢 | 維持 |
| user-profile | 91.0% | 🟢 | 維持 |
この表から、notificationモジュール(45.3%)とdata-pipelineモジュール(58.7%)のテスト改善が最優先であることが即座にわかります。
Survivedミュータントの分析で具体的な改善アクションを導く
スコアが低いモジュールを特定したら、次はSurvivedミュータントの内訳を分析して具体的な改善アクションにつなげます。
# scripts/analyze_survived.py
"""Survivedミュータントの分析レポートを生成する"""
import json
from collections import Counter
from pathlib import Path
def analyze_survived_mutants(json_path: Path) -> dict:
"""Survivedミュータントをミューテーター種別ごとに集計する"""
data = json.loads(json_path.read_text())
survived_by_type: Counter = Counter()
survived_examples: dict[str, list] = {}
for filename, file_data in data.get("files", {}).items():
for mutant in file_data.get("mutants", []):
if mutant["status"] == "Survived":
mutator = mutant.get("mutatorName", "Unknown")
survived_by_type[mutator] += 1
if mutator not in survived_examples:
survived_examples[mutator] = []
if len(survived_examples[mutator]) < 3:
survived_examples[mutator].append({
"file": filename,
"location": mutant.get("location", {}),
"replacement": mutant.get("replacement", ""),
})
return {
"by_type": survived_by_type.most_common(),
"examples": survived_examples,
}
def format_analysis(analysis: dict) -> str:
"""分析結果をMarkdownで整形する"""
lines = ["## Survivedミュータント分析\n"]
lines.append("| ミューテーター種別 | 件数 | 推奨アクション |")
lines.append("|-------------------|------|---------------|")
action_map = {
"ConditionalExpression": "境界値テストの追加",
"EqualityOperator": "等価条件のエッジケース追加",
"ArithmeticOperator": "計算結果の具体値アサーション追加",
"StringLiteral": "文字列出力の検証追加",
"BooleanLiteral": "True/False両方のパステスト",
"ArrayDeclaration": "空配列・多要素のテスト追加",
"BlockStatement": "ブロック削除時の副作用テスト",
}
for mutator, count in analysis["by_type"]:
action = action_map.get(mutator, "該当箇所のアサーション強化")
lines.append(f"| {mutator} | {count} | {action} |")
return "\n".join(lines)
このスクリプトにより、「どの種類のミューテーションが生き残っているか」を定量的に把握できます。たとえば ConditionalExpression のSurvivedが多い場合、条件分岐の境界値テストが不足していることを示唆します。
品質トレンドからテスト劣化を早期検知する
ミューテーションスコアの時系列データが蓄積されると、品質劣化の兆候を検知できるようになります。以下のルールをGrafanaアラートに設定することを推奨します。
| アラート条件 | 閾値 | 対応アクション |
|---|---|---|
| スコア急落 | 前週比 -5ポイント以上 | 直近のPRでテストなしコード追加がないか確認 |
| 継続的低下 | 3週連続で低下 | テスト改善スプリントの計画 |
| モジュール間格差 | 最高-最低 > 30ポイント | 低スコアモジュールへのペアテスティング |
| カバレッジギャップ | Coverage-Based - Mutation > 20ポイント | カバレッジのない領域のテスト追加 |
# scripts/detect_degradation.py
"""品質劣化検知スクリプト(週次レポート用)"""
import json
from dataclasses import dataclass
@dataclass
class WeeklyScore:
week: str
module: str
score: float
def detect_degradation(scores: list[WeeklyScore]) -> list[str]:
"""品質劣化パターンを検出してアラートメッセージを生成する"""
alerts = []
# モジュールごとに直近4週間のスコアを取得
by_module: dict[str, list[WeeklyScore]] = {}
for s in scores:
by_module.setdefault(s.module, []).append(s)
for module, weekly in by_module.items():
weekly.sort(key=lambda x: x.week, reverse=True)
if len(weekly) >= 2:
diff = weekly[0].score - weekly[1].score
if diff <= -5.0:
alerts.append(
f"[ALERT] {module}: スコアが{abs(diff):.1f}pt急落 "
f"({weekly[1].score:.1f}% → {weekly[0].score:.1f}%)"
)
if len(weekly) >= 3:
if all(weekly[i].score < weekly[i + 1].score for i in range(2)):
alerts.append(
f"[WARNING] {module}: 3週連続でスコアが低下中 "
f"({weekly[2].score:.1f}% → {weekly[0].score:.1f}%)"
)
# モジュール間格差チェック
latest_scores = {m: ws[0].score for m, ws in by_module.items() if ws}
if latest_scores:
max_score = max(latest_scores.values())
min_score = min(latest_scores.values())
if max_score - min_score > 30:
min_module = min(latest_scores, key=latest_scores.get)
alerts.append(
f"[WARNING] モジュール間格差: {max_score:.1f}% - {min_score:.1f}% = "
f"{max_score - min_score:.1f}pt({min_module}の改善を推奨)"
)
return alerts
よくある間違い: ミューテーションスコアの絶対値だけで品質を判断してしまうことです。スコア80%のモジュールでも、前週から10ポイント低下していれば問題があります。トレンド(変化の方向性)と絶対値の両方を見ることが重要です。
Prometheus + Grafanaを使わない軽量な可視化アプローチを検討する
Prometheus + Grafanaの構成はインフラの準備が必要です。小規模チームや個人プロジェクトでは、以下の軽量アプローチも有効です。
GitHub Actions アーティファクトとPythonによる集計
# scripts/lightweight_report.py
"""GitHub Actionsアーティファクトから軽量レポートを生成する"""
import json
import csv
from pathlib import Path
from datetime import datetime
def append_to_csv(metrics_json: Path, csv_path: Path, project: str, module: str):
"""メトリクスをCSVに追記する(時系列データの蓄積)"""
data = json.loads(metrics_json.read_text())
# Stryker JSONからメトリクスを抽出
killed = survived = no_cov = timeout = 0
for file_data in data.get("files", {}).values():
for mutant in file_data.get("mutants", []):
status = mutant["status"]
if status == "Killed":
killed += 1
elif status == "Survived":
survived += 1
elif status == "NoCoverage":
no_cov += 1
elif status == "Timeout":
timeout += 1
detected = killed + timeout
valid = detected + survived + no_cov
score = (detected / valid * 100) if valid > 0 else 0.0
row = {
"date": datetime.now().strftime("%Y-%m-%d"),
"project": project,
"module": module,
"mutation_score": f"{score:.2f}",
"killed": killed,
"survived": survived,
"no_coverage": no_cov,
"timeout": timeout,
"total_valid": valid,
}
file_exists = csv_path.exists()
with open(csv_path, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=row.keys())
if not file_exists:
writer.writeheader()
writer.writerow(row)
print(f"Appended: {project}/{module} = {score:.1f}% ({detected}/{valid})")
# .github/workflows/mutation-lightweight.yml
name: Mutation Test (Lightweight)
on:
schedule:
- cron: '0 3 * * 1' # 週次
jobs:
mutation-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run mutation test
run: npx stryker run
- name: Append metrics to CSV
run: |
python scripts/lightweight_report.py \
reports/mutation/mutation.json \
mutation_history.csv \
my-project \
core
- name: Commit CSV update
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add mutation_history.csv
git commit -m "chore: update mutation metrics" || echo "No changes"
git push
この方法では、CSVファイルをGitリポジトリに蓄積し、必要に応じてスプレッドシートやmatplotlibで可視化できます。Grafanaの導入コストなしで始められるため、まずはこの軽量アプローチから試してみることを推奨します。
トレードオフ:
- メリット: 追加インフラ不要、GitリポジトリだけでOK
- デメリット: リアルタイムアラートなし、インタラクティブなダッシュボードなし
よくある問題と解決方法
| 問題 | 原因 | 解決方法 |
|---|---|---|
| ミューテーションテストの実行時間が長すぎる | プロジェクト全体に対して実行している |
--in-diff(cargo-mutants)やPR差分限定で実行。Strykerはmutate設定で対象を限定 |
| 等価ミュータントが多くスコアが不正確 | コンパイラ最適化で同等のコードが生成される | MetaのLLMベース検出(precision 0.79)が参考に。手動判定を最小化するためIgnored設定を活用 |
| モジュール間でスコアが大きく異なる | テスト文化・リソース配分の偏り | ヒートマップで可視化し、低スコアモジュールに集中投資。ペアテスティングの導入 |
| Grafanaの導入コストが高い | Prometheus + Grafanaのインフラ構築が必要 | CSVベースの軽量アプローチから段階的に移行。GitHub Pages + Chart.jsも代替案 |
| CI実行コストの増加 | 毎PRで全ファイルのミューテーションテスト | 週次の全体スキャン + PR時は差分のみの2層運用 |
まとめと次のステップ
まとめ:
- ミューテーションテストは8つのミュータント状態と
Mutation Score = Detected / Valid × 100の計算式で、テストのバグ検出能力を定量化する - Stryker(TypeScript)、PIT(Java)、mutmut(Python)の各ツールがHTMLレポートとJSON/XML出力を提供し、可視化の基盤となる
- CIパイプラインでメトリクスを時系列記録し、Grafanaダッシュボードでトレンド分析することで、テスト品質の劣化を早期検知できる
- モジュール別ヒートマップとSurvivedミュータント分析により、テスト改善の優先度と具体的アクションを導出できる
- 小規模チームではCSVベースの軽量アプローチから始め、段階的にPrometheus + Grafanaへ移行するのが現実的
次にやるべきこと:
- 自分のプロジェクトで1つのモジュールを選び、ミューテーションテストを実行してベースラインスコアを計測する
- CIに週次の定期実行を設定し、メトリクスの蓄積を開始する
- 3週間分のデータが蓄積されたら、トレンドグラフを確認して改善計画を立てる
参考
- Stryker Mutator - Mutant States and Metrics - メトリクス定義の公式リファレンス
- Stryker Dashboard - クラウドホスト型ミューテーションテストダッシュボード
- mutation-testing-elements(GitHub) - Web Componentsベースの可視化フレームワーク
- PIT Mutation Testing - Java/JVM向けミューテーションテストツール
- gradle-pitest-plugin - Gradleでのマルチモジュール集約対応
- mutmut(GitHub) - Python向けミューテーションテストツール
- Cosmic Ray(GitHub) - Python向け高度なミューテーションテストツール
- Meta Engineering Blog: LLMs Are the Key to Mutation Testing - LLMベースのミューテーション生成の事例
- InfoQ: Meta Applies Mutation Testing with LLM(2026年1月) - Metaの大規模運用報告
- Sentry Engineering Blog: Mutation Testing Our SDKs - SentryでのStrykerJS導入事例
- Mutation Testing in CI: An Exploratory Study(論文) - CI統合に関する学術研究
- Qodo: 10 Code Quality Metrics for Large Engineering Orgs(2026年版) - コード品質メトリクスの最新ガイド
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。