2. 搭建可测试的ASP.NET Core项目

可测试性不是测试阶段才补上的能力,而是项目骨架阶段就要做出的结构决策。一个便于测试的 ASP.NET Core 项目,通常具备四个特征:入口与业务逻辑分离、依赖通过接口注入、配置按环境隔离、测试项目与生产项目同解决方案管理。本节围绕最小 API 与 MVC 控制器两种组织方式,说明如何搭建这样的骨架,并给出 xUnit 测试项目的创建与运行方式。

一、两种入口组织方式:最小 API 与 MVC 控制器

最小 API 用 MapGet、MapPost 等扩展方法在 Program.cs 中直接声明端点,代码量小、启动快,适合以 API 为主的服务。MVC 控制器则把每个动作放在继承 ControllerBase 的类中,通过特性路由与模型绑定组织,适合页面与 API 混合、需要过滤器与约定式路由的场景。

从可测试角度看,两者真正的差别不在写法,而在“逻辑放在哪里”。若把计算、校验、数据访问全部写在端点委托或控制器动作里,测试就必须启动整个应用或构造大量 HTTP 上下文。正确做法是把业务逻辑下沉到可独立实例化的服务类,端点与动作只负责参数绑定、调用服务、把结果映射为 IResult 或 IActionResult。这样服务层可以用普通单元测试覆盖,端点层只需少量集成测试验证路由与状态码。

// 最小 API:端点只做编排
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService svc) =>
{
    var result = await svc.CreateAsync(req);
    return result.IsSuccess
        ? Results.Created($"/orders/{result.Value.Id}", result.Value)
        : Results.ValidationProblem(result.Errors);
});

// MVC 控制器:动作同样只做编排
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _svc;
    public OrdersController(IOrderService svc) => _svc = svc;

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest req)
    {
        var result = await _svc.CreateAsync(req);
        return result.IsSuccess ? CreatedAtAction(nameof(Get), new { id = result.Value.Id }, result.Value)
                                : ValidationProblem(result.Errors);
    }
}

二、依赖注入与配置抽象

依赖注入是可测试性的核心机制。服务通过构造函数接收接口,测试时即可传入真实实现、内存实现或桩对象。注册时按生命周期选择:无状态计算用 Singleton,含请求内共享状态的用 Scoped,轻量短生命周期用 Transient。注意 Scoped 服务不能被 Singleton 直接依赖,否则运行时会抛出生命周期校验异常。

配置抽象要求代码不直接读 appsettings.json,而是注入 IOptions<T>。这样测试可以传入任意配置对象,无需准备真实配置文件。例如订单服务依赖 OrderOptions,测试中构造 Options.Create(new OrderOptions { MaxItems = 3 }) 即可验证边界行为。

builder.Services.Configure<OrderOptions>(
    builder.Configuration.GetSection("Order"));
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();

三、环境隔离

ASP.NET Core 通过 ASPNETCORE_ENVIRONMENT 决定当前环境,常用值为 Development、Staging、Production。配置加载顺序为:appsettings.json → appsettings.{Environment}.json → 环境变量 → 命令行参数,后加载者覆盖前者。测试项目通常把环境设为 Testing,并加载 appsettings.Testing.json,把数据库连接指向内存库或测试容器,避免污染开发数据。

判断当前环境可用 app.Environment.IsDevelopment(),但业务代码不应依赖环境分支来决定核心逻辑,环境差异应尽量收敛到配置与注册阶段。

四、解决方案与测试项目结构

推荐把生产代码与测试代码放在同一解决方案下,用项目引用建立依赖,而不是复制源码。典型结构如下:

OrderCenter.sln
├── src/
│   ├── OrderCenter.Api/          # 最小 API 或 MVC 入口
│   └── OrderCenter.Core/         # 领域模型与服务接口/实现
└── tests/
    ├── OrderCenter.UnitTests/    # 服务层单元测试
    └── OrderCenter.IntegrationTests/ # WebApplicationFactory 集成测试

创建命令:dotnet new sln -n OrderCenter,随后 dotnet new web -o src/OrderCenter.Api、dotnet new classlib -o src/OrderCenter.Core、dotnet new xunit -o tests/OrderCenter.UnitTests,再用 dotnet sln add 逐个加入,并用 dotnet add reference 建立引用。把 Program 类声明为 public partial class Program { },集成测试才能通过 WebApplicationFactory<Program> 启动内存宿主。

五、xUnit 测试项目模板与运行方式

xUnit 模板生成的项目包含 xunit、xunit.runner.visualstudio 与 Microsoft.NET.Test.Sdk 三个包。测试类无需继承基类,用 [Fact] 标记无参用例,用 [Theory] 配合 [InlineData] 做参数化。共享初始化用构造函数,清理用 IDisposable;跨类共享昂贵资源用 IClassFixture<T>。

public class OrderServiceTests
{
    private readonly IOrderService _svc;
    public OrderServiceTests()
    {
        var opts = Options.Create(new OrderOptions { MaxItems = 3 });
        _svc = new OrderService(new FakeOrderRepository(), opts);
    }

    [Theory]
    [InlineData(0, false)]
    [InlineData(3, true)]
    [InlineData(4, false)]
    public async Task Create_RespectsMaxItems(int count, bool expected)
    {
        var result = await _svc.CreateAsync(new CreateOrderRequest { ItemCount = count });
        Assert.Equal(expected, result.IsSuccess);
    }
}

运行方式:在解决方案根目录执行 dotnet test 会构建并运行所有测试项目;指定项目用 dotnet test tests/OrderCenter.UnitTests;按名称过滤用 dotnet test --filter "FullyQualifiedName~OrderServiceTests";输出详细结果加 --logger "console;verbosity=detailed"。在 CI 中可加 --collect:"XPlat Code Coverage" 生成覆盖率文件。

可测试项目骨架:入口、服务、配置与测试的关系 src / 生产代码 OrderCenter.Api 最小API端点 / MVC控制器 OrderCenter.Core IOrderService / 领域模型 引用 DI 注册:IOrderService → OrderService(Scoped) 配置抽象:IOptions<OrderOptions> ← appsettings.{Env}.json tests / 测试代码 UnitTests (xUnit) [Fact] / [Theory] 服务层 IntegrationTests WebApplicationFactory<Program> 引用 环境隔离与配置覆盖顺序 appsettings.json appsettings.{Env}.json 环境变量 命令行参数 后者覆盖前者 运行与验证 dotnet test → 运行全部测试项目 dotnet test --filter "FullyQualifiedName~OrderServiceTests" → 按名称过滤

把上述结构落地后,一个典型验证闭环是:先写服务层单元测试确认 MaxItems 边界,再用 WebApplicationFactory 发一个 POST /orders 请求,断言返回 201 与 Location 头,最后用 dotnet test 在 CI 中统一执行。若单元测试通过而集成测试失败,问题通常出在路由、模型绑定或 DI 注册,而不是业务逻辑本身,这正体现了分层测试的定位价值。