REST API Engineering · Lesson 19 of 19
Integration Testing REST APIs — WebApplicationFactory & Testcontainers
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
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.PostgreSql
dotnet add package xunit
dotnet add package xunit.runner.visualstudioCustom WebApplicationFactory
Create a factory that replaces your production DB with a test container:
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:
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
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
[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
[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)
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
dotnet add package Respawnprivate 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)
[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
[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