dotnet测试策略 dotnet-testing-strategy

本技能提供.NET应用程序的测试策略框架,包括单元测试、集成测试和端到端测试的选择决策树、测试项目组织、命名约定以及测试替身(mock、fake、stub)的使用指南。适用于.NET开发者优化测试设计,提升代码质量和测试效率。关键词:.NET测试、单元测试、集成测试、端到端测试、测试策略、测试替身、mock、fake、stub、xUnit、Testcontainers、WebApplicationFactory。

测试 0 次安装 0 次浏览 更新于 3/6/2026

name: dotnet-testing-strategy description: “决定如何测试.NET代码。单元测试 vs 集成测试 vs 端到端测试决策树,测试替身。” user-invocable: false

dotnet-testing-strategy

.NET应用程序的测试策略决策框架,用于选择正确的测试类型、组织测试项目和选择测试替身。涵盖单元测试、集成测试和端到端测试之间的权衡,提供具体标准、命名约定以及何时使用mock、fake、stub。

范围

  • 单元测试、集成测试、端到端测试类型的决策标准
  • 测试项目组织和命名约定
  • 测试替身选择(mock、fake、stub、spy)
  • 测试安排模式和夹具设计

超出范围

  • 测试项目脚手架(目录布局、xUnit项目创建、coverlet设置)——参见[技能:dotnet-add-testing]
  • 代码覆盖工具和变异测试——参见[技能:dotnet-test-quality]
  • CI测试报告和流水线集成——参见[技能:dotnet-gha-build-test]和[技能:dotnet-ado-build-test]

前提条件: 在制定测试策略前,运行[技能:dotnet-project-analysis]以理解解决方案结构。

交叉引用:[技能:dotnet-xunit]用于xUnit v3测试框架特性,[技能:dotnet-integration-testing]用于WebApplicationFactory和Testcontainers模式,[技能:dotnet-snapshot-testing]用于基于Verify的批准测试,[技能:dotnet-test-quality]用于覆盖和变异测试,[技能:dotnet-add-testing]用于测试项目脚手架。


测试类型决策树

使用此决策树确定哪种测试类型适合给定场景。从顶部开始,遵循第一个匹配的标准。

被测代码是否依赖外部基础设施?
  (数据库、HTTP服务、文件系统、消息代理)
|
+-- 是 --> 基础设施行为是否对正确性关键?
|           |
|           +-- 是 --> 是否需要完整应用程序栈(中间件、认证、路由)?
|           |           |
|           |           +-- 是 --> 端到端/功能测试
|           |           |           (WebApplicationFactory或Playwright)
|           |           |
|           |           +-- 否 --> 集成测试
|           |                       (WebApplicationFactory或Testcontainers)
|           |
|           +-- 否 --> 使用测试替身的单元测试
|                        (模拟基础设施边界)
|
+-- 否 --> 这是纯逻辑吗(计算、转换、验证)?
            |
            +-- 是 --> 单元测试(无需测试替身)
            |
            +-- 否 --> 使用测试替身的单元测试
                        (模拟协作者接口)

按测试类型的具体标准

测试类型 基础设施 速度 范围 何时使用
单元测试 无(模拟/伪造) <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的完整应用程序栈

参见[技能:dotnet-add-testing]以使用正确的包引用和构建配置创建这些项目。

测试类组织

每个生产类一个测试类。将测试文件放在镜像生产命名空间的命名空间中:

// 生产:src/MyApp.Domain/Orders/OrderService.cs
// 测试:       tests/MyApp.Domain.UnitTests/Orders/OrderServiceTests.cs
namespace MyApp.Domain.UnitTests.Orders;

public class OrderServiceTests
{
    // 按方法分组,然后按场景
}

对于大型生产类,按方法拆分测试类:

// 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_Expected CalculateTotal_EmptyCart_ReturnsZero
Should_Expected_When_Scenario Should_ReturnZero_When_CartIsEmpty
Given_When_Then GivenEmptyCart_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 记录调用以供后续断言 是(交互) 需要验证调用发生而无需预先规定

决策指导

是否需要验证依赖如何被调用?
|
+-- 是 --> 是否需要工作实现?
|           |
|           +-- 是 --> Spy(在fake上记录调用)
|           +-- 否 --> Mock(NSubstitute / Moq)
|
+-- 否 --> 是否需要依赖做现实的事情?
            |
            +-- 是 --> Fake(内存实现)
            +-- 否 --> Stub(返回预设值)

示例:Stub vs Mock vs Fake

// STUB:返回预设数据——验证被测代码的逻辑
var priceService = Substitute.For<IPriceService>();
priceService.GetPriceAsync("SKU-001").Returns(29.99m);  // 预设返回

var total = await calculator.CalculateTotalAsync(items);
Assert.Equal(59.98m, total);  // 断言结果,而非调用

// MOCK:验证交互——确保副作用发生
var emailSender = Substitute.For<IEmailSender>();

await orderService.CompleteAsync(order);

await emailSender.Received(1).SendAsync(             // 断言调用
    Arg.Is<string>(to => to == order.CustomerEmail),
    Arg.Any<string>(),
    Arg.Any<string>());

// FAKE:内存实现——现实行为无需基础设施
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是否更清晰、更易维护。
  • 基础设施边界最好通过真实基础设施测试 via [技能:dotnet-integration-testing]而非mock。模拟DbContext不验证LINQ转换为有效SQL。

测试反模式

1. 测试实现细节

// 差:重构内部时中断
repository.Received(1).GetByIdAsync(Arg.Is<Guid>(id => id == orderId));
repository.Received(1).SaveAsync(Arg.Any<Order>());
// ... 更多五个Received()调用验证精确调用序列

// 好:测试可观察结果
var result = await service.ProcessAsync(orderId);
Assert.Equal(OrderStatus.Completed, result.Status);

2. 过度Mock设置

// 差:Mock设置比实际测试长
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行.Returns()设置

// 更好:使用封装设置的构建器或fake
var fixture = new OrderServiceFixture()
    .WithOrder(testOrder)
    .WithPrice("SKU-001", 29.99m);
var result = await fixture.Service.ProcessAsync(testOrder.Id);

3. 非确定性测试

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

// 差:直接使用DateTime.UtcNow
public bool IsExpired() => ExpiresAt < DateTime.UtcNow;

// 好:注入TimeProvider(.NET 8+)
public bool IsExpired(TimeProvider time) => ExpiresAt < time.GetUtcNow();

// 在测试中
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero));
Assert.True(order.IsExpired(fakeTime));

关键原则

  • 测试行为,而非实现。 断言可观察结果(返回值、状态变化、发布事件),而非内部方法调用。
  • 每个测试一个逻辑断言。 多个Assert调用是可以的,如果它们验证一个逻辑概念(例如返回对象的所有属性)。多个无关断言表示测试应拆分。
  • 保持测试独立。 任何测试不应依赖另一个测试的执行或顺序。为每个测试使用新夹具。
  • 命名测试使失败自文档化。 失败测试名称应告诉您什么坏了,无需阅读测试体。
  • 根据风险匹配测试类型。 高风险代码(支付、认证)值得集成和端到端覆盖。低风险代码(简单映射)仅需单元测试。
  • 使用TimeProvider处理时间依赖逻辑(.NET 8+)。它是框架提供的抽象;不要创建自定义IClock接口。

代理注意事项

  1. 不要模拟您不拥有的类型。 模拟HttpClientDbContext或框架类型导致脆弱的测试,不反映现实行为。改用WebApplicationFactory或Testcontainers——参见[技能:dotnet-integration-testing]。
  2. 不要创建测试项目而不检查现有结构。 首先运行[技能:dotnet-project-analysis];重复测试基础设施导致构建冲突。
  3. 不要在测试中使用Thread.Sleep 使用带取消令牌的Task.Delay,或更好,使用FakeTimeProvider.Advance()以确定性控制时间。
  4. 不要直接测试私有方法。 如果私有方法需要自己的测试,应提取到自己的类中。通过公共API测试。
  5. 不要在集成测试中硬编码连接字符串。 使用Testcontainers进行可丢弃基础设施或WebApplicationFactory进行进程内测试——参见[技能:dotnet-integration-testing]。

参考文献