1. 课程导入与测试思维建立

在 ASP.NET Core 项目中,很多团队第一次写测试时都会遇到同一个问题:测试写了不少,但真正能拦住线上故障的却不多。原因往往不是测试写得不够多,而是测试的层次、职责和投入比例没有设计好。本节先建立一张整体地图,把“测什么、在哪一层测、用什么工具测、质量如何内建到开发过程中”这几件事串起来,后续章节再逐层展开。

一、ASP.NET Core 测试与质量工程的整体地图

一个可交付的 ASP.NET Core 服务,通常由若干可独立观察的部分组成:HTTP 接口层(Controller 或 Minimal API 端点)、应用服务层(业务编排)、领域模型层(实体、值对象、领域规则)、基础设施层(EF Core、缓存、消息、外部 HTTP 客户端)。测试与质量工程要做的,就是为这些部分分别设计合适的验证手段,并让它们在 CI 流水线中自动运行。

从工程视角看,质量工程包含四条并行的主线:测试(单元、集成、端到端)、静态质量(编译警告、分析器、格式检查)、运行时质量(日志、指标、健康检查、分布式追踪)、流程质量(代码评审、CI 门禁、缺陷复盘)。本课程重点放在测试主线,同时把其余三条主线作为测试的输入与输出衔接起来。

二、测试金字塔在 .NET 生态中的映射

测试金字塔的核心主张是:底层单元测试数量最多、执行最快、成本最低;中层集成测试数量适中;顶层端到端测试数量最少、最慢、最脆弱。在 .NET 生态中,这个金字塔有非常具体的落地形态:

  • 单元测试层:使用 xUnit / NUnit / MSTest 作为测试框架,Moq 或 NSubstitute 作为替身库,FluentAssertions 提升断言可读性。被测对象是纯逻辑类,例如领域服务、计算器、验证器,不依赖数据库和网络。
  • 集成测试层:使用 Microsoft.AspNetCore.Mvc.Testing 提供的 WebApplicationFactory<TEntryPoint> 在内存中启动真实应用管道,配合 Testcontainers 或 SQLite 内存库验证 EF Core 映射、中间件、路由、序列化与数据库交互。
  • 端到端测试层:使用 Playwright for .NET 或 Selenium 驱动真实浏览器,验证前后端联调后的关键用户路径,例如登录、下单、支付回调。

需要强调的是,金字塔不是“数量比例”的硬性规定,而是“反馈速度与置信度”的权衡。一个常见的合理起点是:单元测试占 60%~70%,集成测试占 20%~30%,端到端测试占 5%~10%。比例应根据系统风险调整,例如支付类系统应提高集成测试比重,而纯算法库可以几乎全部是单元测试。

ASP.NET Core 测试金字塔与质量内建 端到端测试 5%~10% Playwright / Selenium · 真实浏览器 集成测试 20%~30% WebApplicationFactory · Testcontainers EF Core · 中间件 · 路由 · 序列化 单元测试 60%~70% xUnit / NUnit / MSTest · Moq / NSubstitute FluentAssertions · 领域逻辑 · 验证器 反馈速度:慢 → 快 业务置信度:低 → 高 质量内建:静态分析 + 代码评审 + CI 门禁 + 可观测性 测试不是最后一道关卡,而是贯穿需求、编码、提交、部署的持续活动

三、质量内建理念:把测试左移,而不是堆在发布前

“质量内建”意味着质量不是靠发布前集中测试“测出来”的,而是在需求澄清、接口设计、编码、提交、构建的每个环节逐步“建进去”的。落到 ASP.NET Core 项目,可以具体化为以下做法:

  • 需求阶段:把验收标准写成可执行的 Given-When-Then 场景,后续可直接转化为集成测试用例。
  • 设计阶段:对依赖外部系统的部分先定义接口(如 IPaymentGateway),使单元测试可以用替身隔离。
  • 编码阶段:开启 <Nullable>enable</Nullable> 与 <TreatWarningsAsErrors>true</TreatWarningsAsErrors>,让编译器先拦住一类缺陷。
  • 提交阶段:通过 Git Hook 或 CI 在 PR 上运行单元测试与格式检查,失败即阻断合并。
  • 部署阶段:用健康检查端点 /healthz 与冒烟测试确认部署结果,而不是只看进程是否启动。
判断一个团队是否真正在做质量内建,有一个简单信号:当有人问“测试什么时候写”,答案如果是“和功能代码同一次提交”,说明已经内建;如果答案是“功能做完再补”,说明测试仍然是外挂环节。

四、课程项目与工具链概览

为了让后续章节有统一的演练对象,本课程使用一个贯穿始终的示例项目 OrderHub:一个简化的订单服务,包含商品查询、下单、库存扣减、支付回调四个核心用例。项目采用 ASP.NET Core 8 Minimal API + EF Core + SQL Server,分层为 OrderHub.Api、OrderHub.Application、OrderHub.Domain、OrderHub.Infrastructure,测试项目为 OrderHub.UnitTests、OrderHub.IntegrationTests、OrderHub.E2ETests。

工具链方面,本课程统一使用以下组合,避免在多个等价工具之间反复切换:

用途选型说明
测试框架xUnit与 .NET 生态集成好,支持 Theory 数据驱动
替身库NSubstitute语法简洁,适合接口隔离
断言库FluentAssertions失败信息可读性强
集成测试宿主WebApplicationFactory内存中启动真实管道
依赖容器Testcontainers用真实 SQL Server 容器验证 EF Core
端到端Playwright for .NET跨浏览器、自动等待
覆盖率Coverlet + ReportGenerator生成 HTML 覆盖率报告
CIGitHub ActionsPR 触发测试与门禁

一个典型的测试项目文件配置如下,它决定了测试能否被 CI 正确发现与统计:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit" Version="2.7.0" />
    <PackageReference Include="NSubstitute" Version="5.1.0" />
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
    <PackageReference Include="coverlet.collector" Version="6.0.1" />
  </ItemGroup>
</Project>

运行测试并收集覆盖率的命令为:

dotnet test OrderHub.UnitTests \
  --collect:"XPlat Code Coverage" \
  --results-directory ./TestResults

执行后,./TestResults 下会生成 coverage.cobertura.xml,可用 ReportGenerator 转为 HTML:

reportgenerator \
  -reports:./TestResults/**/coverage.cobertura.xml \
  -targetdir:./coveragereport \
  -reporttypes:Html

打开 coveragereport/index.html,即可看到按程序集、类、方法逐级下钻的行覆盖率与分支覆盖率。需要提醒的是,覆盖率是观察指标而非目标本身:一个 90% 覆盖率但断言薄弱的测试套件,其保护能力可能低于 70% 覆盖率但断言精准的套件。后续章节会专门讨论如何判断测试是否“有效”。

不要把覆盖率门禁设成“必须 100%”。这通常会导致大量无断言的“走过场”测试,反而降低套件的可信度。更合理的做法是先设一个可达到的基线(例如 70%),再针对核心领域层单独提高要求。