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.

Create a free account to save this page, take notes and track your progress. Sign in

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 an HttpClient that calls it without sockets.
  • In a separate test project, WebApplicationFactory<Program> (package Microsoft.AspNetCore.Mvc.Testing) does the same for your real Program, and WithWebHostBuilder(b => b.ConfigureTestServices(...)) replaces services.
  • Inject TimeProvider instead of calling DateTime.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

In-memory integration tests with a fake clock
// 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.UtcNow directly, 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.

Was this page helpful?