浏览知识库目录

C#

测试、调试与代码质量

通过 xUnit v3、Microsoft Testing Platform、分析器和调试工具建立快速质量反馈。

测试、调试与代码质量

质量工具的目标不是制造更多规则,而是缩短可信反馈周期:测试验证可观察行为,编译器与分析器验证类型和常见缺陷,格式化减少无意义差异,调试器与结构化日志帮助解释失败。


一、学习目标

  • 使用 xUnit v3 编写行为测试
  • 区分单元、集成和端到端测试
  • 用可控时间、临时目录和替身隔离环境
  • 测试异常、取消和事务回滚
  • 启用编译器分析器与格式检查
  • 使用调试器、堆栈和日志定位问题

二、建立测试项目

第 1 篇已经用固定版本的 v3 模板创建项目:

dotnet new install xunit.v3.templates::3.2.2
dotnet new xunit3 -n StudyTasks.Tests \
  -o tests/StudyTasks.Tests -f net10.0
dotnet add tests/StudyTasks.Tests reference src/StudyTasks.Core
dotnet add tests/StudyTasks.Tests reference \
  src/StudyTasks.Infrastructure
dotnet test StudyTasks.slnx -c Release

xUnit v3 测试项目是可执行程序,并通过 global.json 中的 "test": { "runner": "Microsoft.Testing.Platform" } 与 .NET 10 的 dotnet test 集成。项目依赖固定为 xunit.v3 3.2.2。

测试文件按被测行为命名,例如 StudyTaskTests.csSqliteTaskRepositoryTests.cs。不要建立一个包含数百个不相关用例的 Tests.cs


三、第一个领域测试

public sealed class StudyTaskTests
{
    [Fact]
    public void Create_TrimsTitleAndCreatesPendingTask()
    {
        DateTimeOffset now =
            new(2026, 7, 23, 12, 0, 0, TimeSpan.Zero);

        StudyTask task = StudyTask.Create(
            "  阅读 C# 文档  ",
            now);

        Assert.Equal("阅读 C# 文档", task.Title);
        Assert.Equal(TaskStatus.Todo, task.Status);
        Assert.Null(task.CompletedAt);
        Assert.Equal(now, task.CreatedAt);
    }
}

测试名表达“操作、条件、预期”。断言关注公开行为,不验证私有字段或具体辅助方法调用。

异常:

[Theory]
[InlineData("")]
[InlineData("   ")]
public void Create_RejectsBlankTitle(string title)
{
    Assert.Throws<ArgumentException>(
        () => StudyTask.Create(title, DateTimeOffset.UtcNow));
}

理论测试适合相同行为的多组输入,但失败信息不清楚时应拆成命名用例。


四、可控时间

internal sealed class FixedTimeProvider(
    DateTimeOffset utcNow) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => utcNow;
}

测试服务:

[Fact]
public void Complete_UsesInjectedUtcTime()
{
    DateTimeOffset now =
        new(2026, 7, 23, 13, 0, 0, TimeSpan.Zero);
    var clock = new FixedTimeProvider(now);
    var repository = new InMemoryTaskRepository();
    StudyTask existing = repository.Add(
        StudyTask.Create("测试任务", now.AddHours(-1)));
    var service = new TaskService(repository, clock);

    StudyTask completed = service.Complete(existing.Id);

    Assert.Equal(now, completed.CompletedAt);
}

不要在测试里允许“当前时间前后几秒”的宽松断言,这会产生偶发失败并掩盖真实时区问题。


五、测试替身

常见替身:

  • Stub:返回固定数据
  • Fake:有可工作的简化实现,例如内存仓储
  • Spy:记录调用以便断言
  • Mock:按预期交互配置行为

优先使用简单手写 Fake:

internal sealed class StubRemoteTaskClient(
    IReadOnlyList<StudyTask> tasks)
    : IRemoteTaskClient
{
    public Task<IReadOnlyList<StudyTask>> FetchAsync(
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return Task.FromResult(tasks);
    }
}

只有交互本身是契约时才断言调用次数。过度 Mock 会把实现步骤写进测试,使安全重构也大量失败。


六、SQLite 集成测试

每个测试使用独立数据库。内存模式需要保持连接打开;更接近生产行为时使用临时目录文件。

public sealed class SqliteRepositoryTests : IDisposable
{
    private readonly string _directory =
        Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));

    public SqliteRepositoryTests()
    {
        Directory.CreateDirectory(_directory);
    }

    public void Dispose()
    {
        Directory.Delete(_directory, recursive: true);
    }
}

覆盖:

  • 新增返回数据库 ID
  • 唯一与 CHECK 约束
  • 状态和完成时间一起更新
  • 未知 ID 不修改数据库
  • 审计失败时事务回滚
  • 连接释放后文件可删除

清理失败也应让测试失败,因为它可能暴露资源泄漏。


七、异步与取消测试

[Fact]
public async Task FetchAsync_PropagatesCancellation()
{
    using var source = new CancellationTokenSource();
    source.Cancel();

    await Assert.ThrowsAnyAsync<OperationCanceledException>(
        () => client.FetchAsync(source.Token));
}

异步测试返回 Task,不要写 async void。使用已取消令牌、TaskCompletionSource 或可控处理器协调测试,不用长时间 Task.Delay 猜测调度顺序。

并发测试应证明不变量,例如最大同时请求数不超过配置,而不是依赖某一次日志顺序。


八、命令行测试

把命令构建与进程启动分开:

RootCommand command = CommandFactory.Create(service, output);
ParseResult result = command.Parse(["add", "阅读文档"]);
int exitCode = await result.InvokeAsync();

验证:

  • 合法输入调用服务并输出稳定字段
  • 非数字 ID 由解析器拒绝
  • 未知 ID 使用 stderr 和退出码 3
  • --help 不连接数据库
  • 日志不混入 stdout

少量真正的进程测试再验证打包后的可执行入口。


九、分析器和警告

Directory.Build.props

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  </PropertyGroup>
</Project>

构建:

dotnet build -c Release --warnaserror
dotnet format --verify-no-changes

抑制警告前先理解原因。确属误报时使用范围最小的抑制并写说明;不要在根配置中批量关闭未知规则。

升级 SDK 可能带来新分析规则。先单独评估,再决定修复、配置严重级别或有理由地抑制。


十、调试方法

先阅读:

  1. 异常类型与消息
  2. 最内层异常
  3. 第一处属于自己代码的堆栈帧
  4. 当前输入和相关结构化日志

调试器中使用条件断点、异常抛出时中断和线程/任务窗口。不要依赖修改生产代码加入大量 Console.WriteLine

CLI 可用:

dotnet test --logger "console;verbosity=detailed"
dotnet test --filter "FullyQualifiedName~SqliteRepositoryTests"

复现问题后先补一个失败测试,再修复。测试应表达用户可观察行为,而不是把原缺陷实现复制一遍。


十一、常见错误

测试依赖真实时间和公网

结果会因环境变化。注入时间,HTTP 使用本地处理器。

只断言 Mock 调用

测试会与实现耦合。优先断言返回值和状态变化。

集成测试共享一个数据库

测试顺序会影响结果。每个测试独立数据和生命周期。

为了通过构建关闭警告

应修复根因或最小化、有依据地抑制。


十二、练习与自测

练习:

  1. StudyTask 的所有不变量补理论测试。
  2. 用 Fake 仓储测试服务,不断言私有调用。
  3. 写 SQLite 事务回滚集成测试。
  4. 写 CLI 解析、stdout/stderr 与退出码测试。
  5. 在一个测试中引入资源泄漏,让清理断言捕获它。

自测:

  • Fake 与 Mock 的关注点有何不同?
  • 为什么异步测试不能返回 void?
  • 哪些行为必须用真实 SQLite 才能验证?
  • 100% 覆盖率不能证明什么?

十三、官方资料

上一篇:异步、并发与取消 | 下一篇:NuGet、发布与部署