Back to blog
Backend Systemsadvanced

Integration Testing With WebApplicationFactory and Testcontainers

Go beyond unit tests with real HTTP calls, a real PostgreSQL database in a container, clean DB state between tests, and test JWT auth — using WebApplicationFactory and Testcontainers.

LearnixoApril 14, 20265 min read
.NETC#TestingxUnitIntegration TestingTestcontainersWebApplicationFactoryASP.NET Core
Share:𝕏

Why Unit Tests Aren't Enough

Unit tests verify business logic in isolation. They don't verify:

  • EF Core queries actually return the right data
  • Middleware is applied in the right order
  • Validation rejects invalid requests before they hit handlers
  • Auth policies block unauthenticated requests
  • JSON serialization round-trips correctly through the API

Integration tests fill this gap by running your actual application stack against a real database, making real HTTP calls, and asserting on real HTTP responses.


Setup

Bash
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package FluentAssertions
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk

Ensure your app entry point is accessible:

C#
// Program.cs — add this at the bottom
// Makes Program accessible to the test project
public partial class Program { }

WebApplicationFactory Setup

C#
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("testuser")
        .WithPassword("testpass")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the real DbContext registration
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (descriptor is not null)
                services.Remove(descriptor);

            // Replace with test database connection
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_postgres.GetConnectionString()));
        });
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        // Run migrations against the test database
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    public new async Task DisposeAsync()
    {
        await _postgres.StopAsync();
    }
}

Writing Your First Integration Test

C#
public class OrdersApiTests : IClassFixture<ApiFactory>
{
    private readonly HttpClient _client;
    private readonly ApiFactory _factory;

    public OrdersApiTests(ApiFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task PlaceOrder_Returns201_WithOrderId()
    {
        var request = new
        {
            CustomerId = Guid.NewGuid(),
            Lines = new[]
            {
                new { ProductId = Guid.NewGuid(), Quantity = 2 }
            }
        };

        var response = await _client.PostAsJsonAsync("/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var body = await response.Content.ReadFromJsonAsync<PlaceOrderResponse>();
        body!.OrderId.Should().NotBeEmpty();
        body.Total.Should().BeGreaterThan(0);
    }

    [Fact]
    public async Task GetOrder_Returns404_WhenOrderDoesNotExist()
    {
        var response = await _client.GetAsync($"/orders/{Guid.NewGuid()}");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task PlaceOrder_Returns400_WhenLinesEmpty()
    {
        var request = new
        {
            CustomerId = Guid.NewGuid(),
            Lines = Array.Empty<object>()
        };

        var response = await _client.PostAsJsonAsync("/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

Cleaning the Database Between Tests

Tests that share the same database can interfere with each other. Use a respawn-style reset:

Bash
dotnet add package Respawn
C#
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    // ... postgres container setup as before

    private Respawner _respawner = default!;
    private NpgsqlConnection _connection = default!;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        _connection = new NpgsqlConnection(_postgres.GetConnectionString());
        await _connection.OpenAsync();

        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();

        _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            SchemasToInclude = new[] { "public" }
        });
    }

    public async Task ResetDatabaseAsync()
        => await _respawner.ResetAsync(_connection);

    public new async Task DisposeAsync()
    {
        await _connection.DisposeAsync();
        await _postgres.StopAsync();
    }
}

Each test class resets before running:

C#
public class OrdersApiTests : IClassFixture<ApiFactory>, IAsyncLifetime
{
    private readonly HttpClient _client;
    private readonly ApiFactory _factory;

    public OrdersApiTests(ApiFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    public async Task InitializeAsync()
        => await _factory.ResetDatabaseAsync();

    public Task DisposeAsync() => Task.CompletedTask;

    // ... tests
}

Testing Auth Endpoints

Replace the real JWT validation with a test scheme:

C#
// In ApiFactory.ConfigureWebHost
services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
    options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
    TestAuthHandler.SchemeName, _ => { });
C#
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string SchemeName = "Test";
    public const string UserIdHeader = "X-Test-UserId";
    public const string RolesHeader = "X-Test-Roles";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder) : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(UserIdHeader, out var userIdValues))
            return Task.FromResult(AuthenticateResult.Fail("No test user header."));

        var userId = userIdValues.First()!;
        var roles = Request.Headers.TryGetValue(RolesHeader, out var roleValues)
            ? roleValues.First()!.Split(',')
            : Array.Empty<string>();

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, userId),
            new(ClaimTypes.Name, "Test User")
        };

        claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

        var identity = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Tests authenticate by setting headers:

C#
[Fact]
public async Task GetMyOrders_Returns401_WhenNotAuthenticated()
{
    var response = await _client.GetAsync("/orders/mine");
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task GetMyOrders_Returns200_WhenAuthenticated()
{
    var userId = Guid.NewGuid().ToString();
    _client.DefaultRequestHeaders.Add(TestAuthHandler.UserIdHeader, userId);

    var response = await _client.GetAsync("/orders/mine");

    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

[Fact]
public async Task AdminEndpoint_Returns403_ForNonAdmin()
{
    _client.DefaultRequestHeaders.Add(TestAuthHandler.UserIdHeader, Guid.NewGuid().ToString());
    // No admin role set

    var response = await _client.DeleteAsync($"/orders/{Guid.NewGuid()}");

    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Parallel Test Execution Considerations

xUnit runs test classes in parallel by default. With Testcontainers this means:

  • Each test class with IClassFixture<ApiFactory> shares one container instance — fine
  • Two test classes with separate ApiFactory instances spin up two PostgreSQL containers — expensive

Use ICollectionFixture to share the factory across multiple test classes:

C#
[CollectionDefinition("Api")]
public class ApiCollection : ICollectionFixture<ApiFactory> { }

[Collection("Api")]
public class OrdersApiTests : IAsyncLifetime
{
    // ...
}

[Collection("Api")]
public class ProductsApiTests : IAsyncLifetime
{
    // ...
}

One container, shared between both test classes. Call ResetDatabaseAsync in each test's InitializeAsync to prevent cross-test contamination.

For truly parallel tests against the same database, use separate schemas per test or unique table prefixes — but for most APIs, sequential execution per collection is fast enough.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.