关于 dotnet-test-quality
面向 .NET 开发者的测试质量分析技能,涵盖覆盖率、风险代码、测试有效性和不稳定测试。内容包括 Coverlet、ReportGenerator、CRAP 分析及 Stryker.NET 的使用指导。
主要功能
- 01使用 Coverlet 收集和筛选覆盖率
- 02通过 ReportGenerator 生成报告
- 03用 CRAP 分数识别高风险方法
- 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(变更风险反模式)评分分析识别测试不足的复杂代码、使用 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 | 纯文本摘要 | 命令行/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.msbuild 包(而非 coverlet.collector)。对于使用 coverlet.collector 的工作流,请在 CI 脚本中解析 ReportGenerator 文本摘要来强制执行阈值。
CRAP 分析
CRAP(变更风险反模式)评分用于识别既复杂又测试不足的方法。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"
风险热点报告会按 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 会将变异结果分为四类:
| 状态 | 含义 | 操作 |
|---|---|---|
| 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]) |
| 依赖时间的逻辑 | 测试在午夜附近或特定时间失败 | 注入 TimeProvider(或 ISystemClock),而不是使用 DateTime.Now |
| 竞争条件 | 测试在并行执行时偶尔失败 | 使用 ICollectionFixture 管理共享资源;避免共享静态状态 |
| 外部依赖 | 网络/服务不可用时测试失败 | 模拟外部调用;使用 Testcontainers 管理基础设施 |
| 端口冲突 | 其他进程占用同一端口时测试失败 | 使用动态端口分配(WebApplicationFactory 会处理此问题) |
| 文件系统争用 | 并行执行时测试失败 | 为每个测试使用唯一的临时目录(参见 [skill:dotnet-xunit] 中的 IAsyncLifetime 模式) |
检测不稳定测试
重复运行
# 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%,如果断言不够有力,仍可能漏掉关键 Bug。
- 使用 CRAP 分数确定优先级。 将测试精力集中在复杂度高、覆盖率低的方法上,而不是一味追求整体覆盖率。
- 对关键路径运行变异测试。 变异测试的计算开销很大。应重点测试业务关键代码(定价、身份验证、数据验证),而不是对整个代码库运行变异测试。
- 立即修复不稳定的测试,或将其隔离。 如果不稳定的测试一直留在测试套件中,开发者会逐渐忽略失败,从而削弱整个测试套件的价值。
- 跟踪趋势,而非单次结果。 随时间跟踪覆盖率和变异分数。即使绝对数值看起来不错,趋势下降也表明测试质量正在退化。
- 将生成的代码排除在覆盖率统计之外。 迁移文件、生成的客户端和脚手架代码会抬高或拉低覆盖率数值,却不能反映实际的测试质量。
Agent 注意事项
- 不要混淆
coverlet.collector和coverlet.msbuild。coverlet.collector包使用--collect:"XPlat Code Coverage"CLI 标志。coverlet.msbuild包使用/p:CollectCoverage=trueMSBuild 属性。不要混用不同包的标志——它们是彼此独立的集成方式。 - 不要硬编码覆盖率结果路径。
TestResults/<guid>/coverage.cobertura.xml中的 GUID 每次运行都会变化。引用覆盖率输出文件时,务必使用 glob 模式(**/TestResults/*/coverage.cobertura.xml)。 - 一开始不要设置过高的覆盖率阈值。 在现有项目中,一开始就设定 90% 以上的阈值会阻止所有 PR 合并。应从当前基线开始,逐步提高(例如每季度提高 5%)。
- 不要在 CI 中对整个解决方案运行 Stryker.NET。 变异测试会大量占用 CPU。在 CI 中,将变异范围限制为变更的文件(
--since:main)或关键路径。完整运行应留给夜间构建。 - 不要忽略简单代码中存活的变异。 有些存活的变异确实出现在无需测试的代码中(日志记录、
ToString()),但仍应逐一检查。在stryker-config.json中配置ignore-mutations,指定你经过慎重考虑后决定不测试的类别。 - 不要将
[ExcludeFromCodeCoverage]当作低覆盖率的通用解决办法。 这个属性只是掩盖问题,并没有解决问题。仅在确实无法测试的代码(平台互操作、生成的代码)中使用,并确保记录原因。