mcpField
D
dotnet-unit-testing

dotnet-unit-testing

github.com/ronnythedev测试与质量7762 次安装15 周前更新
代码仓库

关于 dotnet-unit-testing

面向 .NET Clean Architecture 项目的单元测试指南与模板,使用 xUnit、NSubstitute 和 FluentAssertions。除了处理器、验证器和实体测试,还强调状态流转、时间逻辑与阈值边界覆盖。

主要功能

  1. 01处理器、查询和验证器测试模板
  2. 02使用 xUnit、NSubstitute 和 FluentAssertions
  3. 03安排-执行-断言测试结构
  4. 04覆盖状态值及生产者到消费者流程
  5. 05通过 TimeProvider 测试时间和边界

适用场景

  1. 01为 Clean Architecture 命令处理器编写测试
  2. 02验证查询、输入校验和副作用
  3. 03检查不同状态值的处理行为
  4. 04测试时间驱动的状态转换和阈值

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-unit-testing

安装后在 Claude Code 中输入/dotnet-unit-testing确认已加载。

详细介绍

单元测试生成器

概述

适用于整洁架构处理程序的单元测试:

  • xUnit - 测试框架
  • NSubstitute - 模拟库
  • FluentAssertions - 可读性强的断言
  • AAA 模式 - Arrange、Act、Assert

快速参考

测试类型用途示例
成功测试验证正常路径Should_ReturnSuccess_When_ValidRequest
失败测试验证错误处理Should_ReturnFailure_When_NotFound
验证测试验证输入Should_ReturnValidationError_When_EmptyName
行为测试验证副作用Should_CallRepository_When_ValidRequest

测试项目结构

tests/
└── {name}.Application.UnitTests/
    ├── {Feature}/
    │   ├── Create{Entity}/
    │   │   ├── Create{Entity}CommandHandlerTests.cs
    │   │   └── Create{Entity}CommandValidatorTests.cs
    │   └── Get{Entity}ById/
    │       └── Get{Entity}ByIdQueryHandlerTests.cs
    ├── Abstractions/
    │   └── BaseTest.cs
    └── {name}.Application.UnitTests.csproj

模板:测试项目文件


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="NSubstitute" Version="5.1.0" />
    <PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="6.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\{name}.application\{name}.application.csproj" />
    <ProjectReference Include="..\..\src\{name}.domain\{name}.domain.csproj" />
  </ItemGroup>

</Project>

模板:基类测试

// tests/{name}.Application.UnitTests/Abstractions/BaseTest.cs
using NSubstitute;
using {name}.domain.abstractions;

namespace {name}.Application.UnitTests.Abstractions;

public abstract class BaseTest
{
    protected static CancellationToken CancellationToken => CancellationToken.None;

    /// <summary>
    /// Creates a mock that returns the provided result
    /// </summary>
    protected static T CreateMock<T>() where T : class
    {
        return Substitute.For<T>();
    }

    /// <summary>
    /// Helper to create a successful Result
    /// </summary>
    protected static Result<T> SuccessResult<T>(T value)
    {
        return Result.Success(value);
    }

    /// <summary>
    /// Helper to create a failed Result
    /// </summary>
    protected static Result<T> FailureResult<T>(Error error)
    {
        return Result.Failure<T>(error);
    }
}

模板:命令处理程序测试

// tests/{name}.Application.UnitTests/{Feature}/Create{Entity}/Create{Entity}CommandHandlerTests.cs
using FluentAssertions;
using NSubstitute;
using {name}.application.{feature}.Create{Entity};
using {name}.domain.{aggregate};
using {name}.domain.abstractions;
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Create{Entity};

public sealed class Create{Entity}CommandHandlerTests : BaseTest
{
    private readonly I{Entity}Repository _{entity}Repository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly Create{Entity}CommandHandler _handler;

    public Create{Entity}CommandHandlerTests()
    {
        // Arrange - Setup mocks (runs before each test)
        _{entity}Repository = CreateMock<I{Entity}Repository>();
        _unitOfWork = CreateMock<IUnitOfWork>();

        _handler = new Create{Entity}CommandHandler(
            _{entity}Repository,
            _unitOfWork);
    }

    // ═══════════════════════════════════════════════════════════════
    // SUCCESS TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Handle_Should_ReturnSuccess_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        var result = await _handler.Handle(command, CancellationToken);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBeEmpty();
    }

    [Fact]
    public async Task Handle_Should_AddEntity_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        _{entity}Repository
            .Received(1)
            .Add(Arg.Is<Domain.{Aggregate}.{Entity}>(e =>
                e.Name == command.Name &&
                e.OrganizationId == command.OrganizationId));
    }

    [Fact]
    public async Task Handle_Should_CallSaveChanges_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        await _unitOfWork
            .Received(1)
            .SaveChangesAsync(CancellationToken);
    }

    // ═══════════════════════════════════════════════════════════════
    // FAILURE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Handle_Should_ReturnFailure_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        var result = await _handler.Handle(command, CancellationToken);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameAlreadyExists);
    }

    [Fact]
    public async Task Handle_Should_NotAddEntity_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        _{entity}Repository
            .DidNotReceive()
            .Add(Arg.Any<Domain.{Aggregate}.{Entity}>());
    }

    [Fact]
    public async Task Handle_Should_NotCallSaveChanges_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        await _unitOfWork
            .DidNotReceive()
            .SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    // ═══════════════════════════════════════════════════════════════
    // HELPER METHODS
    // ═══════════════════════════════════════════════════════════════

    private static Domain.{Aggregate}.{Entity} CreateTest{Entity}(string name)
    {
        // Use reflection or internal factory for testing
        // This assumes the entity has a factory method
        var result = Domain.{Aggregate}.{Entity}.Create(
            name,
            "Description",
            Guid.NewGuid());

        return result.Value;
    }
}

模板:查询处理程序测试

// tests/{name}.Application.UnitTests/{Feature}/Get{Entity}ById/Get{Entity}ByIdQueryHandlerTests.cs
using FluentAssertions;
using NSubstitute;
using {name}.application.{feature}.Get{Entity}ById;
using {name}.application.abstractions.data;
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Get{Entity}ById;

public sealed class Get{Entity}ByIdQueryHandlerTests : BaseTest
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;
    private readonly Get{Entity}ByIdQueryHandler _handler;

    public Get{Entity}ByIdQueryHandlerTests()
    {
        _sqlConnectionFactory = CreateMock<ISqlConnectionFactory>();
        _handler = new Get{Entity}ByIdQueryHandler(_sqlConnectionFactory);
    }

    [Fact]
    public async Task Handle_Should_ReturnSuccess_When_EntityExists()
    {
        // Arrange
        var entityId = Guid.NewGuid();
        var query = new Get{Entity}ByIdQuery(entityId);

        var expected = new {Entity}Response
        {
            Id = entityId,
            Name = "Test Entity",
            Description = "Description"
        };

        // Setup Dapper mock (simplified - in practice, mock IDbConnection)
        SetupConnectionToReturn(expected);

        // Act
        var result = await _handler.Handle(query, CancellationToken);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Id.Should().Be(entityId);
    }

    [Fact]
    public async Task Handle_Should_ReturnFailure_When_EntityNotFound()
    {
        // Arrange
        var entityId = Guid.NewGuid();
        var query = new Get{Entity}ByIdQuery(entityId);

        SetupConnectionToReturn(null);

        // Act
        var result = await _handler.Handle(query, CancellationToken);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NotFound);
    }

    private void SetupConnectionToReturn({Entity}Response? response)
    {
        // In practice, you'd use a library like Dapper.Contrib.Tests
        // or create a test double for IDbConnection
        // This is a simplified example
    }
}

模板:验证器测试

// tests/{name}.Application.UnitTests/{Feature}/Create{Entity}/Create{Entity}CommandValidatorTests.cs
using FluentAssertions;
using FluentValidation.TestHelper;
using {name}.application.{feature}.Create{Entity};
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Create{Entity};

public sealed class Create{Entity}CommandValidatorTests : BaseTest
{
    private readonly Create{Entity}CommandValidator _validator;

    public Create{Entity}CommandValidatorTests()
    {
        _validator = new Create{Entity}CommandValidator();
    }

    // ═══════════════════════════════════════════════════════════════
    // NAME VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_HaveError_When_NameIsEmpty()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: string.Empty,
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Name)
            .WithErrorMessage("{Entity} name is required");
    }

    [Fact]
    public void Validate_Should_HaveError_When_NameTooLong()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: new string('a', 101),  // Exceeds 100 char limit
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Name)
            .WithErrorMessage("{Entity} name must not exceed 100 characters");
    }

    [Theory]
    [InlineData("A")]
    [InlineData("Valid Name")]
    [InlineData("Name with 100 characters padded................................")]
    public void Validate_Should_NotHaveError_When_NameIsValid(string name)
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: name,
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveValidationErrorFor(x => x.Name);
    }

    // ═══════════════════════════════════════════════════════════════
    // ORGANIZATION ID VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_HaveError_When_OrganizationIdIsEmpty()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.Empty);

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.OrganizationId);
    }

    [Fact]
    public void Validate_Should_NotHaveError_When_OrganizationIdIsValid()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveValidationErrorFor(x => x.OrganizationId);
    }

    // ═══════════════════════════════════════════════════════════════
    // FULL VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_BeValid_When_AllFieldsAreValid()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveAnyValidationErrors();
    }
}

模板:领域实体测试

// tests/{name}.Domain.UnitTests/{Aggregate}/{Entity}Tests.cs
using FluentAssertions;
using {name}.domain.{aggregate};
using {name}.domain.{aggregate}.events;

namespace {name}.Domain.UnitTests.{Aggregate};

public sealed class {Entity}Tests
{
    // ═══════════════════════════════════════════════════════════════
    // CREATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Create_Should_ReturnSuccess_When_ValidParameters()
    {
        // Arrange
        var name = "Test Entity";
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Name.Should().Be(name);
        result.Value.OrganizationId.Should().Be(organizationId);
        result.Value.IsActive.Should().BeTrue();
    }

    [Fact]
    public void Create_Should_ReturnFailure_When_NameIsEmpty()
    {
        // Arrange
        var name = string.Empty;
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameRequired);
    }

    [Fact]
    public void Create_Should_RaiseDomainEvent_When_Success()
    {
        // Arrange
        var name = "Test Entity";
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.Value.GetDomainEvents()
            .Should().ContainSingle()
            .Which.Should().BeOfType<{Entity}CreatedDomainEvent>();
    }

    // ═══════════════════════════════════════════════════════════════
    // UPDATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void UpdateName_Should_ReturnSuccess_When_ValidName()
    {
        // Arrange
        var entity = Create{Entity}();
        var newName = "Updated Name";

        // Act
        var result = entity.UpdateName(newName);

        // Assert
        result.IsSuccess.Should().BeTrue();
        entity.Name.Should().Be(newName);
    }

    [Fact]
    public void UpdateName_Should_ReturnFailure_When_EmptyName()
    {
        // Arrange
        var entity = Create{Entity}();

        // Act
        var result = entity.UpdateName(string.Empty);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameRequired);
    }

    // ═══════════════════════════════════════════════════════════════
    // DEACTIVATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Deactivate_Should_SetIsActiveToFalse()
    {
        // Arrange
        var entity = Create{Entity}();
        entity.IsActive.Should().BeTrue();

        // Act
        entity.Deactivate();

        // Assert
        entity.IsActive.Should().BeFalse();
    }

    [Fact]
    public void Deactivate_Should_RaiseDomainEvent()
    {
        // Arrange
        var entity = Create{Entity}();
        entity.ClearDomainEvents();  // Clear create event

        // Act
        entity.Deactivate();

        // Assert
        entity.GetDomainEvents()
            .Should().ContainSingle()
            .Which.Should().BeOfType<{Entity}DeactivatedDomainEvent>();
    }

    // ═══════════════════════════════════════════════════════════════
    // HELPER METHODS
    // ═══════════════════════════════════════════════════════════════

    private static {Entity} Create{Entity}()
    {
        var result = {Entity}.Create(
            "Test Entity",
            "Test Description",
            Guid.NewGuid());

        return result.Value;
    }
}

模板:测试数据构造器

// tests/{name}.Application.UnitTests/TestData/{Entity}Builder.cs
using {name}.application.{feature}.Create{Entity};

namespace {name}.Application.UnitTests.TestData;

public sealed class {Entity}CommandBuilder
{
    private string _name = "Default Name";
    private string _description = "Default Description";
    private Guid _organizationId = Guid.NewGuid();

    public {Entity}CommandBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public {Entity}CommandBuilder WithDescription(string description)
    {
        _description = description;
        return this;
    }

    public {Entity}CommandBuilder WithOrganizationId(Guid organizationId)
    {
        _organizationId = organizationId;
        return this;
    }

    public Create{Entity}Command Build()
    {
        return new Create{Entity}Command(_name, _description, _organizationId);
    }
}

// Usage in tests:
// var command = new {Entity}CommandBuilder()
//     .WithName("Custom Name")
//     .Build();

NSubstitute 快速参考

// Create mock
var repository = Substitute.For<IRepository>();

// Setup return value
repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .Returns(entity);

// Setup return null
repository.GetByIdAsync(entityId, CancellationToken)
    .Returns((Entity?)null);

// Verify method was called
repository.Received(1).Add(Arg.Any<Entity>());

// Verify method was NOT called
repository.DidNotReceive().Add(Arg.Any<Entity>());

// Verify with argument matching
repository.Received().Add(Arg.Is<Entity>(e => e.Name == "Test"));

// Verify call order (advanced)
Received.InOrder(() =>
{
    repository.Add(Arg.Any<Entity>());
    unitOfWork.SaveChangesAsync(CancellationToken);
});

// Setup to throw exception
repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .ThrowsAsync(new Exception("Database error"));

FluentAssertions 快速参考

// Basic assertions
result.Should().BeTrue();
result.Should().BeFalse();
result.Should().BeNull();
result.Should().NotBeNull();

// Equality
result.Should().Be(expected);
result.Should().NotBe(unexpected);
result.Should().BeEquivalentTo(expected);

// Collections
list.Should().BeEmpty();
list.Should().NotBeEmpty();
list.Should().HaveCount(3);
list.Should().Contain(item);
list.Should().ContainSingle();
list.Should().ContainSingle().Which.Should().BeOfType<MyType>();

// Types
result.Should().BeOfType<MyType>();
result.Should().BeAssignableTo<IMyInterface>();

// Strings
name.Should().StartWith("Test");
name.Should().Contain("Entity");
name.Should().BeNullOrEmpty();

// Exceptions
action.Should().Throw<InvalidOperationException>()
    .WithMessage("*not found*");

action.Should().NotThrow();

// Result pattern
result.IsSuccess.Should().BeTrue();
result.Error.Should().Be(ExpectedError);

关键规则

  1. 每个测试只断言一个概念 - 专注于单一行为
  2. 测试名称清晰明确 - Should_{ExpectedBehavior}_When_{Condition}
  3. Arrange-Act-Assert - 每个测试都要有清晰的结构
  4. 只模拟依赖项 - 不要模拟被测系统(SUT)
  5. 测试行为,而非实现 - 关注结果
  6. 使用 Theory 编写数据驱动测试 - 避免重复测试逻辑
  7. 测试边界情况 - 空值、null、边界值
  8. 测试运行要快 - 不进行 I/O,不连接数据库
  9. 测试相互独立 - 不共享状态
  10. 断言有意义 - 测试重要内容

覆盖率规范:状态、生命周期和边界

以下四条规则可以捕获这样一类 Bug:某个处理程序静默忽略了一个判别字段的值,而系统中的另一个处理程序正在生成该值。孤立地针对每个状态值测试处理程序还不够——你必须通过生成方完成往返测试。

1. 判别字段矩阵是必需项,而非可选项

对于任何根据状态/种类/枚举字符串/标志字段进行分支的处理程序,测试套件必须针对规范常量类中声明的每个值进行参数化(例如 OrderStatuses.All)。向该类添加新值属于覆盖率事件:每个使用方都必须声明其对新值的预期行为,否则测试套件就不再是规范。

[Theory]
[InlineData("pending",   false)]
[InlineData("shipped",   true)]
[InlineData("delivered", true)]   // <-- often the missed row
[InlineData("cancelled", false)]
public async Task Handle_RespectsStatus(string status, bool shouldSend)
{
    await SeedAsync(NewOrder(status, jobs: [new OrderEmailJob { Sent = false }]));
    var result = await _sut.Handle(new SendDueOrderEmailsCommand(), CancellationToken.None);
    result.Sent.ShouldBe(shouldSend ? 1 : 0);
}

**反模式:**编写一个 Should_NotSend_When_NonShipped fact,并为每个“非正常路径”值编写一个 [InlineData]。上面的表格格式会强制每一行都对预期行为作出正向断言;负向形式则可能让你漏掉某些行。

2. 在同一测试中测试生成方和使用方

当处理程序 A 写入判别字段,而处理程序 B 读取该字段时,至少有一个测试必须通过实际存储依次执行 A 和 B。不要在 Arrange 步骤中直接修改字段——这会绕过执行写入操作的生产代码路径,并掩盖我们要捕获的 Bug。

[Fact]
public async Task SendDueEmails_FiresOnDeliveredOrders_AfterPromoterRuns()
{
    // Arrange: a Shipped order with an unsent job
    var order = NewOrder(OrderStatuses.Shipped, jobs: [new OrderEmailJob { Sent = false }]);
    await SeedAsync(order);

    // Act 1: the OTHER handler runs first and promotes Status to Delivered
    var promoter = new PromoteShippedToDeliveredCommandHandler(_context);
    await promoter.Handle(new PromoteShippedToDeliveredCommand(), CancellationToken.None);

    // Act 2: now the handler under test runs
    var result = await _sut.Handle(new SendDueOrderEmailsCommand(), CancellationToken.None);

    // Assert: business-level question — should a Delivered order still flush its email queue?
    // Answer this against the spec, not against the current Where(...) clause.
    result.Sent.ShouldBe(1);
}

**为何这点不容妥协:**CRUD 系统中最棘手的 Bug,往往藏在两个从未被一起编写的处理程序之间的隐式耦合中。在 Arrange 步骤中直接修改字段(order.Status = "delivered")会悄无声息地掩盖这种耦合。通过生成方进行往返测试,才能让这种耦合显现出来。

**何时编写此测试:**每当你发现代码库中的另一个处理程序会写入 .Where(...) 所读取的同一字段时,都应编写此测试。搜索对该字段的赋值;对每个写入处理程序,都添加一个生成方和使用方测试。

3. 注入时钟;禁止在处理程序中直接调用 DateTime.UtcNow

禁止在处理程序、服务和验证器中直接调用 DateTime.UtcNow(或 DateTime.Now)。注入 TimeProvider(.NET 8+);在测试中使用 Microsoft.Extensions.Time.Testing.FakeTimeProvider,以便单个测试可以在状态转换过程中推进时间。

public class SendDueOrderEmailsCommandHandler
{
    private readonly TimeProvider _clock;
    // ctor injects TimeProvider

    public async Task<...> Handle(...)
    {
        var utcNow = _clock.GetUtcNow().UtcDateTime;
        // ...
    }
}

// In tests:
private readonly FakeTimeProvider _clock = new(startDateTime: new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero));
// Construct the SUT with _clock instead of TimeProvider.System.

[Fact]
public async Task Job_Fires_AfterWindowElapses()
{
    await SeedDueJobAt(_clock.GetUtcNow().UtcDateTime.AddMinutes(60));

    _clock.Advance(TimeSpan.FromMinutes(59));
    (await _sut.Handle(...)).Sent.ShouldBe(0);  // not yet

    _clock.Advance(TimeSpan.FromMinutes(2));
    (await _sut.Handle(...)).Sent.ShouldBe(1);  // crossed the threshold
}

为什么这是强制要求: 在 #1 所述的问题之后,由时间驱动的状态转换是生产环境中最常见的静默 Bug 来源。只要生产代码直接读取系统时钟,这类转换就根本无法测试。第一次编写读取“当前时间”的处理程序时,就添加 TimeProvider 构造函数参数——以后再补成本很高。

4. 阈值三点测试

任何在阈值处发生的行为变化(事件结束时间 + 偏移量、宽限窗口边界、注册截止时间、容量限制、重试等待时间)都需要 三个 明确的测试:刚好在阈值之前、恰好位于阈值,以及刚好在阈值之后。

[Theory]
[InlineData(59,  false)]   // just before
[InlineData(60,  true)]    // exactly at
[InlineData(61,  true)]    // just after
public async Task Job_FiresOnceWindowReached(int minutesElapsed, bool shouldFire)
{
    await SeedDueJobAt(_clock.GetUtcNow().UtcDateTime.AddMinutes(60));
    _clock.Advance(TimeSpan.FromMinutes(minutesElapsed));
    (await _sut.Handle(...)).Sent.ShouldBe(shouldFire ? 1 : 0);
}

三行 [InlineData] 比它们能防止的差一错误成本低得多。

合理化借口表——如果发现自己有以下任何想法,请立即停止
借口事实
“其他状态显然不适用,筛选条件没问题”如果它们“显然”不适用,那么对应的 InlineData 行只需 5 秒就能写出断言。写上它。
“Status 只会在 Create 期间设置,所以我直接填充 Status = X”之后可能有其他处理程序会写入它。相信自己之前,先在整个代码库中搜索 \.Status =。
“Promoter 是另一个测试要关注的内容”Promoter 和 Reader 之间的耦合正是这类 Bug 的根源。一个往返测试就能明确展现这种耦合。
“这个简单处理程序用 TimeProvider 太小题大做了”每个读取 UtcNow 的处理程序,都可能因为“未来的工程师修改上游状态”而变得无法测试。提前承担这项成本。
“边界测试太吹毛求疵了”阈值处的差一错误是状态筛选之后最常见的静默生产环境 Bug。写三行 InlineData。
“我的理论只需要我正在断言的那些值”这个理论就是一份不断演进的规范。缺少行 = 规范悄然缺失 = 未来回归。
危险信号——立即停止并重新测试
  • 存在 .Where(x => x.Field == constant),却没有参数化测试覆盖 Field 的每个值
  • 测试通过 entity.Field = "X" 设置判别字段,而不是运行会生成该值的处理程序
  • 处理程序直接调用 DateTime.UtcNow,且没有 TimeProvider 构造函数参数
  • 行为阈值(偏移量、截止时间、限制)只用一个值测试,而不是三个

如果你在 PR 中发现上述任何问题,请在批准前要求补上缺少的测试。


应避免的反模式

// ❌ WRONG: Multiple unrelated assertions
[Fact]
public void Test_Everything()
{
    // Tests too many things at once
    result.IsSuccess.Should().BeTrue();
    result.Value.Name.Should().Be("Test");
    repository.Received(1).Add(Arg.Any<Entity>());
    unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}

// ✅ CORRECT: Focused tests
[Fact]
public void Handle_Should_ReturnSuccess_When_ValidRequest() { }

[Fact]
public void Handle_Should_AddEntity_When_ValidRequest() { }

[Fact]
public void Handle_Should_CallSaveChanges_When_ValidRequest() { }

// ❌ WRONG: Testing implementation details
repository.Received(1).GetByIdAsync(entityId, CancellationToken);
repository.Received(1).Add(Arg.Any<Entity>());
// ... testing every single call

// ✅ CORRECT: Testing outcomes
result.IsSuccess.Should().BeTrue();
result.Value.Id.Should().NotBeEmpty();

// ❌ WRONG: Shared mutable state
private Entity _sharedEntity;  // Modified by tests, causes flaky tests

// ✅ CORRECT: Fresh setup per test
[Fact]
public void Test()
{
    var entity = CreateEntity();  // Fresh instance
}

相关技能

  • dotnet-cqrs-command-generator - 要测试的命令
  • dotnet-cqrs-query-generator - 要测试的查询
  • dotnet-domain-entity-generator - 要测试的领域实体
  • dotnet-integration-testing - 端到端测试

评论

支持 Markdown