.NET & C# Development · Lesson 87 of 92
Integration Tests That Hit a Real Database
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
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.SdkEnsure your app entry point is accessible:
// Program.cs — add this at the bottom
// Makes Program accessible to the test project
public partial class Program { }WebApplicationFactory Setup
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
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:
dotnet add package Respawnpublic 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:
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:
// In ApiFactory.ConfigureWebHost
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });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:
[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
ApiFactoryinstances spin up two PostgreSQL containers — expensive
Use ICollectionFixture to share the factory across multiple test classes:
[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.