Back to blog
Backend Systemsintermediate

Integration Testing REST APIs — WebApplicationFactory & Testcontainers

Test your ASP.NET Core REST API end-to-end with WebApplicationFactory and a real PostgreSQL database via Testcontainers. Test the full HTTP stack — routing, middleware, auth, and DB — without mocking.

LearnixoApril 15, 20266 min read
.NETC#TestingASP.NET CoreWebApplicationFactoryTestcontainersxUnitIntegration Testing
Share:𝕏

Unit tests verify logic in isolation. Integration tests verify that your API actually works end-to-end — routing resolves, middleware runs, EF Core queries execute, the right HTTP status comes back. You need both, but integration tests catch the class of bugs that unit tests can't.


The Stack

  • WebApplicationFactory — spins up your ASP.NET Core app in-process, no network needed
  • Testcontainers — starts a real PostgreSQL container per test run
  • xUnit — test framework
  • HttpClient — makes real HTTP calls to the in-process server
Bash
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package xunit
dotnet add package xunit.runner.visualstudio

Custom WebApplicationFactory

Create a factory that replaces your production DB with a test container:

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

    // IAsyncLifetime — starts before first test, stops after last
    public async Task InitializeAsync()
    {
        await _db.StartAsync();
    }

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

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

            // Register with the test container connection string
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_db.GetConnectionString()));
        });

        builder.UseEnvironment("Testing");
    }
}

The factory starts the container once per test class — not per test. Shared startup cost.


Base Test Class

Centralise shared setup: apply migrations, seed reference data, expose an authenticated client:

C#
public abstract class IntegrationTestBase : IClassFixture<ApiFactory>
{
    protected readonly HttpClient Client;
    protected readonly AppDbContext Db;

    protected IntegrationTestBase(ApiFactory factory)
    {
        Client = factory.CreateClient();

        // Get a DbContext scope for seeding/asserting
        var scope = factory.Services.CreateScope();
        Db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        // Apply migrations once
        Db.Database.Migrate();
    }

    // Create an authenticated client for endpoints that require auth
    protected HttpClient CreateAuthenticatedClient(string role = "User")
    {
        var client = factory.CreateClient();
        var token = GenerateTestJwt(role);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }

    private static string GenerateTestJwt(string role)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes("test-secret-key-at-least-32-characters"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: "test",
            audience: "test",
            claims: new[]
            {
                new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
                new Claim(ClaimTypes.Role, role),
            },
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

Writing Integration Tests

Test a GET endpoint

C#
public class ProductsEndpointTests : IntegrationTestBase
{
    public ProductsEndpointTests(ApiFactory factory) : base(factory) { }

    [Fact]
    public async Task GetAll_Returns200_WithProducts()
    {
        // Arrange — seed the database
        Db.Products.AddRange(
            new Product { Name = "Widget A", Price = 9.99m, Category = "Widgets" },
            new Product { Name = "Widget B", Price = 19.99m, Category = "Widgets" }
        );
        await Db.SaveChangesAsync();

        // Act
        var response = await Client.GetAsync("/api/products");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var body = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
        body!.Items.Should().HaveCountGreaterThanOrEqualTo(2);
        body.Items.Should().Contain(p => p.Name == "Widget A");
    }

    [Fact]
    public async Task GetById_Returns404_WhenProductNotFound()
    {
        var response = await Client.GetAsync("/api/products/99999");
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task GetById_Returns200_WithCorrectProduct()
    {
        // Arrange
        var product = new Product { Name = "Test Item", Price = 5.00m };
        Db.Products.Add(product);
        await Db.SaveChangesAsync();

        // Act
        var response = await Client.GetAsync($"/api/products/{product.Id}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var dto = await response.Content.ReadFromJsonAsync<ProductDto>();
        dto!.Name.Should().Be("Test Item");
        dto.Price.Should().Be(5.00m);
    }
}

Test a POST endpoint

C#
[Fact]
public async Task CreateProduct_Returns201_WithLocationHeader()
{
    // Arrange
    var authClient = CreateAuthenticatedClient("Admin");
    var request = new CreateProductDto("New Product", 29.99m, "Widgets");

    // Act
    var response = await authClient.PostAsJsonAsync("/api/products", request);

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Created);
    response.Headers.Location.Should().NotBeNull();
    response.Headers.Location!.ToString().Should().Contain("/api/products/");

    var created = await response.Content.ReadFromJsonAsync<ProductDto>();
    created!.Name.Should().Be("New Product");
    created.Price.Should().Be(29.99m);

    // Verify it's actually in the database
    var inDb = await Db.Products.FindAsync(created.Id);
    inDb.Should().NotBeNull();
    inDb!.Name.Should().Be("New Product");
}

[Fact]
public async Task CreateProduct_Returns401_WhenUnauthenticated()
{
    var response = await Client.PostAsJsonAsync("/api/products",
        new CreateProductDto("Test", 9.99m, "Cat"));
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task CreateProduct_Returns403_WhenNotAdmin()
{
    var userClient = CreateAuthenticatedClient("User");  // not Admin
    var response = await userClient.PostAsJsonAsync("/api/products",
        new CreateProductDto("Test", 9.99m, "Cat"));
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Test validation

C#
[Fact]
public async Task CreateProduct_Returns400_WhenNameIsEmpty()
{
    var authClient = CreateAuthenticatedClient("Admin");
    var response = await authClient.PostAsJsonAsync("/api/products",
        new CreateProductDto("", 9.99m, "Category"));  // empty name

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

    var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
    problem!.Errors.Should().ContainKey("Name");
}

[Theory]
[InlineData(-1)]
[InlineData(0)]
public async Task CreateProduct_Returns400_WhenPriceIsInvalid(decimal price)
{
    var authClient = CreateAuthenticatedClient("Admin");
    var response = await authClient.PostAsJsonAsync("/api/products",
        new CreateProductDto("Valid Name", price, "Category"));

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

Database Isolation Between Tests

Each test leaves data behind, which can pollute other tests. Two approaches:

Option 1: Wrap each test in a transaction (fastest)

C#
public abstract class IntegrationTestBase : IClassFixture<ApiFactory>, IAsyncLifetime
{
    private IDbContextTransaction? _transaction;

    public async Task InitializeAsync()
    {
        _transaction = await Db.Database.BeginTransactionAsync();
    }

    public async Task DisposeAsync()
    {
        // Roll back after each test — data never committed
        await _transaction!.RollbackAsync();
    }
}

Each test runs inside a transaction that's rolled back at the end. Fast, but doesn't work if your code under test spawns its own transactions or uses a different DbContext.

Option 2: Respawn — truncate tables between tests

Bash
dotnet add package Respawn
C#
private static Respawner _respawner = null!;

public static async Task InitializeAsync()
{
    // Called once — create respawner
    _respawner = await Respawner.CreateAsync(_db.GetConnectionString(), new RespawnerOptions
    {
        DbAdapter = DbAdapter.Postgres,
        TablesToIgnore = new Table[] { "__EFMigrationsHistory" }
    });
}

// Before each test — clean all tables
public async Task InitializeAsync()
    => await _respawner.ResetAsync(_db.GetConnectionString());

Respawn truncates all tables before each test. Slightly slower than transactions but handles any execution model.


Testing Error Responses (ProblemDetails)

C#
[Fact]
public async Task GetById_ReturnsProblemDetails_WhenNotFound()
{
    var response = await Client.GetAsync("/api/products/99999");

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

    var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
    problem!.Status.Should().Be(404);
    problem.Title.Should().NotBeNullOrEmpty();
}

Testing File Uploads

C#
[Fact]
public async Task UploadFile_Returns200_WithFileName()
{
    var authClient = CreateAuthenticatedClient();
    using var content = new MultipartFormDataContent();

    var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes("fake file content"));
    fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
    content.Add(fileContent, "file", "test.txt");

    var response = await authClient.PostAsync("/api/upload", content);

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

Anti-Patterns to Avoid

Don't mock the HTTP layer. Using MockHttpMessageHandler to intercept requests defeats the purpose — you're not testing your routing or middleware.

Don't mock the database. Tests that mock EF Core give false confidence. The whole point of integration tests is to verify queries work against a real database. Testcontainers makes this cheap.

Don't use a shared mutable database. Tests must be isolated. If test A creates data that test B depends on, you have flaky tests.

Don't test things the framework already tests. ASP.NET Core's routing works — don't write tests just to verify that [HttpGet("{id}")] routes correctly. Test your business logic and error cases.


Quick Reference

WebApplicationFactory   → in-process app server, no port binding
Testcontainers          → real PostgreSQL per test run, auto-cleaned
Test isolation          → transaction rollback (fast) or Respawn (flexible)
Auth testing            → generate test JWT, set Authorization header
Assert status codes     → response.StatusCode.Should().Be(HttpStatusCode.Created)
Assert body             → ReadFromJsonAsync() + FluentAssertions
Assert DB state         → query DbContext after the HTTP call

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.