ASP.NET Core Web APIs · Configuration, Security, Data and Operations
Integration Tests with TestServer
Test the whole HTTP pipeline in memory with TestServer, replacing dependencies such as the clock and external services.
What you'll build
Integration tests for a coupon API that run the real pipeline (routing, validation, errors) in memory with TestServer, replacing TimeProvider with a fake clock so expiry is tested deterministically. The test harness prints pass/fail for each case.
The idea
builder.WebHost.UseTestServer()swaps Kestrel for an in-memory server;app.GetTestClient()returns anHttpClientthat calls it without sockets.- In a separate test project,
WebApplicationFactory<Program>(packageMicrosoft.AspNetCore.Mvc.Testing) does the same for your realProgram, andWithWebHostBuilder(b => b.ConfigureTestServices(...))replaces services. - Inject
TimeProviderinstead of callingDateTime.UtcNow, so tests control time. - Test against a real database engine (for example a PostgreSQL container) for data access; fake only external services.
The code
// package: [email protected]
using System.Net;
using Microsoft.AspNetCore.TestHost;
var failures = 0;
async Task Check(string name, Func<HttpClient, Task<bool>> test, DateTimeOffset now)
{
await using var app = BuildApp(new FixedClock(now));
await app.StartAsync();
var ok = await test(app.GetTestClient());
if (!ok) failures++;
Console.WriteLine($"{(ok ? "PASS" : "FAIL")} {name}");
}
var beforeExpiry = new DateTimeOffset(2026, 11, 30, 12, 0, 0, TimeSpan.Zero);
var afterExpiry = new DateTimeOffset(2026, 12, 1, 0, 0, 1, TimeSpan.Zero);
await Check("valid coupon gives a discount", async http =>
{
var r = await http.GetFromJsonAsync<Discount>("/coupons/WINTER10?total=80");
return r!.Total == 72m;
}, beforeExpiry);
await Check("expired coupon is rejected with 410", async http =>
(await http.GetAsync("/coupons/WINTER10?total=80")).StatusCode == HttpStatusCode.Gone, afterExpiry);
await Check("unknown coupon is 404 problem details", async http =>
{
var r = await http.GetAsync("/coupons/NOPE?total=80");
return r.StatusCode == HttpStatusCode.NotFound && r.Content.Headers.ContentType?.MediaType == "application/problem+json";
}, beforeExpiry);
await Check("negative total fails validation", async http =>
(await http.GetAsync("/coupons/WINTER10?total=-5")).StatusCode == HttpStatusCode.BadRequest, beforeExpiry);
Console.WriteLine(failures == 0 ? "all tests passed" : $"{failures} failed");
static WebApplication BuildApp(TimeProvider clock)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddSingleton(clock);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();
app.UseStatusCodePages();
var coupons = new Dictionary<string, (decimal Percent, DateTimeOffset Expires)>
{
["WINTER10"] = (10m, new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero)),
};
app.MapGet("/coupons/{code}", (string code, [System.ComponentModel.DataAnnotations.Range(0, 100000)] decimal total, TimeProvider time) =>
{
if (!coupons.TryGetValue(code, out var coupon)) return Results.NotFound();
if (time.GetUtcNow() >= coupon.Expires) return Results.Problem("coupon expired", statusCode: 410);
return Results.Ok(new Discount(code, total - total * coupon.Percent / 100));
});
return app;
}
record Discount(string Code, decimal Total);
class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
Output
PASS valid coupon gives a discount PASS expired coupon is rejected with 410 PASS unknown coupon is 404 problem details PASS negative total fails validation all tests passed
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Only unit-testing handlers, missing routing, binding, validation and error-format bugs.
- Calling
DateTime.UtcNowdirectly, making time-dependent rules untestable. - Replacing the database with EF Core's in-memory provider, which doesn't behave like a relational database (constraints, transactions, SQL translation).
- Sharing mutable state between tests so they pass or fail depending on order.
Key points
- TestServer runs the real pipeline in memory; WebApplicationFactory does it for your Program.
- Replace external dependencies and the clock, not the framework.
- Use a real database engine for data-access tests.
Further reading: Integration tests in ASP.NET Core · TimeProvider
Practice questions
What does TestServer give you over unit tests of handlers?
Answer
It runs the full pipeline in memory (middleware, routing, binding, validation, auth, error handling) with an HttpClient, without opening sockets.
Why inject TimeProvider?
Answer
Code that reads the time through an injected TimeProvider can be tested with a fake clock, making expiry and scheduling rules deterministic.
Why avoid EF Core's in-memory provider for data tests?
Answer
It isn't a relational database: it doesn't enforce constraints or transactions the same way and doesn't translate queries to SQL, so tests can pass while production fails.
Open in the app to mark lessons complete, take notes and track your progress.