问题修复
.NET 代码测试策略选择:用决策树定类型…
在 .NET 项目中为某段代码决定写单元测试、集成测试还是 E2E 测试,并合理组织测试项目与选择 stub/mock/fake/spy。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 49/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 85/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 60/100 ×0.15
适用场景
.NET 代码测试策略选择:用决策树定类通用
解决思路
- 按决策树自上而下选择测试类型:先判断被测代码是否依赖外部基础设施(数据库、HTTP 服务、文件系统、消息代理)。
- 依赖外部基础设施、且该基础设施行为对正确性关键、且需要完整应用栈(中间件、认证、路由)时,写 E2E / 功能测试(用 WebApplicationFactory 或 Playwright)。
- 依赖外部基础设施且行为关键、但不需要完整应用栈时,写集成测试(用 WebApplicationFactory 或 Testcontainers)。
- 依赖外部基础设施但该行为对正确性不关键时,写单元测试并 mock 基础设施边界。
- 不依赖外部基础设施且属于纯逻辑(计算、转换、校验)时,写不需要测试替身的单元测试。
- 不依赖外部基础设施但不属于纯逻辑时,写单元测试并 mock 协作者接口。
- 按单元测试的标准判定:无基础设施(mock/fake)、每个测试 <10ms、范围为单个类/方法;适用于纯逻辑、领域规则、值对象、转换、验证器。
- 按集成测试的标准判定:真实基础设施(DB、HTTP)、每个测试 100ms-5s、覆盖多个组件;适用于仓储查询、API 契约验证、序列化往返、中间件行为。
- 按 E2E / 功能测试的标准判定:全栈、每个测试 1-30s、覆盖整个请求管道;适用于关键用户流程、认证+路由+中间件组合、横切关注点验证。
- 业务逻辑优先写单元测试:运行快、失败定位精确、无基础设施依赖。
- 用集成测试验证基础设施边界:不要用 mock 的 DbContext 写仓储单元测试(证明不了 SQL 生成),改用 Testcontainers 起真实数据库。
- E2E 测试只用于关键路径:覆盖 happy path 和一两个关键失败场景,因为它慢、脆弱、维护成本高。
- 把测试金字塔当指导而非规则:逻辑极少的 CRUD API 可以让集成测试多于单元测试,按应用的复杂度画像匹配策略。
- 在 tests/ 下镜像 src/ 的项目结构并用后缀标明类型:*.UnitTests(隔离、无外部依赖)、*.IntegrationTests(真实基础设施,如数据库、HTTP、文件系统)、*.FunctionalTests(通过 WebApplicationFactory 覆盖全栈)。
- 每个生产类对应一个测试类,测试文件放在镜像生产命名空间的命名空间下,例如生产 src/MyApp.Domain/Orders/OrderService.cs 对应测试 tests/MyApp.Domain.UnitTests/Orders/OrderServiceTests.cs,命名空间为 MyApp.Domain.UnitTests.Orders。
- 生产类很大时按方法拆分测试类,如 OrderService_CreateTests.cs、OrderService_CancelTests.cs、OrderService_RefundTests.cs。
- 测试命名采用 Method_Scenario_ExpectedBehavior 模式(如 CalculateTotal_WithDiscountCode_AppliesPercentageDiscount),让测试浏览器输出可读、失败自解释。
- 备选命名风格选一种并在项目内保持一致:Method_Scenario_Expected、Should_Expected_When_Scenario、Given_When_Then。
- 每个测试遵循 Arrange-Act-Assert 结构并清晰分隔三段;若无法清晰标注这三段,说明测试做太多,应拆成多个测试。
- 需要验证依赖被“如何”调用时:若同时还需要可用的实现 → 用 Spy(在 fake 上记录调用);否则 → 用 Mock(NSubstitute / Moq)。
- 不需要验证调用方式时:若需要依赖做真实的事情 → 用 Fake(内存实现);否则 → 用 Stub(返回预设值)。
- 用 Stub 时让它返回预设数据(如 priceService.GetPriceAsync("SKU-001").Returns(29.99m)),然后对被测代码的结果断言(如 Assert.Equal(59.98m, total)),不要断言调用本身。
- 用 Mock 时通过调用断言验证副作用确实发生,如 emailSender.Received(1).SendAsync(Arg.Is<string>(to => to == order.CustomerEmail), Arg.Any<string>(), Arg.Any<string>())。
- 用 Fake 时写内存实现,例如 FakeOrderRepository 用 Dictionary<Guid, Order> 实现 GetByIdAsync/SaveAsync。
- 领域密集型应用优先用 Fake 而非 Mock:内存仓储能捕获 mock 漏掉的缺陷(如重复键冲突)。
- 避免 mock 过度这一测试坏味道:如果 mock 设置比实际断言还长,考虑改用 fake 是否更清晰、更易维护。
- 集成边界用真实基础设施验证(见 dotnet-integration-testing),而不是用 mock 代替。
- 不要测试实现细节:避免一串 Received() 验证精确调用序列,改为断言可观察结果(如 result.Status == OrderStatus.Completed)。
- 不要写过度 mock 设置的测试:用 builder 或 fixture 封装准备逻辑,如 new OrderServiceFixture().WithOrder(testOrder).WithPrice("SKU-001", 29.99m),再调用 fixture.Service.ProcessAsync(testOrder.Id)。
- 保证测试确定性,不依赖系统时钟、随机值或外部网络,把这些依赖抽象注入。
- 时间相关逻辑注入 TimeProvider(.NET 8+),测试中用 FakeTimeProvider 指定固定时间并断言,如 new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero))。
- 测试行为而非实现:断言可观察结果(返回值、状态变化、发布的事件),不检查内部方法调用。
- 每个测试只做逻辑上的一类断言:多个 Assert 只要验证同一个逻辑概念即可,若断言彼此无关则拆分测试。
- 保持测试相互独立:任何测试都不依赖其他测试的执行或顺序,每个测试使用全新 fixture。
- 命名测试使失败自解释:只看失败的测试名就能知道哪里坏了,无需读测试体。
- 按风险匹配测试类型:高风险代码(支付、认证)值得集成与 E2E 覆盖;低风险代码(简单映射)只需单元测试。
- 时间相关逻辑使用框架提供的 TimeProvider(.NET 8+),不要自建 IClock 接口。
- 不要 mock 你不拥有的类型(HttpClient、DbContext 或框架类型),否则测试脆弱且不反映真实行为,改用 WebApplicationFactory 或 Testcontainers。
- 创建测试项目前先检查是否已有测试结构,先运行 dotnet-project-analysis,避免重复搭建测试基础设施造成构建冲突。
- 不要在测试中使用 Thread.Sleep,改用带取消令牌的 Task.Delay,或更好用 FakeTimeProvider.Advance() 确定性地推进时间。
- 不要直接测试私有方法:需要单独测试的私有方法应提取成独立类,通过公共 API 进行测试。
- 集成测试中不要硬编码连接字符串,用 Testcontainers 提供一次性基础设施,或用 WebApplicationFactory 做进程内测试。
适用边界 · 注意事项
- 不适用于非 .NET 项目:其他语言/框架的测试替身选型与测试项目组织约定不同。
- 不覆盖测试项目脚手架搭建(目录布局、xUnit 项目创建、coverlet 配置、editorconfig 覆盖),那属于 dotnet-add-testing。
- 不覆盖代码覆盖率工具与变异测试、CI 测试报告与流水线集成,分别属于 dotnet-test-quality 与 dotnet-gha-build-test / dotnet-ado-build-test。