跳到正文

测试 ​

每次改完代码,都手动发一遍 curl,很容易漏掉某个错误分支。这一章把“创建任务,再读回来核对”写成集成测试(integration test):让一次请求经过路由、校验、认证、处理程序和 SQLite,然后检查结果。

先看第一个完整测试文件。API 沿用第 20 章,本章新增 Tests 项目:

Tests/CreateTodoTests.cs
cs
using System.Net;
using System.Net.Http.Json;

public class CreateTodoTests
{
    [Fact]
    public async Task Create_then_read_returns_saved_todo()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;

        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);

        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        Assert.Equal("/todos/1", created.Headers.Location?.ToString());
        var saved = await client.GetFromJsonAsync<TodoResponse>(created.Headers.Location, ct);
        Assert.NotNull(saved);
        Assert.Equal(new TodoResponse(1, "Buy milk", false, 1), saved);
    }
}

这个文件需要下面的测试工厂和项目配置,均已包含在仓库中。

完整测试工厂与项目配置
cs
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.Tokens;

public sealed class TodoApiFactory : WebApplicationFactory<Program>
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");
    private readonly SymmetricSecurityKey _key = new(RandomNumberGenerator.GetBytes(32));

    public TodoApiFactory() => _connection.Open();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Development");
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<TodoDbContext>>();
            services.RemoveAll<IDbContextOptionsConfiguration<TodoDbContext>>();
            services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
            services.PostConfigure<JwtBearerOptions>("Bearer", options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = "test-issuer",
                    ValidateAudience = true,
                    ValidAudience = "test-api",
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = _key,
                    ClockSkew = TimeSpan.Zero
                };
            });
        });
    }

    public HttpClient CreateUserClient(bool editor = false)
    {
        var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
        if (editor) claims.Add(new Claim(ClaimTypes.Role, "editor"));
        var token = new JwtSecurityToken("test-issuer", "test-api", claims,
            expires: DateTime.UtcNow.AddMinutes(5),
            signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256));
        var client = CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
            "Bearer", new JwtSecurityTokenHandler().WriteToken(token));
        return client;
    }

    public override async ValueTask DisposeAsync()
    {
        await base.DisposeAsync();
        await _connection.DisposeAsync();
    }
}
xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsTestProject>true</IsTestProject>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
    <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
    <Using Include="Xunit" />
    <ProjectReference Include="../Testing.csproj" />
  </ItemGroup>
</Project>
json
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
cs
// Lets the separate test project reference the entry-point type generated from top-level statements.
public partial class Program;
xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnetcore-first-steps-21-testing</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <Compile Remove="Tests/**/*.cs" />
    <Content Remove="Tests/**" />
    <None Remove="Tests/**" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
  </ItemGroup>
</Project>

运行与验证 ​

从仓库根目录执行:

bash
cd samples/21-testing
dotnet test --project Tests/TodoApi.Tests.csproj

不需要先 dotnet run,也不需要生成开发 JWT。摘要中的结果应为下面这样,耗时和完整路径因机器而异:

text
测试运行摘要: 已通过!
  总计: 11
  失败: 0
  成功: 11
  已跳过: 0

示例使用 xUnit 测试框架,并在本章 global.json 中选择 .NET 10 的 Microsoft Testing Platform(MTP) 测试运行器。因此命令使用 --project 指定测试项目;请从本章目录执行,让 SDK 找到这份配置。xUnit 包名中的 v3 是产品系列名,与包版本号不必相同。xUnit 入门

一条测试检查什么 ​

[Fact] 标出一条测试。方法名描述要验证的行为:创建成功后,可以读取保存的数据。方法分三步:

  1. 创建测试应用,取得带 editor 身份的 HttpClient。
  2. 向 /todos 发送 JSON 请求。
  3. 用断言(assertion)核对状态码、Location,以及再次读取的任务内容。

PostAsJsonAsync 把对象序列化为 JSON,并设置请求的 Content-Type;GetFromJsonAsync<TodoResponse> 将响应反序列化为指定类型。这里使用前面已有的 DTO,不再手动解析 JSON 字符串。

为什么不只检查 201?处理程序可能返回了成功,却没有保存任务,或者 Location 指向了错误的编号。再读一次,才能确认客户端拿到的地址确实可用。

TestContext.Current.CancellationToken 来自测试运行器,取消测试时可以中止尚未完成的 HTTP 操作。using 和 await using 则在测试结束后释放客户端、测试应用及数据库连接。

WebApplicationFactory 做了什么 ​

WebApplicationFactory<Program> 创建测试宿主,用测试服务器处理 HttpClient 发出的请求,不占用真实的 5080 端口。Program 指应用入口;TestAccess.cs 中的公开部分声明让另一个项目能引用这个类型,不会新增 HTTP 端点。

Microsoft.AspNetCore.Mvc.Testing 虽然名字里有 Mvc,同样可以测试 Minimal API,不需要引入控制器。ASP.NET Core 集成测试

测试工厂替换了两项配置:

配置测试中的处理原因
数据库每个工厂单独打开一个 SQLite 内存连接不读写练习用的数据库文件,测试之间不会争抢编号
JWT每个工厂生成随机签名密钥,创建短期测试令牌不依赖本机 User Secrets,也不用连接外部身份服务

这里仍然使用真实的 SQLite 提供程序和 JWT 验证处理器。只是更换数据库位置和可信签发者配置,没有把“允许访问”写死。工厂里的签发代码仅属于测试项目,不是登录接口。

技术细节

SQLite 内存数据库在连接关闭时消失,所以工厂先打开连接,等测试应用释放后再关闭。还要移除旧的 DbContextOptions 及其配置注册,避免两套连接配置一起生效。同一个工厂中的请求共享这份测试库;本例每条测试创建自己的工厂,并顺序发送请求。

错误分支也要检查 ​

下面是其余测试的完整文件:

校验、认证、角色与更新删除测试
Tests/TodoApiTests.cs
cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

public class TodoApiTests
{
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    public async Task Invalid_title_does_not_insert(string title)
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title, categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var items = await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct);
        Assert.NotNull(items);
        Assert.Empty(items);
    }

    [Fact]
    public async Task Unknown_category_does_not_insert()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 99 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Empty((await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Missing_todo_returns_404()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient();
        var response = await client.GetAsync("/todos/99", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Anonymous_request_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Invalid_token_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid");
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Theory]
    [InlineData("POST")]
    [InlineData("PUT")]
    [InlineData("DELETE")]
    public async Task Reader_cannot_write(string method)
    {
        await using var app = new TodoApiFactory();
        using var editor = app.CreateUserClient(editor: true);
        using var reader = app.CreateUserClient();
        var ct = TestContext.Current.CancellationToken;
        var created = await editor.PostAsJsonAsync("/todos", new { title = "Keep me", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        using var request = new HttpRequestMessage(new HttpMethod(method), method == "POST" ? "/todos" : "/todos/1")
        {
            Content = JsonContent.Create(new { title = "Changed", done = true, categoryId = 2 })
        };
        var response = await reader.SendAsync(request, ct);
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        var saved = await reader.GetFromJsonAsync<TodoResponse>("/todos/1", ct);
        Assert.Equal(new TodoResponse(1, "Keep me", false, 1), saved);
        Assert.Single((await reader.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Editor_can_replace_and_delete()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        var updated = await client.PutAsJsonAsync("/todos/1", new { title = "Bought milk", done = true, categoryId = 2 }, ct);
        Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
        Assert.Equal(new TodoResponse(1, "Bought milk", true, 2), await client.GetFromJsonAsync<TodoResponse>("/todos/1", ct));
        Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync("/todos/1", ct)).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/todos/1", ct)).StatusCode);
    }
}

[Theory] 配合 [InlineData],让同一段测试分别使用多组输入。空标题有两组,禁止写入有 POST、PUT、DELETE 三组,所以测试方法数与最终用例数不同。

这些测试检查的不只是状态码:

  • 标题为空或分类不存在:返回 400,列表仍然为空。
  • 编号不存在:返回 404。
  • 没有令牌或令牌无效:返回 401。
  • 普通读者尝试创建、修改、删除:返回 403,原任务和条数不变。
  • 编辑者修改后重新读取,再删除并查询:依次确认保存成功和 404。

故意改错一次 ​

在本章 Program.cs 的 POST 注册末尾,临时移除 RequireAuthorization("CanWriteTodos"),保留分组上的认证要求,再运行测试。

Reader_cannot_write 的 POST 用例应失败:预期是 Forbidden(403),实际却变成 Created(201)。这说明普通读者获得了写权限。把策略调用恢复,11 个用例应重新全部通过。

这样,第 22 章拆分文件时就有了检查依据:文件可以换位置,客户端看到的行为应保持一致。

注意

这些是 API 集成测试,不会验证浏览器是否执行 CORS,也不会验证反向代理、TLS 或外部身份服务的登录流程。涉及浏览器和部署的行为仍要在相应环境检查。

FastAPI 对照

这类似用 pytest 和 TestClient 调用 FastAPI 应用,再断言状态码和 JSON。这里由 WebApplicationFactory 创建测试应用,测试工厂负责替换数据库与认证配置。

总结 ​

  • 集成测试通过请求验证多个组件一起工作的结果,不必手动启动 API。
  • [Fact] 写单个案例,[Theory] 用同一段代码验证多组输入。
  • 成功响应要检查内容和后续读取,拒绝写入还要检查数据没有被修改。
  • 每条测试使用独立数据库和测试签名密钥,结果不依赖运行顺序。
  • 重构前先跑通测试,改完再跑,及时发现响应和权限变化。

下一章:按功能组织项目——把同一功能的代码放在一起。上一章:CORS。

基于 .NET 10 与 Minimal API · 所有示例均可直接 dotnet run