Testing
After each code change, manually sending a curl request makes it easy to miss an error path. This chapter turns “create a task, then read it back” into an integration test: one request passes through routing, validation, authentication, the handler, and SQLite before the test checks the result.
Start with the first complete test file. The API reuses Chapter 20; this chapter adds a Tests project:
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);
}
}This file also needs the test factory and project configuration below. Both are included in the repository.
Complete test factory and project configuration
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>Run and verify
Run from the repository root:
cd samples/21-testing
dotnet test --project Tests/TodoApi.Tests.csprojYou do not need to run dotnet run first or create a development JWT. The summary should look like this; elapsed time and full paths vary by machine. The CLI localizes this text according to its UI language; this example shows English:
Test run summary: Passed!
total: 11
failed: 0
succeeded: 11
skipped: 0The example uses the xUnit test framework and selects .NET 10's Microsoft Testing Platform (MTP) test runner in this chapter's global.json. That is why the command uses --project to select the test project; run it from this chapter's directory so the SDK finds that configuration. v3 in the xUnit package name is a product-series name and does not have to match the package version. See Getting started with xUnit.
What does one test check?
[Fact] marks a test. Its method name describes the behavior under test: after creating a task successfully, you can read the saved data. The method has three steps:
- Create the test application and obtain an
HttpClientwith the editor role. - Send a JSON request to
/todos. - Use assertions to check the status code, Location, and task contents returned by a follow-up read.
PostAsJsonAsync serializes an object as JSON and sets the request's Content-Type. GetFromJsonAsync<TodoResponse> deserializes the response to the specified type. The test reuses the DTO introduced earlier instead of parsing a JSON string by hand.
Why check more than 201? A handler might report success without saving the task, or return a Location with the wrong ID. Reading the task again confirms the address the client received actually works.
TestContext.Current.CancellationToken comes from the test runner and can cancel an unfinished HTTP operation when the test is cancelled. using and await using release the client, test application, and database connection when the test ends.
What does WebApplicationFactory do?
WebApplicationFactory<Program> creates a test host. A test server handles requests from HttpClient without occupying the real port 5080. Program is the application entry point; the public declarations in TestAccess.cs let another project reference this type without adding an HTTP endpoint.
Despite Mvc in its name, Microsoft.AspNetCore.Mvc.Testing can also test Minimal APIs; it does not require controllers. See ASP.NET Core integration tests.
The test factory replaces two settings:
| Setting | Test behavior | Why |
|---|---|---|
| Database | Each factory opens its own SQLite in-memory connection | Does not read or write the practice database file, and tests do not compete for IDs |
| JWT | Each factory creates a random signing key and a short-lived test token | Does not depend on local User Secrets or an external identity service |
The tests still use the real SQLite provider and JWT validation handler. They replace the database location and trusted issuer configuration without hardcoding “allow access.” The token-issuing code in the factory belongs only to the test project; it is not a login endpoint.
Technical detail
An SQLite in-memory database disappears when its connection closes, so the factory opens the connection and closes it only after the test application is disposed. The old DbContextOptions and its configuration registration must also be removed to prevent two connection configurations from taking effect at once. Requests from the same factory share this test database; each test in this example creates its own factory and sends requests sequentially.
Check error paths too
Here is the complete file containing the remaining tests:
Validation, authentication, role, update, and delete tests
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] combined with [InlineData] runs the same test code with several inputs. There are two empty-title cases and three forbidden-write cases (POST, PUT, and DELETE), so the number of test methods differs from the number of test cases.
These tests check more than status codes:
- An empty title or missing category returns 400; the list remains empty.
- A nonexistent ID returns 404.
- A missing or invalid token returns 401.
- A regular reader trying to create, update, or delete gets 403; the original task and item count stay unchanged.
- After an editor updates a task, the test reads it again, deletes it, and queries again to confirm the save and the final 404.
Intentionally break it once
In this chapter's Program.cs, temporarily remove RequireAuthorization("CanWriteTodos") from the end of the POST registration, while keeping the authorization requirement on the group. Then run the tests.
The POST case in Reader_cannot_write should fail: it expects Forbidden (403), but receives Created (201). This shows that a regular reader gained write permission. Restore the policy call; all 11 test cases should pass again.
This also gives us a check for Chapter 22's file split: the files can move while the behavior seen by the client stays the same.
Note
These are API integration tests. They do not verify browser CORS behavior, reverse proxies, TLS, or login flows with an external identity service. Check browser and deployment behavior in the corresponding environment.
FastAPI comparison
This is similar to calling a FastAPI app with pytest and TestClient, then asserting the status code and JSON. Here, WebApplicationFactory creates the test app, and the test factory replaces the database and authentication settings.
Summary
- Integration tests send requests through multiple components working together, without manually starting the API.
- Use
[Fact]for one case and[Theory]to check multiple inputs with the same code. - Check response content and follow-up reads after success; after a rejected write, also confirm the data did not change.
- Each test uses its own database and test signing key, so results do not depend on test order.
- Run the tests before and after a refactor to catch response or permission changes early.
Next: Organizing the project by feature—keep code for a feature together. Previous: CORS.
