dotnet-test-quality について
.NET 開発者向けに、カバレッジやリスクの高いコード、テストの有効性、不安定なテストを評価するスキルです。Coverlet、ReportGenerator、CRAP 分析、Stryker.NET の活用方法を扱います。
主な機能
- 01Coverlet によるカバレッジ収集と絞り込み
- 02ReportGenerator によるレポート作成
- 03CRAP スコアによる高リスクメソッドの特定
- 04ミューテーションテストによるテスト評価
- 05不安定なテストの検出と管理
活用シーン
- 01複数のテストプロジェクトのカバレッジ確認
- 02複雑でテスト不足のメソッドの優先順位付け
- 03ミューテーションによるテストの不足箇所の発見
- 04断続的なテスト失敗の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-test-qualityインストール後、Claude Code で/dotnet-test-qualityを入力して読み込まれたか確認します。
詳細
dotnet-test-quality
.NET プロジェクトのテスト品質分析です。coverlet によるコードカバレッジの収集、ReportGenerator による人間が読みやすいカバレッジレポートの生成、テスト不足の複雑なコードを特定する CRAP(Change Risk Anti-Patterns)スコア分析、テストスイートの有効性を評価する Stryker.NET によるミューテーションテスト、フレークテストの検出と管理の戦略を扱います。
バージョンの前提条件: Coverlet 6.x 以降、ReportGenerator 5.x 以降、Stryker.NET 4.x 以降(.NET 8.0 以降を基準)。Coverlet は MSBuild 統合(coverlet.msbuild)と coverlet.collector データコレクターの両方をサポートします。例では推奨される方法として coverlet.collector を使用します。
対象外: テストプロジェクトのひな型作成(プロジェクトの作成、パッケージ参照、coverlet のセットアップ)は [skill:dotnet-add-testing] が担当します。テスト戦略とテストの種類の決定については [skill:dotnet-testing-strategy] を参照してください。CI でのテストレポートとパイプライン統合については、[skill:dotnet-gha-build-test] と [skill:dotnet-ado-build-test] を参照してください。
前提条件: [skill:dotnet-add-testing] を通じてテストプロジェクトのひな型がすでに作成され、coverlet パッケージが参照されていること。.NET 8.0 以降を基準とします。
相互参照:[skill:dotnet-testing-strategy] では、テスト対象の決定とカバレッジ目標の指針を、[skill:dotnet-xunit] では xUnit テストフレームワークの機能と構成を説明しています。
Coverlet によるコードカバレッジ
Coverlet は .NET の標準的なオープンソースのコードカバレッジライブラリです。ビルド時またはデータコレクターを介してアセンブリをインストルメントし、複数の形式でカバレッジレポートを生成します。
パッケージ
<PackageReference Include="coverlet.collector" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
カバレッジの収集
# Collect coverage with Cobertura output (default for ReportGenerator)
dotnet test --collect:"XPlat Code Coverage"
# Specify output format explicitly
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
# Multiple formats
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura,opencover
カバレッジの結果は、各テストプロジェクトの出力ディレクトリにある TestResults/<guid>/coverage.cobertura.xml に書き込まれます。
カバレッジのフィルタリング
生成されたコード、テストプロジェクト、または特定の名前空間を除外します。
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.IntegrationTests]*" \
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute="GeneratedCodeAttribute,ObsoleteAttribute,ExcludeFromCodeCoverageAttribute"
または、再現性を高めるために runsettings ファイルで設定します。
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Exclude>[*.Tests]*,[*.IntegrationTests]*</Exclude>
<ExcludeByAttribute>
GeneratedCodeAttribute,ObsoleteAttribute,ExcludeFromCodeCoverageAttribute
</ExcludeByAttribute>
<ExcludeByFile>**/Migrations/**</ExcludeByFile>
<IncludeTestAssembly>false</IncludeTestAssembly>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
dotnet test --settings coverlet.runsettings
複数のテストプロジェクトのカバレッジをマージする
ソリューションに複数のテストプロジェクトがある場合は、それらのカバレッジを単一のレポートにマージします。
# Run all tests, collecting coverage per project
dotnet test --collect:"XPlat Code Coverage"
# Find all coverage files and merge via ReportGenerator (see next section)
ReportGenerator によるカバレッジレポート
ReportGenerator は、生のカバレッジデータ(Cobertura、OpenCover)を、行ごとのハイライトを備えた人間が読みやすい HTML レポートに変換します。
インストール
# Install as a global tool
dotnet tool install -g dotnet-reportgenerator-globaltool
# Or as a local tool
dotnet tool install dotnet-reportgenerator-globaltool
レポートの生成
# Single coverage file
reportgenerator \
-reports:"tests/MyApp.Tests/TestResults/*/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;TextSummary"
# Multiple test projects (glob pattern merges automatically)
reportgenerator \
-reports:"**/TestResults/*/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;Cobertura;TextSummary"
レポートの種類
| 種類 | 説明 | 用途 |
|---|---|---|
Html | 行ごとのハイライトを備えたインタラクティブな HTML | ローカルでの開発者レビュー |
HtmlInline_AzurePipelines | Azure DevOps 向けに最適化された HTML | CI 成果物 |
Cobertura | マージ済みの Cobertura XML | 他のツールへの入力 |
TextSummary | プレーンテキストの概要 | CLI/CI の出力 |
Badges | SVG カバレッジバッジ | README バッジ |
MarkdownSummaryGithub | GitHub 形式の Markdown | PR コメント |
例:カバレッジパイプライン全体
#!/bin/bash
# clean previous results
rm -rf coverage-report TestResults
# run tests with coverage
dotnet test --collect:"XPlat Code Coverage" --results-directory TestResults
# generate merged HTML report
reportgenerator \
-reports:"**/TestResults/*/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;TextSummary;Badges"
# display summary
cat coverage-report/Summary.txt
カバレッジのしきい値を設定する
テキスト概要を解析するか、しきい値パラメーターを使って、CI でカバレッジの最小値を適用します。
# ReportGenerator does not enforce thresholds directly.
# Parse the summary or use dotnet-coverage (Microsoft) for threshold enforcement.
# Alternative: use coverlet's built-in threshold via MSBuild
dotnet test /p:CollectCoverage=true \
/p:Threshold=80 \
/p:ThresholdType=line \
/p:ThresholdStat=total
注: /p:Threshold パラメーターには coverlet.collector ではなく coverlet.msbuild パッケージが必要です。coverlet.collector を使うワークフローでは、CI スクリプトで ReportGenerator のテキスト概要を解析してしきい値を適用してください。
CRAP 分析
CRAP(Change Risk Anti-Patterns)スコアは、複雑でテストが不十分なメソッドを特定します。CRAP スコアが高い場合、そのメソッドの循環的複雑度が高く、コードカバレッジが低いことを示しており、リスクの高い組み合わせです。
計算式
CRAP(m) = complexity(m)^2 * (1 - coverage(m)/100)^3 + complexity(m)
ここで:
complexity(m)= メソッド m の循環的複雑度coverage(m)= メソッド m のコードカバレッジ率(0~100)
CRAP スコアの解釈
| CRAP スコア | リスクレベル | 対応 |
|---|---|---|
| < 5 | 低 | メソッドが単純、または十分にテストされています |
| 5-15 | 中程度 | レビューしてください。追加テストが必要な場合があります |
| 15-30 | 高 | 優先してテストを追加するか、複雑度を下げてください |
| > 30 | 重大 | すぐにリファクタリングしてテストを追加してください |
CRAP レポートの生成
OpenCover 形式を入力として使用する場合、ReportGenerator は CRAP 分析を含めます。
# Step 1: Collect coverage in OpenCover format
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
# Step 2: Generate report with risk hotspot analysis
reportgenerator \
-reports:"**/TestResults/*/coverage.opencover.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;RiskHotspots"
Risk Hotspots レポートでは、CRAP スコア順に並べられたメソッドが表示されます。表示される情報は次のとおりです。
- メソッド名と、それを含むクラス
- サイクロマティック複雑度
- コードカバレッジ率
- 算出された CRAP スコア
CRAP スコアを効果的に活用する
// Example: a method with high complexity and low coverage
// Cyclomatic complexity: 12, Coverage: 20%
// CRAP = 12^2 * (1 - 0.20)^3 + 12 = 144 * 0.512 + 12 = 85.7 (Critical)
public decimal CalculateShipping(Order order)
{
if (order.Items.Count == 0) return 0;
decimal baseRate = order.DestinationCountry switch
{
"US" => 5.99m,
"CA" => 9.99m,
"UK" => 12.99m,
_ => 19.99m
};
if (order.Total > 100) baseRate *= 0.5m;
if (order.IsPriority) baseRate *= 2.0m;
if (order.Items.Any(i => i.IsFragile)) baseRate += 4.99m;
if (order.Items.Any(i => i.IsOversized)) baseRate += 14.99m;
if (order.HasInsurance) baseRate += order.Total * 0.02m;
if (order.IsExpedited && order.DestinationCountry != "US") baseRate *= 1.5m;
return Math.Round(baseRate, 2);
}
高い CRAP スコアには、次の方法で対処します。
- カバレッジを高めてスコアを下げるため、カバレッジされていない分岐を対象とするテストを追加します
- メソッドを抽出して複雑さを軽減します(例:
CalculateBaseRateとApplySurchargesのメソッドを分けます) - 両方 -- カバレッジの向上とメソッドの簡素化を組み合わせるのが最も効果的です
Stryker.NET によるミューテーションテスト
ミューテーションテストでは、本番コードに小さな変更(ミューテーション)を加え、テストがそれを検出できるかどうかを確認して、テストスイートの品質を評価します。ミューテーションが生き残る(テストが引き続き成功する)場合、テストスイートに抜けがあります。
インストール
# Install as a global tool
dotnet tool install -g dotnet-stryker
# Or as a local tool (recommended for team consistency)
dotnet tool install dotnet-stryker
Stryker.NET の実行
# From the test project directory
cd tests/MyApp.Tests
dotnet stryker
# Specify the source project explicitly
dotnet stryker --project MyApp.csproj
# Target specific files
dotnet stryker --mutate "src/Services/**/*.cs"
設定ファイル
テストプロジェクトのディレクトリに stryker-config.json を作成します。
{
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-net/master/src/Stryker.Core/Stryker.Core/stryker-config.schema.json",
"stryker-config": {
"project": "MyApp.csproj",
"reporters": ["html", "progress", "cleartext"],
"mutation-level": "Standard",
"thresholds": {
"high": 80,
"low": 60,
"break": 50
},
"mutate": [
"src/Services/**/*.cs",
"!src/Services/Migrations/**/*.cs"
],
"ignore-mutations": [
"string",
"linq"
]
}
}
ミューテーション結果の理解
Stryker はミューテーションを次の 4 つのカテゴリで報告します。
| ステータス | 意味 | 対処 |
|---|---|---|
| Killed | テストがミューテーションを検出した(失敗した) | 良好 -- テストスイートが欠陥を検出しました |
| Survived | ミューテーションを検出したテストがなかった(すべて成功した) | 抜けがあります -- テストを追加または強化します |
| No Coverage | ミューテーションされたコードをカバーするテストがない | 抜けがあります -- このコードのテストを追加します |
| Timeout | ミューテーションによって無限ループまたはタイムアウトが発生した | 通常は検出されたものとして扱われます |
ミューテーションスコア
Mutation Score = Killed / (Killed + Survived + NoCoverage) * 100
ミューテーションスコアが 80% 以上であれば、強固なテストスイートであることを示します。60% 未満の場合は、重大な抜けがあることが示唆されます。
例:テストの抜けを特定する
次の本番コードがあるとします。
public class PricingService
{
public decimal CalculateDiscount(decimal price, CustomerTier tier) =>
tier switch
{
CustomerTier.Bronze => price * 0.05m,
CustomerTier.Silver => price * 0.10m,
CustomerTier.Gold => price * 0.15m,
CustomerTier.Platinum => price * 0.20m,
_ => 0m
};
}
テストで Gold ティアのみを確認している場合、Stryker は次のようなミューテーションを生成します。
0.05mを0.06mに置き換える(生き残る -- Bronze のテストがない)0.10mを0.11mに置き換える(生き残る -- Silver のテストがない)0.15mを0.16mに置き換える(検出される -- Gold のテストで検出される)0.20mを0.21mに置き換える(生き残る -- Platinum のテストがない)0mを1mに置き換える(生き残る -- デフォルト値のテストがない)
HTML レポートでは、生き残ったミューテーションごとにコードの変更箇所が正確に強調表示されるため、テストを追加すべき箇所が分かります。
Stryker のしきい値
{
"thresholds": {
"high": 80, // Green: mutation score >= 80%
"low": 60, // Yellow: 60% <= mutation score < 80%
"break": 50 // Red: mutation score < 50% -> exit code 1
}
}
break しきい値を設定すると、Stryker はゼロ以外の終了コードを返します。これは CI のゲートで役立ちます。
不安定なテストの検出
不安定なテストは、コードに変更がないにもかかわらず、ときどき成功したり失敗したりします。テストスイートへの信頼を損ない、開発を遅らせます。
よくある原因
| 原因 | 症状 | 対処 |
|---|---|---|
| 共有された変更可能な状態 | 特定の順序で実行するとテストが失敗する | 適切なテスト分離を使用します(フィクスチャについては [skill:dotnet-xunit] を参照) |
| 時間依存のロジック | 深夜付近または特定の時刻にテストが失敗する | DateTime.Now の代わりに TimeProvider(または ISystemClock)を注入します |
| 競合状態 | 並列実行時にテストがときどき失敗する | 共有リソースには ICollectionFixture を使用し、共有された静的状態は避けます |
| 外部依存関係 | ネットワークやサービスが利用できないとテストが失敗する | 外部呼び出しをモックし、インフラストラクチャには Testcontainers を使用します |
| ポートの競合 | 別のプロセスが同じポートを使用しているとテストが失敗する | 動的なポート割り当てを使用します(WebApplicationFactory が処理します) |
| ファイルシステムの競合 | 並列実行時にテストが失敗する | テストごとに一意の一時ディレクトリを使用します(IAsyncLifetime のパターンについては [skill:dotnet-xunit] を参照) |
不安定なテストの検出
繰り返し実行
# Run tests multiple times to surface flakiness
for i in $(seq 1 10); do
dotnet test --logger "trx;LogFileName=run-$i.trx" || echo "Run $i failed"
done
xUnit の条件付きスキップ
xUnit v3 では、[Fact] の Skip を使った条件付きスキップが組み込まれています。
// xUnit v3 — built-in conditional skip
[Fact(Skip = "Requires external service")]
public async Task ExternalApi_ReturnsData()
{
var result = await _client.GetDataAsync();
Assert.NotEmpty(result);
}
// xUnit v3 — runtime skip via Assert.Skip
[Fact]
public async Task ExternalApi_ReturnsData()
{
if (!await IsServiceAvailable())
Assert.Skip("External service unavailable");
var result = await _client.GetDataAsync();
Assert.NotEmpty(result);
}
時間依存のテスト
DateTime.Now/DateTime.UtcNow を .NET 8 の TimeProvider に置き換えます。
// Production code
public class SubscriptionService(TimeProvider timeProvider)
{
public bool IsExpired(Subscription sub)
{
var now = timeProvider.GetUtcNow();
return sub.ExpiresAt < now;
}
}
// Test code
[Fact]
public void IsExpired_PastExpiry_ReturnsTrue()
{
var fakeTime = new FakeTimeProvider(
new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero));
var service = new SubscriptionService(fakeTime);
var sub = new Subscription
{
ExpiresAt = new DateTimeOffset(2025, 6, 14, 0, 0, 0, TimeSpan.Zero)
};
Assert.True(service.IsExpired(sub));
}
[Fact]
public void IsExpired_FutureExpiry_ReturnsFalse()
{
var fakeTime = new FakeTimeProvider(
new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero));
var service = new SubscriptionService(fakeTime);
var sub = new Subscription
{
ExpiresAt = new DateTimeOffset(2025, 6, 16, 0, 0, 0, TimeSpan.Zero)
};
Assert.False(service.IsExpired(sub));
}
注: FakeTimeProvider は Microsoft.Extensions.TimeProvider.Testing(NuGet)で利用できます。
隔離戦略
不安定なテストをすぐに修正できない場合:
// Mark as skipped with a tracking issue
[Fact(Skip = "Flaky: tracking in #1234 -- race condition in event handler")]
public async Task EventHandler_ConcurrentEvents_ProcessesAll()
{
// ...
}
不安定なテストを削除しないでください。課題への参照を付けてスキップし、体系的に修正してください。
主要な原則
- カバレッジは遅行指標であり、目標ではありません。 カバレッジが高くても、優れたテストが保証されるわけではありません。アサーションが弱ければ、カバレッジが90%のテストスイートでも重大なバグを見逃す可能性があります。
- 優先順位付けにはCRAPスコアを使用してください。 全体のカバレッジ率を追い求めるのではなく、複雑度が高くカバレッジが低いメソッドにテストの労力を集中させてください。
- 重要な処理経路でミューテーションテストを実行してください。 ミューテーションテストは計算コストが高いため、コードベース全体で実行するのではなく、ビジネス上重要なコード(価格設定、認証、データ検証)に対象を絞ってください。
- 不安定なテストはすぐに修正するか、隔離してください。 不安定なテストをテストスイートに残すと、開発者が失敗を無視するようになり、テストスイート全体の価値が損なわれます。
- スナップショットではなく傾向を測定してください。 カバレッジとミューテーションスコアの推移を追跡してください。絶対値が許容範囲に見えても、低下傾向はテスト品質の劣化を示します。
- 生成コードはカバレッジから除外してください。 マイグレーション、生成されたクライアント、スキャフォールドコードは、実際のテスト品質を反映しないまま、カバレッジの数値を実態より高く、または低く見せます。
エージェントに関する注意点
coverlet.collectorとcoverlet.msbuildを混同しないでください。coverlet.collectorパッケージでは--collect:"XPlat Code Coverage"CLI フラグを使用します。coverlet.msbuildパッケージでは/p:CollectCoverage=trueMSBuild プロパティを使用します。パッケージ間でフラグを混在させないでください。これらは独立した統合ポイントです。- カバレッジ結果のパスをハードコードしないでください。
TestResults/<guid>/coverage.cobertura.xmlのGUIDは実行のたびに変わります。カバレッジ出力ファイルを参照する際は、必ずグロブパターン(**/TestResults/*/coverage.cobertura.xml)を使用してください。 - 最初からカバレッジのしきい値を高く設定しないでください。 既存のプロジェクトで90%以上のしきい値から始めると、すべてのPRがブロックされます。現在のベースラインから始め、段階的に引き上げてください(例:四半期ごとに5%)。
- CIでソリューション全体に対してStryker.NETを実行しないでください。 ミューテーションテストはCPU負荷が高いため、CIでは変更されたファイル(
--since:main)または重要な処理経路に対象を限定してください。全体実行は夜間ビルド用にしてください。 - 単純なコードにおける生存ミューテーションを無視しないでください。 生存ミューテーションの中には、テストする必要のないコード(ロギング、
ToString())に関するものもありますが、それぞれ確認してください。テストしないと明確に判断したカテゴリについては、stryker-config.jsonでignore-mutationsを設定してください。 - カバレッジ不足への一律の対処として
[ExcludeFromCodeCoverage]を使わないでください。 この属性は問題を解決するのではなく、隠してしまいます。本当にテスト不可能なコード(プラットフォーム相互運用、生成コード)に限って使用し、その理由を必ず文書化してください。