Testing It

Unit tests for the rules that need nothing, and integration tests through WebApplicationFactory against a real Postgres in a container.

Syntaxbuilder.UseSetting("ConnectionStrings:Shortly", _postgres.GetConnectionString());

The service needs two kinds of test, and the split is about dependencies rather than size. A unit test calls one piece of logic directly, in memory, in microseconds. An integration test starts the whole application and sends it a real HTTP request, so routing, model binding, validation, authentication, serialisation and the actual SQL all run.

What goes where

Unit test the rules that depend on nothing: is this code six valid characters, is this link expired at this instant, is this target an allowed address. These are the tests you will run hundreds of times a day.

Integration test the things only the real pipeline can get wrong: that an unauthenticated POST returns 401, that an unknown code returns 404 rather than 500, that the unique index on code actually rejects a duplicate, that a migration applies to an empty database.

A real database, started by the test

WebApplicationFactory<Program> runs your real Program.cs in the test process and hands back an HttpClient wired to it, with no port opened. Point it at Postgres in a throwaway container, started by Testcontainers. The EF Core in-memory provider is quicker but it is not a database: it does not enforce a unique index, so the duplicate-code test passes there and fails in production.

Override the connection string with UseSetting rather than unregistering and re-registering the DbContext. One line, and it exercises the same configuration path the deployed service uses.

What trips people up

Speed. Starting a container per test class is seconds; per test is minutes. Share one fixture and keep each test's data distinct instead.

Example

Example · csharp
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shortly.Api.Data;
using Testcontainers.PostgreSql;
using Xunit;

namespace Shortly.Api.Tests;

// One Postgres container and one in-memory app, shared by a whole test class.
public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:17")
        .Build();

    // xUnit v3 calls this once, before the first test in the class.
    public async ValueTask InitializeAsync()
    {
        await _postgres.StartAsync();

        // Touching Services builds the app, which runs ConfigureWebHost below.
        using IServiceScope scope = Services.CreateScope();
        ShortlyDb db = scope.ServiceProvider.GetRequiredService<ShortlyDb>();
        await db.Database.MigrateAsync();   // the real migrations, not EnsureCreated
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // The app reads this key from configuration, so no service needs replacing.
        builder.UseSetting("ConnectionStrings:Shortly", _postgres.GetConnectionString());
        builder.UseEnvironment("Testing");
    }

    public override async ValueTask DisposeAsync()
    {
        await base.DisposeAsync();          // shut the app down first
        await _postgres.DisposeAsync();     // then delete the container
    }
}

When to use it

  • Proving an endpoint returns 404 and not 500 for a code that does not exist.
  • Catching a migration that applies to an empty database but fails on one with rows.
  • Testing a unique-index violation, which no in-memory fake can produce.

More examples

A test that uses the fixture

These are the two integration tests worth writing before any happy path. Both are about the pipeline rather than the logic: one proves a missing row is a 404 and not an unhandled exception, the other proves the authorisation attached to the route group is really there. Both would still pass if every handler were deleted, which is why you also need the happy-path test.

Example · csharp
using System.Net;
using System.Net.Http.Json;
using Xunit;

namespace Shortly.Api.Tests;

// IClassFixture gives every test in this class the same ApiFixture instance,
// so the container starts once for the class and not once per test.
public class RedirectTests : IClassFixture<ApiFixture>
{
    private readonly HttpClient _client;

    public RedirectTests(ApiFixture fixture)
    {
        // Not following the redirect is the point: we want to see the 302.
        _client = fixture.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false
        });
    }

    [Fact]
    public async Task UnknownCode_Returns404()
    {
        HttpResponseMessage response = await _client.GetAsync("/r/zzzzzz");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task CreateLink_WithoutToken_Returns401()
    {
        HttpResponseMessage response = await _client.PostAsJsonAsync(
            "/links", new { targetUrl = "https://example.com/docs" });

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}

// dotnet test reports: Failed: 0, Passed: 2, Skipped: 0, Total: 2

The unit tests that need no database

Use this shape for every rule with an edge at a boundary. Passing the current time in as an argument turns an untestable method into three one-line cases, including the awkward one where the expiry is exactly now. Nothing here needs a container, so these tests run on every keystroke.

Example · csharp
using Xunit;

namespace Shortly.Api.Tests;

public static class LinkRules
{
    // The time is a parameter, never DateTimeOffset.UtcNow inside the method.
    // That is the whole reason this rule is testable.
    public static bool IsExpired(DateTimeOffset? expiresAt, DateTimeOffset now)
    {
        return expiresAt is not null && expiresAt.Value <= now;
    }
}

public class LinkRulesTests
{
    private static readonly DateTimeOffset Now =
        new DateTimeOffset(2026, 1, 14, 9, 30, 0, TimeSpan.Zero);

    [Fact]
    public void NoExpiry_IsNeverExpired()
    {
        Assert.False(LinkRules.IsExpired(null, Now));
    }

    [Theory]
    [InlineData(-1, true)]    // one second ago
    [InlineData(0, true)]     // exactly now counts as expired
    [InlineData(1, false)]    // one second from now
    public void Expiry_IsRelativeToNow(int offsetSeconds, bool expected)
    {
        DateTimeOffset expiresAt = Now.AddSeconds(offsetSeconds);

        Assert.Equal(expected, LinkRules.IsExpired(expiresAt, Now));
    }
}

// dotnet test reports: Failed: 0, Passed: 4, Skipped: 0, Total: 4

Resetting the tables between tests

Reach for this the first time two tests pass separately and fail together. Truncating in the per-test setup, rather than the teardown, means a failed test leaves its rows behind for you to inspect. The duplicate-code assertion is also the test that cannot be written against a fake: only a real unique index raises DbUpdateException.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shortly.Api.Data;
using Xunit;

namespace Shortly.Api.Tests;

public class LinkWriteTests : IClassFixture<ApiFixture>, IAsyncLifetime
{
    private readonly ApiFixture _fixture;

    public LinkWriteTests(ApiFixture fixture) => _fixture = fixture;

    // IAsyncLifetime on a test class runs once per test, before and after it.
    public async ValueTask InitializeAsync()
    {
        using IServiceScope scope = _fixture.Services.CreateScope();
        ShortlyDb db = scope.ServiceProvider.GetRequiredService<ShortlyDb>();

        // TRUNCATE is far quicker than deleting rows, and RESTART IDENTITY puts
        // the sequences back so generated ids are the same in every test.
        await db.Database.ExecuteSqlRawAsync(
            "TRUNCATE TABLE link_clicks, links, users RESTART IDENTITY CASCADE");
    }

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;

    [Fact]
    public async Task DuplicateCode_IsRejectedByTheDatabase()
    {
        using IServiceScope scope = _fixture.Services.CreateScope();
        ShortlyDb db = scope.ServiceProvider.GetRequiredService<ShortlyDb>();

        db.Links.Add(new Link { Code = "a7Kd92", TargetUrl = "https://a.example" });
        db.Links.Add(new Link { Code = "a7Kd92", TargetUrl = "https://b.example" });

        await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
    }
}

// dotnet test reports: Failed: 0, Passed: 1, Skipped: 0, Total: 1

Discussion

  • Be the first to comment on this lesson.