Testing It

Test the orders API with fast unit tests for the rules and WebApplicationFactory tests against a real PostgreSQL in Testcontainers.

Syntax[assembly: AssemblyFixture(typeof(PostgresFixture))] public sealed class PlaceOrderTests(PostgresFixture pg) : IAsyncLifetime _factory = new OrdersApiFactory(connectionString); var client = _factory.CreateClient();

The project has two test projects with different jobs. Orders.UnitTests covers rules that need no I/O: the order state machine, pricing, the scope check, handler methods called directly. It runs in seconds and should hold most of the edge cases. Orders.IntegrationTests boots the real application in memory with WebApplicationFactory<Program>, points it at a real PostgreSQL started by Testcontainers and sends HTTP requests through the whole pipeline: routing, authentication, validation, EF Core, the database's constraints.

What each level proves

BehaviourUnitIntegration
Shipped orders cannot be cancelledYesOne happy-path check
401 without a token, 403 without the scopeNoYes: depends on pipeline order and policies
Validation keys such as Lines[0].QuantityNoYes: produced by the endpoint filter
Retried POST creates one orderNoYes: relies on the unique constraint
Stale If-Match gives 412NoYes: relies on xmin
Migrations match the modelNoYes: MigrateAsync runs them

Why a real PostgreSQL

The EF Core in-memory provider has no transactions, no unique constraints, no xmin and no SQL translation, so every interesting row in that table would pass against it while broken in production. SQLite is closer but still a different dialect. A postgres:17-alpine container starts in a few seconds; start it once for the whole run with an xUnit v3 assembly fixture and give each test class its own database name, so classes run in parallel without seeing each other's rows. Apply the schema with Database.MigrateAsync(), not EnsureCreatedAsync(): the migrations are what production runs, and EF Core refuses to migrate when the model has changes no migration covers, which catches a forgotten migrations add in CI.

Replacing only what you must

The factory overrides configuration (UseSetting("ConnectionStrings:Orders", ...)) and swaps the JWT scheme for a test scheme in ConfigureTestServices, which runs after Program's registrations. Everything else stays real. The test scheme reads the user and scopes from request headers, so each test states who is calling; returning NoResult when the header is absent keeps the 401 path testable. Making it the default authenticate and challenge scheme matters: leaving JWT as the challenge scheme produces confusing 401s.

Failure modes

  • Shared data between tests. Assertions like "there is one order" break when another test in the class added one; filter by a customer id unique to the test.
  • No cancellation token. Pass TestContext.Current.CancellationToken to every call so a hung request fails the test at its timeout instead of holding the CI job.
  • Docker missing on the agent. Testcontainers needs a Docker-compatible daemon; hosted Linux runners have one, some Windows and macOS runners do not.
  • Containers left behind. Disposing the fixture stops the container, and the Ryuk sidecar removes it if the test process crashes.

Example

Example · csharp
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;

[assembly: AssemblyFixture(typeof(PostgresFixture))]

// One PostgreSQL container for the whole test run.
public sealed class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17-alpine").Build();

    public string ConnectionStringFor(string database) =>
        new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = database }.ConnectionString;

    public async ValueTask InitializeAsync() => await _container.StartAsync();

    public ValueTask DisposeAsync() => _container.DisposeAsync();
}

public sealed class OrdersApiFactory(string connectionString) : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.UseSetting("ConnectionStrings:Orders", connectionString);
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = TestAuthHandler.Scheme;
                    options.DefaultChallengeScheme = TestAuthHandler.Scheme;
                })
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.Scheme, null);
        });
    }
}

public sealed class PlaceOrderTests(PostgresFixture postgres) : IAsyncLifetime
{
    private OrdersApiFactory _factory = null!;

    public async ValueTask InitializeAsync()
    {
        // A database per test class: classes run in parallel without sharing rows.
        _factory = new OrdersApiFactory(postgres.ConnectionStringFor($"orders_{Guid.NewGuid():N}"));
        await using var scope = _factory.Services.CreateAsyncScope();
        await scope.ServiceProvider.GetRequiredService<OrdersDbContext>().Database
            .MigrateAsync(TestContext.Current.CancellationToken);
    }

    public ValueTask DisposeAsync() => _factory.DisposeAsync();

    [Fact]
    public async Task Retried_post_with_the_same_key_creates_one_order()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = ClientFor("customer-42", "orders.read orders.write");
        var basket = new { lines = new[] { new { sku = "KETTLE-BLUE", quantity = 1 } } };

        var first = await PostOrder(client, "6f1c2a90", basket, ct);
        var retry = await PostOrder(client, "6f1c2a90", basket, ct);

        Assert.Equal(HttpStatusCode.Created, first.StatusCode);
        Assert.Equal(HttpStatusCode.Created, retry.StatusCode);
        Assert.Equal(first.Headers.Location, retry.Headers.Location);

        await using var scope = _factory.Services.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
        Assert.Equal(1, await db.Orders.CountAsync(o => o.CustomerSubject == "customer-42", ct));
    }

    [Theory]
    [InlineData(null, null, HttpStatusCode.Unauthorized)]
    [InlineData("customer-42", "orders.read", HttpStatusCode.Forbidden)]
    public async Task Post_requires_a_token_with_the_write_scope(string? user, string? scopes, HttpStatusCode expected)
    {
        var client = user is null ? _factory.CreateClient() : ClientFor(user, scopes!);
        var response = await PostOrder(client, "b51d07e2", new { lines = new[] { new { sku = "MUG-WHITE", quantity = 2 } } },
            TestContext.Current.CancellationToken);
        Assert.Equal(expected, response.StatusCode);
    }

    [Fact]
    public async Task Invalid_quantity_is_a_validation_problem_keyed_by_path()
    {
        var client = ClientFor("customer-43", "orders.write");
        var response = await PostOrder(client, "c0ffee01", new { lines = new[] { new { sku = "MUG-WHITE", quantity = 0 } } },
            TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(TestContext.Current.CancellationToken);
        Assert.Contains("Lines[0].Quantity", problem!.Errors.Keys);
    }

    private HttpClient ClientFor(string user, string scopes)
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Add(TestAuthHandler.UserHeader, user);
        client.DefaultRequestHeaders.Add(TestAuthHandler.ScopesHeader, scopes);
        return client;
    }

    private static Task<HttpResponseMessage> PostOrder(HttpClient client, string key, object body, CancellationToken ct)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/orders") { Content = JsonContent.Create(body) };
        request.Headers.Add("Idempotency-Key", key);
        return client.SendAsync(request, ct);
    }
}

public sealed class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    public const string Scheme = "Test";
    public const string UserHeader = "X-Test-User";
    public const string ScopesHeader = "X-Test-Scopes";

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(UserHeader, out var user))
            return Task.FromResult(AuthenticateResult.NoResult());

        Claim[] claims = [new("sub", user.ToString()), new("scope", Request.Headers[ScopesHeader].ToString())];
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme, "sub", "roles"));
        return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme)));
    }
}

// Stand-ins for types that live in Orders.Api.
public partial class Program;

public sealed class Order
{
    public long Id { get; set; }
    public required string CustomerSubject { get; set; }
}

public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

When to use it

  • Prove that two POSTs with Idempotency-Key 6f1c2a90 create exactly one row in a real PostgreSQL, not in the in-memory provider.
  • Assert the 401 and 403 matrix for POST /api/orders with a header-driven test authentication scheme instead of real tokens.
  • Fail CI when a developer changes the Order entity without adding a migration, because MigrateAsync refuses pending model changes.

More examples

Unit testing the checkout rules

The payment gateway is the one dependency worth faking here: it is slow, costs money and its failures are hard to trigger for real. <code>FakeTimeProvider</code> makes the paid timestamp exact instead of 'roughly now'. <code>Received(1)</code> checks the amount actually charged, which is the assertion that catches a rounding bug. Neither test touches HTTP or a database, so hundreds of cases like these run in about a second.

Example · csharp
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;

public sealed class CheckoutServiceTests
{
    private readonly IPaymentGateway _payments = Substitute.For<IPaymentGateway>();
    private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 22, 9, 30, 0, TimeSpan.Zero));

    [Fact]
    public async Task Declined_payment_leaves_the_order_pending()
    {
        _payments.CaptureAsync(1042, 129.90m, Arg.Any<CancellationToken>())
            .Returns(new PaymentResult(false, "insufficient_funds"));
        var order = new PendingOrder(1042, 129.90m);

        var outcome = await new CheckoutService(_payments, _clock).PayAsync(order, TestContext.Current.CancellationToken);

        Assert.Equal("insufficient_funds", outcome);
        Assert.Equal("Pending", order.Status);
        Assert.Null(order.PaidAt);
    }

    [Fact]
    public async Task Successful_payment_stamps_the_paid_time()
    {
        _payments.CaptureAsync(Arg.Any<long>(), Arg.Any<decimal>(), Arg.Any<CancellationToken>())
            .Returns(new PaymentResult(true, null));
        var order = new PendingOrder(1043, 18.50m);

        await new CheckoutService(_payments, _clock).PayAsync(order, TestContext.Current.CancellationToken);

        Assert.Equal("Paid", order.Status);
        Assert.Equal(_clock.GetUtcNow(), order.PaidAt);
        await _payments.Received(1).CaptureAsync(1043, 18.50m, Arg.Any<CancellationToken>());
    }
}

public sealed record PaymentResult(bool Succeeded, string? DeclineCode);

public interface IPaymentGateway
{
    Task<PaymentResult> CaptureAsync(long orderId, decimal amount, CancellationToken ct);
}

public sealed class PendingOrder(long id, decimal total)
{
    public long Id { get; } = id;
    public decimal Total { get; } = total;
    public string Status { get; private set; } = "Pending";
    public DateTimeOffset? PaidAt { get; private set; }

    public void MarkPaid(DateTimeOffset at) => (Status, PaidAt) = ("Paid", at);
}

public sealed class CheckoutService(IPaymentGateway payments, TimeProvider clock)
{
    public async Task<string?> PayAsync(PendingOrder order, CancellationToken ct)
    {
        var result = await payments.CaptureAsync(order.Id, order.Total, ct);
        if (!result.Succeeded) return result.DeclineCode;
        order.MarkPaid(clock.GetUtcNow());
        return null;
    }
}

The integration test project

<code>Microsoft.AspNetCore.Mvc.Testing</code> copies the API's <code>.deps.json</code> next to the tests and sets the content root, which is why the factory can boot the real <code>Program</code>. In .NET 10 the Web SDK generates a <code>public partial class Program</code> for apps that use top-level statements, so <code>WebApplicationFactory&lt;Program&gt;</code> works without changes; the hand-written <code>public partial class Program;</code> was the workaround on earlier versions. With Central Package Management the <code>Version</code> attributes move to <code>Directory.Packages.props</code>. The project has no <code>Microsoft.NET.Test.Sdk</code>, so it runs only on Microsoft.Testing.Platform: the repository's <code>global.json</code> carries <code>"test": { "runner": "Microsoft.Testing.Platform" }</code>, without which <code>dotnet test</code> uses VSTest and runs nothing (see Tests with xUnit v3).

Example · xml
<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" />
    <PackageReference Include="Testcontainers.PostgreSql" Version="4.16.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\Orders.Api\Orders.Api.csproj" />
  </ItemGroup>

</Project>

Proving the concurrency token against PostgreSQL

Two contexts play two users who loaded the same order. The warehouse ships it first; the staff member's cancel then carries an outdated <code>xmin</code> and must fail. This is the test that stops someone from removing <code>IsRowVersion()</code> in a refactor, and it only means something against PostgreSQL. It reuses the run's single container through <code>PostgresFixture</code> and creates its own database, and it uses <code>EnsureCreatedAsync</code> because it tests the mapping alone, not the migrations.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Xunit;

public sealed class OrderConcurrencyTests(PostgresFixture postgres) : IAsyncLifetime
{
    // The shared container from the assembly fixture; this class only gets its own database.
    private readonly string _connectionString = postgres.ConnectionStringFor($"concurrency_{Guid.NewGuid():N}");

    public async ValueTask InitializeAsync()
    {
        await using var db = NewContext();
        await db.Database.EnsureCreatedAsync();
        db.Orders.Add(new Order { Id = 1042, Status = "Paid" });
        await db.SaveChangesAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await using var db = NewContext();
        await db.Database.EnsureDeletedAsync();
    }

    [Fact]
    public async Task Second_writer_with_a_stale_version_is_rejected()
    {
        var ct = TestContext.Current.CancellationToken;
        await using var staff = NewContext();
        await using var warehouse = NewContext();
        var cancelling = await staff.Orders.SingleAsync(o => o.Id == 1042, ct);
        var shipping = await warehouse.Orders.SingleAsync(o => o.Id == 1042, ct);

        shipping.Status = "Shipped";
        await warehouse.SaveChangesAsync(ct);

        cancelling.Status = "Cancelled";
        await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => staff.SaveChangesAsync(ct));
    }

    private ConcurrencyDbContext NewContext() =>
        new(new DbContextOptionsBuilder<ConcurrencyDbContext>().UseNpgsql(_connectionString).Options);
}

// Stand-in for the assembly fixture in the main example, which starts the one container per run.
public sealed class PostgresFixture
{
    public string ConnectionStringFor(string database) =>
        $"Host=localhost;Port=5432;Username=postgres;Password=postgres;Database={database}";
}

public sealed class Order
{
    public long Id { get; set; }
    public required string Status { get; set; }
    public uint Version { get; set; }
}

public sealed class ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>().Property(o => o.Version).IsRowVersion();
}

Discussion

  • Be the first to comment on this lesson.