名称: dotnet-test-quality 描述: “测量测试有效性。Coverlet 代码覆盖率、Stryker.NET 突变测试、Flaky 测试。” 用户可调用: false
dotnet-test-quality
.NET 项目的测试质量分析。涵盖使用 Coverlet 收集代码覆盖率、使用 ReportGenerator 生成可读性强的覆盖率报告、CRAP(变更风险反模式)分数分析以识别未充分测试的复杂代码、使用 Stryker.NET 进行突变测试以评估测试套件有效性,以及检测和管理 Flaky 测试的策略。
版本假设: Coverlet 6.x+、ReportGenerator 5.x+、Stryker.NET 4.x+(基于 .NET 8.0+)。Coverlet 支持 MSBuild 集成(coverlet.msbuild)和 coverlet.collector 数据收集器;示例使用 coverlet.collector 作为推荐方法。
范围
- Coverlet 代码覆盖率收集和配置
- ReportGenerator 生成可读性强的覆盖率报告
- CRAP 分数分析未充分测试的复杂代码
- Stryker.NET 突变测试用于测试套件评估
- Flaky 测试检测和管理策略
不在范围内
- 测试项目脚手架(创建项目、包引用、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>
收集覆盖率
# 使用 Cobertura 输出收集覆盖率(ReportGenerator 默认)
dotnet test --collect:"XPlat Code Coverage"
# 显式指定输出格式
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
# 多种格式
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 文件配置以实现可重复性:
<!-- coverlet.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
合并多个测试项目的覆盖率
当解决方案有多个测试项目时,将其覆盖率合并为单一报告:
# 运行所有测试,按项目收集覆盖率
dotnet test --collect:"XPlat Code Coverage"
# 查找所有覆盖率文件并通过 ReportGenerator 合并(见下一节)
使用 ReportGenerator 生成覆盖率报告
ReportGenerator 将原始覆盖率数据(Cobertura、OpenCover)转换为可读性强的 HTML 报告,带行级高亮。
安装
# 安装为全局工具
dotnet tool install -g dotnet-reportgenerator-globaltool
# 或作为本地工具
dotnet tool install dotnet-reportgenerator-globaltool
生成报告
# 单一覆盖率文件
reportgenerator \
-reports:"tests/MyApp.Tests/TestResults/*/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;TextSummary"
# 多个测试项目(全局模式自动合并)
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
# 清理先前结果
rm -rf coverage-report TestResults
# 运行测试并收集覆盖率
dotnet test --collect:"XPlat Code Coverage" --results-directory TestResults
# 生成合并的 HTML 报告
reportgenerator \
-reports:"**/TestResults/*/coverage.cobertura.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;TextSummary;Badges"
# 显示摘要
cat coverage-report/Summary.txt
设置覆盖率阈值
通过解析文本摘要或使用阈值参数在 CI 中强制执行最小覆盖率:
# ReportGenerator 不直接强制执行阈值。
# 解析摘要或使用 dotnet-coverage(Microsoft)进行阈值强制执行。
# 替代方案:通过 MSBuild 使用 Coverlet 的内置阈值
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 报告
ReportGenerator 在使用 OpenCover 格式作为输入时包含 CRAP 分析:
# 步骤 1:以 OpenCover 格式收集覆盖率
dotnet test --collect:"XPlat Code Coverage" \
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
# 步骤 2:生成带风险热点分析的报告
reportgenerator \
-reports:"**/TestResults/*/coverage.opencover.xml" \
-targetdir:"coverage-report" \
-reporttypes:"Html;RiskHotspots"
风险热点报告按 CRAP 分数高亮方法,显示:
- 方法名称和所属类
- 圈复杂度
- 代码覆盖率百分比
- 计算的 CRAP 分数
有效使用 CRAP 分数
// 示例:一个高复杂度和低覆盖率的方法
// 圈复杂度:12,覆盖率:20%
// CRAP = 12^2 * (1 - 0.20)^3 + 12 = 144 * 0.512 + 12 = 85.7(关键)
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 进行突变测试
突变测试通过向生产代码引入小变化(突变)并检查测试是否检测到它们来评估测试套件质量。如果突变存活(测试仍通过),则测试套件有漏洞。
安装
# 安装为全局工具
dotnet tool install -g dotnet-stryker
# 或作为本地工具(推荐用于团队一致性)
dotnet tool install dotnet-stryker
运行 Stryker.NET
# 从测试项目目录
cd tests/MyApp.Tests
dotnet stryker
# 显式指定源项目
dotnet stryker --project MyApp.csproj
# 目标特定文件
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 报告四类突变:
| 状态 | 含义 | 行动 |
|---|---|---|
| 已杀死 | 测试检测到突变(失败) | 好——测试套件捕获了缺陷 |
| 存活 | 没有测试检测到突变(全部通过) | 漏洞——添加或加强测试 |
| 无覆盖 | 没有测试覆盖突变代码 | 漏洞——为此代码添加测试 |
| 超时 | 突变导致无限循环或超时 | 通常视为已杀死(计入检测) |
突变分数
突变分数 = 已杀死 / (已杀死 + 存活 + 无覆盖) * 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, // 绿色:突变分数 >= 80%
"low": 60, // 黄色:60% <= 突变分数 < 80%
"break": 50 // 红色:突变分数 < 50% -> 退出码 1
}
}
break 阈值使 Stryker 返回非零退出码,适用于 CI 门控。
Flaky 测试检测
Flaky 测试间歇性地通过和失败,无代码更改。它们侵蚀对测试套件的信任并减慢开发。
常见原因
| 原因 | 症状 | 修复 |
|---|---|---|
| 共享可变状态 | 按特定顺序运行时测试失败 | 使用适当的测试隔离(参见 [skill:dotnet-xunit] 了解固件) |
| 时间依赖逻辑 | 测试在午夜附近或特定时间失败 | 注入 TimeProvider(或 ISystemClock)而非使用 DateTime.Now |
| 竞争条件 | 在并行执行下测试间歇性失败 | 对共享资源使用 ICollectionFixture;避免共享静态状态 |
| 外部依赖 | 网络/服务不可用时测试失败 | 模拟外部调用;对基础设施使用 Testcontainers |
| 端口冲突 | 另一个进程使用相同端口时测试失败 | 使用动态端口分配(WebApplicationFactory 处理此) |
| 文件系统争用 | 在并行执行下测试失败 | 每个测试使用唯一临时目录(参见 [skill:dotnet-xunit] IAsyncLifetime 模式) |
检测 Flaky 测试
重复运行
# 多次运行测试以暴露 flakiness
for i in $(seq 1 10); do
dotnet test --logger "trx;LogFileName=run-$i.trx" || echo "运行 $i 失败"
done
xUnit 条件跳过
xUnit v3 通过 [Fact] 上的 Skip 内置条件跳过:
// xUnit v3 — 内置条件跳过
[Fact(Skip = "需要外部服务")]
public async Task ExternalApi_ReturnsData()
{
var result = await _client.GetDataAsync();
Assert.NotEmpty(result);
}
// xUnit v3 — 通过 Assert.Skip 运行时跳过
[Fact]
public async Task ExternalApi_ReturnsData()
{
if (!await IsServiceAvailable())
Assert.Skip("外部服务不可用");
var result = await _client.GetDataAsync();
Assert.NotEmpty(result);
}
时间依赖测试
替换 DateTime.Now/DateTime.UtcNow 为 .NET 8 的 TimeProvider:
// 生产代码
public class SubscriptionService(TimeProvider timeProvider)
{
public bool IsExpired(Subscription sub)
{
var now = timeProvider.GetUtcNow();
return sub.ExpiresAt < now;
}
}
// 测试代码
[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)中可用。
隔离策略
当 Flaky 测试无法立即修复时:
// 标记为跳过,带跟踪问题
[Fact(Skip = "Flaky:在 #1234 中跟踪 —— 事件处理程序中的竞争条件")]
public async Task EventHandler_ConcurrentEvents_ProcessesAll()
{
// ...
}
不要删除 Flaky 测试。跳过它们并带问题引用,系统性地修复它们。
关键原则
- 覆盖率是滞后指标,而非目标。 高覆盖率不保证好测试。具有 90% 覆盖率的测试套件如果断言弱,仍可能错过关键缺陷。
- 使用 CRAP 分数来优先处理。 将测试努力集中在高复杂度和低覆盖率的方法上,而非追求整体覆盖率百分比。
- 在关键路径上运行突变测试。 突变测试计算成本高。集中在业务关键代码(定价、认证、数据验证),而非在整个代码库上运行它。
- 立即修复或隔离 Flaky 测试。 保留在套件中的 Flaky 测试训练开发者忽略失败,破坏整个测试套件的价值。
- 测量趋势,而非快照。 随时间跟踪覆盖率和突变分数。下降趋势表明测试质量侵蚀,即使绝对数字看起来可接受。
- 从覆盖率中排除生成的代码。 迁移、生成客户端和脚手架代码在不反映实际测试质量的情况下膨胀或压缩覆盖率数字。
代理注意事项
- 不要混淆
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。 突变测试计算成本高。在 CI 中,限制突变为更改的文件(
--since:main)或关键路径。将完整运行保留用于夜间构建。 - 不要忽略在琐碎代码中的存活突变。 虽然一些存活突变在无需测试的代码中(日志记录、
ToString()),但审查每一个。在stryker-config.json中配置ignore-mutations用于您有意识地决定不测试的类别。 - 不要使用
[ExcludeFromCodeCoverage]作为低覆盖率的笼统修复。 此属性隐藏问题而非解决问题。仅用于真正不可测试的代码(平台互操作、生成代码),并确保记录原因。