mcpField
D
dotnet-testing-strategy

dotnet-testing-strategy

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

关于 dotnet-testing-strategy

dotnet-testing-strategy 是帮助 .NET 团队制定测试方案的技能,涵盖测试类型选择、项目组织,以及 Mock、Fake、Stub 等测试替身的取舍。它用具体标准指导团队依据基础设施、风险和维护成本安排测试。

主要功能

  1. 01选择单元、集成或端到端测试
  2. 02规划测试项目与类的组织
  3. 03应用测试命名和 AAA 模式
  4. 04区分 Mock、Fake、Stub 与 Spy
  5. 05识别常见测试反模式

适用场景

  1. 01为业务逻辑选择合适的测试类型
  2. 02按生产项目结构组织测试项目
  3. 03为依赖项选择合适的测试替身

安装

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

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-testing-strategy

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

详细介绍

dotnet-testing-strategy

用于在 .NET 应用程序中选择合适测试类型、组织测试项目和选择测试替身的决策框架。涵盖单元测试、集成测试与端到端测试之间的权衡,并提供具体标准、命名约定,以及何时使用模拟对象、伪对象或桩对象。

不在范围内: 测试项目脚手架(目录布局、创建 xUnit 项目、配置 coverlet、覆盖 editorconfig 设置)由 [skill:dotnet-add-testing] 负责。代码覆盖率工具和变异测试见 [skill:dotnet-test-quality]。CI 测试报告和流水线集成请参阅 [skill:dotnet-gha-build-test] 和 [skill:dotnet-ado-build-test]。

前置条件: 运行 [skill:dotnet-project-analysis],在设计测试策略前了解解决方案结构。

交叉参考:[skill:dotnet-xunit] 介绍 xUnit v3 测试框架功能;[skill:dotnet-integration-testing] 介绍 WebApplicationFactory 和 Testcontainers 模式;[skill:dotnet-snapshot-testing] 介绍基于 Verify 的审批测试;[skill:dotnet-test-quality] 介绍覆盖率和变异测试;[skill:dotnet-add-testing] 介绍测试项目脚手架。


测试类型决策树

使用此决策树确定适用于特定场景的测试类型。从顶部开始,遵循第一个符合条件的项。

Does the code under test depend on external infrastructure?
  (database, HTTP service, file system, message broker)
|
+-- YES --> Is the infrastructure behavior critical to correctness?
|           |
|           +-- YES --> Does it need the full application stack (middleware, auth, routing)?
|           |           |
|           |           +-- YES --> E2E / Functional Test
|           |           |           (WebApplicationFactory or Playwright)
|           |           |
|           |           +-- NO  --> Integration Test
|           |                       (WebApplicationFactory or Testcontainers)
|           |
|           +-- NO  --> Unit Test with test doubles
|                        (mock the infrastructure boundary)
|
+-- NO  --> Is this pure logic (calculations, transformations, validation)?
            |
            +-- YES --> Unit Test (no test doubles needed)
            |
            +-- NO  --> Unit Test with test doubles
                        (mock collaborator interfaces)
各测试类型的具体标准
测试类型基础设施速度范围适用场景
单元测试无(使用模拟对象/伪对象)每项测试 <10ms单个类/方法纯逻辑、领域规则、值对象、转换、验证器
集成测试真实(数据库、HTTP)每项测试 100ms-5s多个组件存储库查询、API 契约验证、序列化往返、中间件行为
端到端 / 功能测试完整堆栈每项测试 1-30s整个请求管道关键用户流程、身份验证 + 路由 + 中间件组合、横切关注点验证
成本效益指南
  • 业务逻辑优先使用单元测试。 它们运行快、能精准定位故障,而且不需要基础设施。
  • 使用集成测试验证基础设施边界是否正常工作。 使用模拟 DbContext 编写存储库单元测试,无法证明实际 SQL 生成正确——应通过 Testcontainers 使用真实数据库。
  • 仅对关键路径谨慎使用端到端测试。 它们速度慢、脆弱且维护成本高。覆盖正常流程以及一两个关键故障场景即可。
  • 测试金字塔是指南,而非规则。 有些应用程序(逻辑很少的 CRUD API)从比单元测试更多的集成测试中获益。应根据应用程序的复杂度特征调整策略。

测试组织

项目命名约定

在 tests/ 下镜像 src/ 项目结构,并添加后缀以标示测试类型:

MyApp/
  src/
    MyApp.Domain/
    MyApp.Application/
    MyApp.Api/
    MyApp.Infrastructure/
  tests/
    MyApp.Domain.UnitTests/
    MyApp.Application.UnitTests/
    MyApp.Api.IntegrationTests/
    MyApp.Api.FunctionalTests/
    MyApp.Infrastructure.IntegrationTests/
  • *.UnitTests —— 隔离测试,无外部依赖
  • *.IntegrationTests —— 使用真实基础设施(数据库、HTTP、文件系统)
  • *.FunctionalTests —— 通过 WebApplicationFactory 使用完整应用程序堆栈

请参阅 [skill:dotnet-add-testing],了解如何创建这些项目并正确配置包引用和构建。

测试类组织

每个生产类对应一个测试类。将测试文件放在与生产代码命名空间对应的命名空间中:

// Production: src/MyApp.Domain/Orders/OrderService.cs
// Test:       tests/MyApp.Domain.UnitTests/Orders/OrderServiceTests.cs
namespace MyApp.Domain.UnitTests.Orders;

public class OrderServiceTests
{
    // Group by method, then by scenario
}

对于较大的生产类,可按方法拆分测试类:

// OrderService_CreateTests.cs
// OrderService_CancelTests.cs
// OrderService_RefundTests.cs

测试命名约定

使用 Method_Scenario_ExpectedBehavior 模式。这种命名在测试资源管理器输出中读起来自然,也能让故障原因一目了然:

public class OrderServiceTests
{
    [Fact]
    public void CalculateTotal_WithDiscountCode_AppliesPercentageDiscount()
    {
        // ...
    }

    [Fact]
    public void CalculateTotal_WithExpiredDiscount_ThrowsInvalidOperationException()
    {
        // ...
    }

    [Fact]
    public async Task SubmitOrder_WhenInventoryInsufficient_ReturnsOutOfStockError()
    {
        // ...
    }
}

其他命名风格(每个项目选择一种并保持一致):

风格示例
Method_Scenario_ExpectedCalculateTotal_EmptyCart_ReturnsZero
Should_Expected_When_ScenarioShould_ReturnZero_When_CartIsEmpty
Given_When_ThenGivenEmptyCart_WhenCalculatingTotal_ThenReturnsZero

Arrange-Act-Assert 模式

每个测试都遵循 AAA 结构。将各部分清晰分开:

[Fact]
public async Task CreateOrder_WithValidItems_PersistsAndReturnsOrder()
{
    // Arrange
    var repository = new FakeOrderRepository();
    var service = new OrderService(repository);
    var request = new CreateOrderRequest
    {
        CustomerId = "cust-123",
        Items = [new OrderItem("SKU-001", Quantity: 2, UnitPrice: 29.99m)]
    };

    // Act
    var result = await service.CreateAsync(request);

    // Assert
    Assert.NotNull(result);
    Assert.Equal("cust-123", result.CustomerId);
    Assert.Single(result.Items);
    Assert.True(repository.SavedOrders.ContainsKey(result.Id));
}

指南: 如果无法清晰标出这三个部分,测试可能做得太多了。请拆分成多个测试。


测试替身:何时使用何种类型

术语
替身类型行为状态验证适用场景
Stub返回预设数据否需要依赖项返回特定值,以便被测代码继续执行
Mock验证交互是(交互)需要验证被测代码是否以特定方式调用了依赖项
Fake可工作的实现是(状态)需要轻量但可用的替代品(内存中的存储库、内存中的消息总线)
Spy记录调用以供之后断言是(交互)需要验证调用确实发生,但不预先规定调用方式
选择指南
Do you need to verify HOW a dependency was called?
|
+-- YES --> Do you need a working implementation too?
|           |
|           +-- YES --> Spy (record calls on a fake)
|           +-- NO  --> Mock (NSubstitute / Moq)
|
+-- NO  --> Do you need the dependency to DO something realistic?
            |
            +-- YES --> Fake (in-memory implementation)
            +-- NO  --> Stub (return canned values)
示例:Stub、Mock 与 Fake
// STUB: Returns canned data -- verifying the code under test's logic
var priceService = Substitute.For<IPriceService>();
priceService.GetPriceAsync("SKU-001").Returns(29.99m);  // canned return

var total = await calculator.CalculateTotalAsync(items);
Assert.Equal(59.98m, total);  // assert on the result, not the call

// MOCK: Verifies interaction -- ensuring a side effect happened
var emailSender = Substitute.For<IEmailSender>();

await orderService.CompleteAsync(order);

await emailSender.Received(1).SendAsync(             // assert on the call
    Arg.Is<string>(to => to == order.CustomerEmail),
    Arg.Any<string>(),
    Arg.Any<string>());

// FAKE: In-memory implementation -- realistic behavior without infrastructure
public class FakeOrderRepository : IOrderRepository
{
    public Dictionary<Guid, Order> Orders { get; } = new();

    public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => Task.FromResult(Orders.GetValueOrDefault(id));

    public Task SaveAsync(Order order, CancellationToken ct = default)
    {
        Orders[order.Id] = order;
        return Task.CompletedTask;
    }
}
何时优先使用 Fake 而非 Mock
  • 领域逻辑较重的应用: Fake 能更真实地处理复杂交互。内存中的存储库可以捕获 Mock 会漏掉的错误(例如重复键冲突)。
  • 过度使用 Mock 是一种测试异味。 如果测试中的 Mock 配置比实际断言还多,请考虑 Fake 是否更清晰、更易维护。
  • 通过真实基础设施测试集成边界更合适,而不是使用 Mock;请参阅 [skill:dotnet-integration-testing]。Mock DbContext 无法验证 LINQ 是否能转换成有效的 SQL。

测试反模式

1. 测试实现细节
// BAD: Breaks when refactoring internals
repository.Received(1).GetByIdAsync(Arg.Is<Guid>(id => id == orderId));
repository.Received(1).SaveAsync(Arg.Any<Order>());
// ... five more Received() calls verifying the exact call sequence

// GOOD: Test the observable outcome
var result = await service.ProcessAsync(orderId);
Assert.Equal(OrderStatus.Completed, result.Status);
2. 过多的 Mock 配置
// BAD: Mock setup is longer than the actual test
var repo = Substitute.For<IOrderRepository>();
var pricing = Substitute.For<IPricingService>();
var inventory = Substitute.For<IInventoryService>();
var shipping = Substitute.For<IShippingService>();
var notification = Substitute.For<INotificationService>();
var audit = Substitute.For<IAuditService>();
// ... 20 lines of .Returns() setup

// BETTER: Use a builder or fake that encapsulates setup
var fixture = new OrderServiceFixture()
    .WithOrder(testOrder)
    .WithPrice("SKU-001", 29.99m);
var result = await fixture.Service.ProcessAsync(testOrder.Id);
3. 非确定性测试

测试不得依赖系统时钟、随机值或外部网络。请注入抽象:

// BAD: Uses DateTime.UtcNow directly
public bool IsExpired() => ExpiresAt < DateTime.UtcNow;

// GOOD: Inject TimeProvider (.NET 8+)
public bool IsExpired(TimeProvider time) => ExpiresAt < time.GetUtcNow();

// In test
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero));
Assert.True(order.IsExpired(fakeTime));

核心原则

  • 测试行为,而非实现。 断言可观察到的结果(返回值、状态变化、已发布的事件),而非内部方法调用。
  • 每个测试关注一个逻辑断言。 如果多个 Assert 调用验证的是一个逻辑概念(例如返回对象的所有属性),则没有问题。多个互不相关的断言则表明测试应该拆分。
  • 保持测试相互独立。 不应有测试依赖其他测试的执行或顺序。每个测试都使用新的夹具。
  • 为测试命名,让失败原因一目了然。 即使不阅读测试主体,测试名称也应能说明发生了什么问题。
  • 根据风险选择测试类型。 高风险代码(支付、身份验证)应包含集成测试和 E2E 覆盖。低风险代码(简单映射)只需要单元测试。
  • 对依赖时间的逻辑使用 TimeProvider(.NET 8+)。这是框架提供的抽象;不要创建自定义的 IClock 接口。

Agent 易犯问题

  1. 不要 Mock 自己不拥有的类型。 Mock HttpClient、DbContext 或框架类型会导致测试脆弱,且无法反映真实行为。请改用 WebApplicationFactory 或 Testcontainers;详见 [skill:dotnet-integration-testing]。
  2. 创建测试项目之前,先检查是否已有现成结构。 请先运行 [skill:dotnet-project-analysis];重复的测试基础设施会导致构建冲突。
  3. 不要在测试中使用 Thread.Sleep。 请使用带取消令牌的 Task.Delay;更好的做法是使用 FakeTimeProvider.Advance(),以确定性的方式控制时间。
  4. 不要直接测试私有方法。 如果某个私有方法需要单独测试,就应将其提取到独立的类中。通过公共 API 进行测试。
  5. 不要在集成测试中硬编码连接字符串。 请使用 Testcontainers 创建可随时销毁的基础设施,或使用 WebApplicationFactory 进行进程内测试;详见 [skill:dotnet-integration-testing]。

参考资料

评论

支持 Markdown