mcpField
D
dotnet-test-quality

dotnet-test-quality

github.com/wshaddix测试与质量8681 次安装35 周前更新
代码仓库

关于 dotnet-test-quality

面向 .NET 开发者的测试质量分析技能,涵盖覆盖率、风险代码、测试有效性和不稳定测试。内容包括 Coverlet、ReportGenerator、CRAP 分析及 Stryker.NET 的使用指导。

主要功能

  1. 01使用 Coverlet 收集和筛选覆盖率
  2. 02通过 ReportGenerator 生成报告
  3. 03用 CRAP 分数识别高风险方法
  4. 04通过变异测试评估测试
  5. 05检测并管理不稳定测试

适用场景

  1. 01检查多个测试项目的覆盖率
  2. 02确定复杂且测试不足的方法优先级
  3. 03通过变异发现测试套件缺口
  4. 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 优化的 HTMLCI 工件
Cobertura合并后的 Cobertura XML供其他工具使用
TextSummary纯文本摘要命令行/CI 输出
BadgesSVG 覆盖率徽章README 徽章
MarkdownSummaryGithubGitHub 风格的 MarkdownPR 评论
示例:完整覆盖率流水线
#!/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 分数:

  1. 添加有针对性的测试,覆盖尚未测试的分支,以提高覆盖率、降低分数
  2. 降低复杂度,例如提取方法(如拆分为 CalculateBaseRate 和 ApplySurcharges 方法)
  3. 两者兼顾——最有效的方法是同时提高覆盖率并简化方法

使用 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 注意事项

  1. 不要混淆 coverlet.collector 和 coverlet.msbuild。 coverlet.collector 包使用 --collect:"XPlat Code Coverage" CLI 标志。coverlet.msbuild 包使用 /p:CollectCoverage=true MSBuild 属性。不要混用不同包的标志——它们是彼此独立的集成方式。
  2. 不要硬编码覆盖率结果路径。 TestResults/<guid>/coverage.cobertura.xml 中的 GUID 每次运行都会变化。引用覆盖率输出文件时,务必使用 glob 模式(**/TestResults/*/coverage.cobertura.xml)。
  3. 一开始不要设置过高的覆盖率阈值。 在现有项目中,一开始就设定 90% 以上的阈值会阻止所有 PR 合并。应从当前基线开始,逐步提高(例如每季度提高 5%)。
  4. 不要在 CI 中对整个解决方案运行 Stryker.NET。 变异测试会大量占用 CPU。在 CI 中,将变异范围限制为变更的文件(--since:main)或关键路径。完整运行应留给夜间构建。
  5. 不要忽略简单代码中存活的变异。 有些存活的变异确实出现在无需测试的代码中(日志记录、ToString()),但仍应逐一检查。在 stryker-config.json 中配置 ignore-mutations,指定你经过慎重考虑后决定不测试的类别。
  6. 不要将 [ExcludeFromCodeCoverage] 当作低覆盖率的通用解决办法。 这个属性只是掩盖问题,并没有解决问题。仅在确实无法测试的代码(平台互操作、生成的代码)中使用,并确保记录原因。

参考资料

评论

支持 Markdown