dotnet-testing-strategy
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesedotnet-testing-strategy
dotnet-testing-strategy
Decision framework for choosing the right test type, organizing test projects, and selecting test doubles in .NET applications. Covers unit vs integration vs E2E trade-offs with concrete criteria, naming conventions, and when to use mocks vs fakes vs stubs.
Out of scope: Test project scaffolding (directory layout, xUnit project creation, coverlet setup, editorconfig overrides) is owned by [skill:dotnet-add-testing]. Code coverage tooling and mutation testing are covered by [skill:dotnet-test-quality]. CI test reporting and pipeline integration -- see [skill:dotnet-gha-build-test] and [skill:dotnet-ado-build-test].
Prerequisites: Run [skill:dotnet-project-analysis] to understand the solution structure before designing a test strategy.
Cross-references: [skill:dotnet-xunit] for xUnit v3 testing framework features, [skill:dotnet-integration-testing] for WebApplicationFactory and Testcontainers patterns, [skill:dotnet-snapshot-testing] for Verify-based approval testing, [skill:dotnet-test-quality] for coverage and mutation testing, [skill:dotnet-add-testing] for test project scaffolding.
用于.NET应用中选择合适测试类型、组织测试项目以及选择测试替身的决策框架。涵盖单元测试、集成测试与E2E测试的权衡取舍,包含具体判断标准、命名规范,以及mock、fake和stub的适用场景。
超出范围: 测试项目脚手架(目录结构、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]了解解决方案结构。
交叉参考:xUnit v3测试框架特性请见[skill:dotnet-xunit],WebApplicationFactory和Testcontainers模式请见[skill:dotnet-integration-testing],基于Verify的验收测试请见[skill:dotnet-snapshot-testing],覆盖率和变异测试请见[skill:dotnet-test-quality],测试项目脚手架请见[skill:dotnet-add-testing]。
Test Type Decision Tree
测试类型决策树
Use this decision tree to determine which test type fits a given scenario. Start at the top and follow the first matching criterion.
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)使用该决策树判断特定场景适用的测试类型,从顶部开始匹配首个符合的条件。
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)Concrete Criteria by Test Type
按测试类型划分的具体判断标准
| Test Type | Infrastructure | Speed | Scope | When to Use |
|---|---|---|---|---|
| Unit | None (mocked/faked) | <10ms per test | Single class/method | Pure logic, domain rules, value objects, transformations, validators |
| Integration | Real (DB, HTTP) | 100ms-5s per test | Multiple components | Repository queries, API contract verification, serialization round-trips, middleware behavior |
| E2E / Functional | Full stack | 1-30s per test | Entire request pipeline | Critical user flows, auth + routing + middleware combined, cross-cutting concern verification |
| 测试类型 | 依赖基础设施情况 | 速度 | 测试范围 | 适用场景 |
|---|---|---|---|---|
| 单元测试 | 无依赖(全mocked/faked) | 单条测试<10ms | 单个类/方法 | 纯逻辑、领域规则、值对象、数据转换、验证器 |
| 集成测试 | 真实基础设施(数据库、HTTP服务等) | 单条测试100ms-5s | 多个组件 | 仓库查询、API契约验证、序列化往返、中间件行为 |
| E2E / 功能测试 | 完整技术栈 | 单条测试1-30s | 整个请求链路 | 核心用户流程、鉴权+路由+中间件组合测试、横切关注点验证 |
Cost-Benefit Guidance
成本收益指导
- Prefer unit tests for business logic. They run fast, pinpoint failures precisely, and have no infrastructure requirements.
- Use integration tests to verify infrastructure boundaries work correctly. A repository unit test with a mocked proves nothing about actual SQL generation -- use a real database via Testcontainers.
DbContext - Use E2E tests sparingly for critical paths only. They are slow, brittle, and expensive to maintain. Cover the happy path and one or two critical failure scenarios.
- The testing pyramid is a guideline, not a rule. Some applications (CRUD APIs with minimal logic) benefit from more integration tests than unit tests. Match the strategy to the application's complexity profile.
- 业务逻辑优先使用单元测试:运行速度快,可精确定位故障,无基础设施依赖。
- 使用集成测试验证基础设施边界的正确性:使用mocked 的仓库单元测试无法验证实际SQL生成效果,应该通过Testcontainers连接真实数据库测试。
DbContext - 谨慎使用E2E测试,仅覆盖核心路径:E2E测试运行慢、脆弱、维护成本高,只需覆盖主流程和1-2个核心故障场景即可。
- 测试金字塔是指导原则而非硬性规则:部分应用(逻辑极少的CRUD API)用更多集成测试比单元测试收益更高,需要根据应用的复杂度特点匹配测试策略。
Test Organization
测试项目组织
Project Naming Convention
项目命名规范
Mirror the project structure under with a suffix indicating test type:
src/tests/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/- -- isolated tests, no external dependencies
*.UnitTests - -- real infrastructure (database, HTTP, file system)
*.IntegrationTests - -- full application stack via
*.FunctionalTestsWebApplicationFactory
See [skill:dotnet-add-testing] for creating these projects with proper package references and build configuration.
在目录下镜像的项目结构,添加后缀标识测试类型:
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 - -- 依赖真实基础设施(数据库、HTTP、文件系统)
*.IntegrationTests - -- 通过
*.FunctionalTests启动完整应用栈测试WebApplicationFactory
创建这些项目并配置正确的包引用和构建配置的方法请参考[skill:dotnet-add-testing]。
Test Class Organization
测试类组织
One test class per production class. Place test files in a namespace that mirrors the production namespace:
csharp
// 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
}For large production classes, split test classes by method:
csharp
// OrderService_CreateTests.cs
// OrderService_CancelTests.cs
// OrderService_RefundTests.cs每个生产类对应一个测试类,测试文件的命名空间镜像生产代码的命名空间:
csharp
// Production: src/MyApp.Domain/Orders/OrderService.cs
// Test: tests/MyApp.Domain.UnitTests/Orders/OrderServiceTests.cs
namespace MyApp.Domain.UnitTests.Orders;
public class OrderServiceTests
{
// 按方法分组,再按场景分组
}如果生产类代码量很大,可以按方法拆分测试类:
csharp
// OrderService_CreateTests.cs
// OrderService_CancelTests.cs
// OrderService_RefundTests.csTest Naming Conventions
测试命名规范
Use the pattern. This reads naturally in test explorer output and makes failures self-documenting:
Method_Scenario_ExpectedBehaviorcsharp
public class OrderServiceTests
{
[Fact]
public void CalculateTotal_WithDiscountCode_AppliesPercentageDiscount()
{
// ...
}
[Fact]
public void CalculateTotal_WithExpiredDiscount_ThrowsInvalidOperationException()
{
// ...
}
[Fact]
public async Task SubmitOrder_WhenInventoryInsufficient_ReturnsOutOfStockError()
{
// ...
}
}Alternative naming styles (choose one per project and stay consistent):
| Style | Example |
|---|---|
| |
| |
| |
使用的命名模式,这样在测试资源管理器的输出中可读性好,故障信息自带文档效果:
方法名_场景_预期行为csharp
public class OrderServiceTests
{
[Fact]
public void CalculateTotal_WithDiscountCode_AppliesPercentageDiscount()
{
// ...
}
[Fact]
public void CalculateTotal_WithExpiredDiscount_ThrowsInvalidOperationException()
{
// ...
}
[Fact]
public async Task SubmitOrder_WhenInventoryInsufficient_ReturnsOutOfStockError()
{
// ...
}
}可选命名风格(每个项目选择一种并保持一致即可):
| 风格 | 示例 |
|---|---|
| |
| |
| |
Arrange-Act-Assert Pattern
Arrange-Act-Assert模式
Every test follows the AAA structure. Keep each section clearly separated:
csharp
[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));
}Guideline: If you cannot clearly label the three sections, the test may be doing too much. Split into multiple tests.
所有测试都遵循AAA结构,保持三个部分清晰分隔:
csharp
[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));
}指导原则: 如果你无法清晰划分三个阶段,说明这个测试可能承担了过多职责,应该拆分为多个测试。
Test Doubles: When to Use What
测试替身:不同类型的适用场景
Terminology
术语定义
| Double Type | Behavior | State Verification | Use When |
|---|---|---|---|
| Stub | Returns canned data | No | You need a dependency to return specific values so the code under test can proceed |
| Mock | Verifies interactions | Yes (interaction) | You need to verify that the code under test called a dependency in a specific way |
| Fake | Working implementation | Yes (state) | You need a lightweight but functional substitute (in-memory repository, in-memory message bus) |
| Spy | Records calls for later assertion | Yes (interaction) | You need to verify calls happened without prescribing them upfront |
| 替身类型 | 行为 | 状态验证 | 适用场景 |
|---|---|---|---|
| Stub | 返回预设数据 | 否 | 你需要依赖返回特定值才能让被测代码正常执行时 |
| Mock | 验证交互行为 | 是(交互层面) | 你需要验证被测代码是否以特定方式调用了依赖时 |
| Fake | 可运行的简化实现 | 是(状态层面) | 你需要轻量但功能完整的替代实现时(内存仓库、内存消息总线) |
| Spy | 记录调用供后续断言 | 是(交互层面) | 你需要验证调用确实发生,但不需要提前预设调用规则时 |
Decision Guidance
选择决策指导
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)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)Example: Stub vs Mock vs Fake
示例:Stub vs Mock vs Fake
csharp
// 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;
}
}csharp
// 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;
}
}When to Prefer Fakes Over Mocks
优先选择Fake而非Mock的场景
- Domain-heavy applications: Fakes give more realistic behavior for complex interactions. An in-memory repository catches bugs that mocks miss (e.g., duplicate key violations).
- Overuse of mocks is a test smell. If a test has more mock setup than actual assertions, consider whether a fake would be clearer and more maintainable.
- Integration boundaries are better tested with real infrastructure via [skill:dotnet-integration-testing] than with mocks. A mocked does not verify that your LINQ translates to valid SQL.
DbContext
- 领域逻辑复杂的应用:对于复杂交互,Fake可以提供更真实的行为,内存仓库可以发现Mock漏掉的Bug(比如重复键冲突)。
- 过度使用Mock是测试坏味道:如果一个测试里Mock配置的代码比实际断言还多,考虑用Fake会不会更清晰、更易维护。
- 集成边界测试用真实基础设施效果更好:通过[skill:dotnet-integration-testing]使用真实基础设施测试,比用Mock效果好,Mock的无法验证你的LINQ是否可以转换为有效SQL。
DbContext
Testing Anti-Patterns
测试反模式
1. Testing Implementation Details
1. 测试实现细节
csharp
// 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);csharp
// 反面示例:重构内部实现时测试就会失效
repository.Received(1).GetByIdAsync(Arg.Is<Guid>(id => id == orderId));
repository.Received(1).SaveAsync(Arg.Any<Order>());
// ... 还有5个Received()调用验证精确的调用顺序
// 正面示例:测试可观测的输出
var result = await service.ProcessAsync(orderId);
Assert.Equal(OrderStatus.Completed, result.Status);2. Excessive Mock Setup
2. 过多的Mock配置
csharp
// 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);csharp
// 反面示例: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()配置代码
// 优化方案:使用Builder或者Fake封装配置
var fixture = new OrderServiceFixture()
.WithOrder(testOrder)
.WithPrice("SKU-001", 29.99m);
var result = await fixture.Service.ProcessAsync(testOrder.Id);3. Non-Deterministic Tests
3. 非确定性测试
Tests must not depend on system clock, random values, or external network. Inject abstractions:
csharp
// 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));测试不能依赖系统时钟、随机值或者外部网络,应该注入抽象:
csharp
// 反面示例:直接使用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));Key Principles
核心原则
- Test behavior, not implementation. Assert on observable outcomes (return values, state changes, published events), not internal method calls.
- One logical assertion per test. Multiple calls are fine if they verify one logical concept (e.g., all properties of a returned object). Multiple unrelated assertions indicate the test should be split.
Assert - Keep tests independent. No test should depend on another test's execution or ordering. Use fresh fixtures for each test.
- Name tests so failures are self-documenting. A failing test name should tell you what broke without reading the test body.
- Match test type to risk. High-risk code (payments, auth) deserves integration and E2E coverage. Low-risk code (simple mapping) needs only unit tests.
- Use for time-dependent logic (.NET 8+). It is the framework-provided abstraction; do not create custom
TimeProviderinterfaces.IClock
- 测试行为,而非实现:对可观测的结果(返回值、状态变更、发布的事件)断言,而非对内部方法调用断言。
- 每个测试只做一个逻辑断言:多个调用没问题,只要它们验证的是同一个逻辑概念(比如返回对象的所有属性),多个不相关的断言说明测试应该拆分。
Assert - 保持测试独立:任何测试都不应该依赖其他测试的执行结果或者执行顺序,每个测试使用全新的fixture。
- 测试命名要让故障自带文档:失败的测试名称应该能直接告诉你哪里出了问题,不需要阅读测试代码。
- 测试类型匹配风险等级:高风险代码(支付、鉴权)需要集成测试和E2E测试覆盖,低风险代码(简单映射)只需要单元测试即可。
- 时间相关逻辑使用(.NET 8+):这是框架提供的官方抽象,不需要自定义
TimeProvider接口。IClock
Agent Gotchas
Agent注意事项
- Do not mock types you do not own. Mocking ,
HttpClient, or framework types leads to brittle tests that do not reflect real behavior. UseDbContextor Testcontainers instead -- see [skill:dotnet-integration-testing].WebApplicationFactory - Do not create test projects without checking for existing structure. Run [skill:dotnet-project-analysis] first; duplicating test infrastructure causes build conflicts.
- Do not use in tests. Use
Thread.Sleepwith a cancellation token, or better, useTask.Delayto control time deterministically.FakeTimeProvider.Advance() - Do not test private methods directly. If a private method needs its own tests, it should be extracted into its own class. Test through the public API.
- Do not hard-code connection strings in integration tests. Use Testcontainers for disposable infrastructure or for in-process testing -- see [skill:dotnet-integration-testing].
WebApplicationFactory
- 不要Mock你不拥有的类型:Mock 、
HttpClient或者框架类型会导致测试脆弱,无法反映真实行为,应该使用DbContext或者Testcontainers,参考[skill:dotnet-integration-testing]。WebApplicationFactory - 创建测试项目前先检查现有结构:先运行[skill:dotnet-project-analysis],重复的测试基础设施会导致构建冲突。
- 测试中不要使用:使用带取消令牌的
Thread.Sleep,更好的方式是用Task.Delay确定性控制时间。FakeTimeProvider.Advance() - 不要直接测试私有方法:如果私有方法需要单独测试,说明它应该被提取到独立的类中,通过公共API测试即可。
- 集成测试中不要硬编码连接字符串:使用Testcontainers创建 disposable 基础设施,或者用做进程内测试,参考[skill:dotnet-integration-testing]。
WebApplicationFactory