测试
每次改完代码,都手动发一遍 curl,很容易漏掉某个错误分支。这一章把“创建任务,再读回来核对”写成集成测试(integration test):让一次请求经过路由、校验、认证、处理程序和 SQLite,然后检查结果。
先看第一个完整测试文件。API 沿用第 20 章,本章新增 Tests 项目:
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);
}
}这个文件需要下面的测试工厂和项目配置,均已包含在仓库中。
完整测试工厂与项目配置
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();
}
}<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>{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}// Lets the separate test project reference the entry-point type generated from top-level statements.
public partial class Program;<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>运行与验证
从仓库根目录执行:
cd samples/21-testing
dotnet test --project Tests/TodoApi.Tests.csproj不需要先 dotnet run,也不需要生成开发 JWT。摘要中的结果应为下面这样,耗时和完整路径因机器而异:
测试运行摘要: 已通过!
总计: 11
失败: 0
成功: 11
已跳过: 0示例使用 xUnit 测试框架,并在本章 global.json 中选择 .NET 10 的 Microsoft Testing Platform(MTP) 测试运行器。因此命令使用 --project 指定测试项目;请从本章目录执行,让 SDK 找到这份配置。xUnit 包名中的 v3 是产品系列名,与包版本号不必相同。xUnit 入门
一条测试检查什么
[Fact] 标出一条测试。方法名描述要验证的行为:创建成功后,可以读取保存的数据。方法分三步:
- 创建测试应用,取得带 editor 身份的
HttpClient。 - 向
/todos发送 JSON 请求。 - 用断言(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 及其配置注册,避免两套连接配置一起生效。同一个工厂中的请求共享这份测试库;本例每条测试创建自己的工厂,并顺序发送请求。
错误分支也要检查
下面是其余测试的完整文件:
校验、认证、角色与更新删除测试
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]用同一段代码验证多组输入。- 成功响应要检查内容和后续读取,拒绝写入还要检查数据没有被修改。
- 每条测试使用独立数据库和测试签名密钥,结果不依赖运行顺序。
- 重构前先跑通测试,改完再跑,及时发现响应和权限变化。
