Integration Tests with WebApplicationFactory
Boot the real API in memory with WebApplicationFactory<Program>, send HTTP requests to it and assert on status codes, headers and JSON.
class OrdersApiTests(WebApplicationFactory<Program> f)
: IClassFixture<WebApplicationFactory<Program>>
// in a test
using var client = f.CreateClient();
// .NET 10: Program is generated public, nothing to addAn integration test sends a real HTTP request through the real pipeline: routing, binding, validation, filters, authorization, JSON serialization and your handler. WebApplicationFactory<TEntryPoint> from Microsoft.AspNetCore.Mvc.Testing (10.0.13) makes that cheap. It runs your Program.cs unchanged, but swaps Kestrel for TestServer, an in-memory server. CreateClient() returns an HttpClient whose handler hands requests straight to the pipeline: no socket, no port, no TLS.
How it finds and starts your app
The type argument is any public type in the API assembly; Program is the convention. In .NET 10 the Web SDK source-generates public partial class Program for an app with top-level statements, so WebApplicationFactory<Program> works with no change to the API. On .NET 9 and earlier the generated class was internal, and the workaround was a hand-written public partial class Program; in Program.cs (or InternalsVisibleTo); an analyzer now flags that line as redundant. The test files in this course declare a Program stand-in only so each compiles alone; in a real test project it would shadow the API's Program. On first use, the factory runs your entry point with Kestrel replaced by TestServer. Everything in Program.cs runs, including the middleware and endpoint mappings after builder.Build(). app.Run() starts the host on the in-memory server, so no port is opened, and blocks until the factory is disposed; code after it runs then. The content root is set to the API project's folder, so its appsettings.json is loaded. The environment is Development unless you call UseEnvironment, which also means appsettings.Development.json and the developer exception page are active in tests.
Cost and sharing
Building the host is the expensive part: tens to hundreds of milliseconds, plus whatever your startup does. The host is built lazily on the first CreateClient() or Services access and lives until the factory is disposed. Share it with IClassFixture<WebApplicationFactory<Program>> and create a cheap new client per test. Every WithWebHostBuilder call creates a new factory and therefore a new host, so a suite that calls it in every test pays startup every time.
| Option | Default | Why it matters |
|---|---|---|
BaseAddress | http://localhost | Requests are plain HTTP; pass https://localhost when code checks Request.IsHttps |
AllowAutoRedirect | true | Set false to assert a 302 or 307 instead of following it |
HandleCookies | true | Cookies persist across calls on the same client |
Asserting JSON
ReadFromJsonAsync<T> uses the web defaults, which match property names case-insensitively. A test that deserializes into the API's own DTO therefore still passes after someone switches the API to PascalCase or renames a property and its DTO together: the test checks your code against itself, not the contract. Deserialize into a test-side record, or read the body with JsonDocument and assert on the exact property names and types clients depend on. Also assert the status code first and the content type: errors must be application/problem+json.
Production failure modes it catches
A missing AddValidation(), a route constraint that rejects valid IDs, an endpoint that lost RequireAuthorization(), a JSON option set on the wrong serializer, middleware in the wrong order. None of those show in unit tests. What it does not catch: Kestrel limits, reverse proxy headers and real database behaviour, unless you add them to the test host.
Example
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public sealed class OrdersApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
// A test-side shape: if the API renames "total", this record stops matching and the test fails.
private sealed record OrderBody(int Id, string CustomerId, decimal Total, string Status);
[Fact]
public async Task Get_existing_order_returns_camelCase_json()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/orders/1042", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var root = json.RootElement;
Assert.Equal(1042, root.GetProperty("id").GetInt32());
Assert.Equal("CUST-17", root.GetProperty("customerId").GetString());
Assert.Equal(JsonValueKind.Number, root.GetProperty("total").ValueKind);
Assert.False(root.TryGetProperty("internalNotes", out _), "internal fields must not leak");
}
[Fact]
public async Task Unknown_order_returns_problem_details()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/orders/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task Post_creates_order_and_returns_location()
{
using var client = factory.CreateClient();
using var response = await client.PostAsJsonAsync("/orders",
new { customerId = "CUST-17", sku = "KETTLE-BLUE", quantity = 2 },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Matches(@"^/orders/\d+$", response.Headers.Location?.OriginalString);
var order = await response.Content.ReadFromJsonAsync<OrderBody>(TestContext.Current.CancellationToken);
Assert.Equal(69.00m, order!.Total);
Assert.Equal("Pending", order.Status);
}
[Theory]
[InlineData("""{"customerId":"CUST-17","sku":"KETTLE-BLUE","quantity":0}""", "quantity")]
[InlineData("""{"customerId":"","sku":"KETTLE-BLUE","quantity":1}""", "customerId")]
public async Task Invalid_body_returns_400_naming_the_field(string body, string field)
{
using var client = factory.CreateClient();
using var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/orders", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var problem = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var errors = problem.RootElement.GetProperty("errors");
Assert.Contains(errors.EnumerateObject(), e => e.Name.Equals(field, StringComparison.OrdinalIgnoreCase));
}
}
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;When to use it
- Prove POST /orders with quantity 0 returns 400 with an errors.quantity entry, through real binding and AddValidation.
- Pin the JSON contract of GET /orders/{id} so a renamed property or a leaked internalNotes field fails the build.
- Check that GET /admin/reports returns 401 without credentials after a refactor moved it into a new route group.
More examples
The API's Program.cs, ready to be tested
Nothing in this file knows about tests: in .NET 10 the Web SDK generates a public <code>Program</code>, so there is no <code>public partial class Program;</code> line to add (it was needed on .NET 9 and earlier). The 404 goes through <code>TypedResults.Problem</code>, so it is <code>application/problem+json</code>, which the main example asserts. <code>AddValidation</code> produces the 400 with an <code>errors</code> map; remove it and the theory in the main example fails, which is exactly the regression unit tests on the handler cannot see. The store is a singleton, so it is shared by every test in the class: tests must not assume they see an empty store.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddSingleton<OrderStore>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
var orders = app.MapGroup("/orders");
orders.MapGet("/{id:int}", Results<Ok<OrderResponse>, ProblemHttpResult> (int id, OrderStore store) =>
store.Find(id) is { } order
? TypedResults.Ok(order)
: TypedResults.Problem(title: "Order not found", statusCode: StatusCodes.Status404NotFound));
orders.MapPost("/", (PlaceOrder request, OrderStore store) =>
{
var order = store.Add(request.CustomerId, request.Quantity * 34.50m);
return TypedResults.Created($"/orders/{order.Id}", order);
});
app.Run();
public sealed record PlaceOrder([Required, MinLength(1)] string CustomerId, [Required] string Sku, [Range(1, 20)] int Quantity);
public sealed record OrderResponse(int Id, string CustomerId, decimal Total, string Status);
public sealed class OrderStore
{
private readonly Dictionary<int, OrderResponse> _orders = new() { [1042] = new(1042, "CUST-17", 69.00m, "Paid") };
private int _nextId = 2000;
public OrderResponse? Find(int id)
{
lock (_orders) return _orders.GetValueOrDefault(id);
}
public OrderResponse Add(string customerId, decimal total)
{
lock (_orders)
{
var order = new OrderResponse(++_nextId, customerId, total, "Pending");
_orders[order.Id] = order;
return order;
}
}
}The integration test project file
The test project uses the plain <code>Microsoft.NET.Sdk</code>; <code>Microsoft.AspNetCore.Mvc.Testing</code> brings the ASP.NET Core framework reference and <code>TestServer</code> with it. The package also copies the API's <code>.deps.json</code> into the test output and records the API's content root, which is how the factory finds <code>appsettings.json</code>. Reference the API project, not its DLL, so a build of the tests always rebuilds the API first.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Shop.Api\Shop.Api.csproj" />
</ItemGroup>
</Project>Seeding through the host's services and checking side effects
<code>factory.Services</code> is the test host's root provider, so the test can seed state and inspect side effects through the same services the endpoint uses. Scoped services such as a <code>DbContext</code> must be resolved from <code>CreateScope()</code>; resolving them from the root throws when scope validation is on, which it is in <code>Development</code>. Asserting the queued refund, not only the response, proves the endpoint did the work rather than just reporting it.
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public sealed class CancelOrderTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Cancelling_a_paid_order_marks_it_cancelled_and_queues_a_refund()
{
// Resolve app services from the test host; scoped ones need a scope.
int orderId;
using (var scope = factory.Services.CreateScope())
{
var store = scope.ServiceProvider.GetRequiredService<IOrderStore>();
orderId = store.Add(new Order(0, "CUST-17", 129.90m, "Paid"));
}
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
using var response = await client.PostAsync($"/orders/{orderId}/cancel", null, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Order>(TestContext.Current.CancellationToken);
Assert.Equal("Cancelled", body!.Status);
var refunds = factory.Services.GetRequiredService<IRefundQueue>();
Assert.Contains(refunds.Pending, r => r.OrderId == orderId && r.Amount == 129.90m);
}
}
public sealed record Order(int Id, string CustomerId, decimal Total, string Status);
public sealed record Refund(int OrderId, decimal Amount);
public interface IOrderStore { int Add(Order order); }
public interface IRefundQueue { IReadOnlyCollection<Refund> Pending { get; } }
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;
Discussion