Skip to content
fullstackhero

Guide

Integration tests

WebApplicationFactory in-process API tests against Testcontainers Postgres + Valkey + MinIO — one container set, all modules, real round trips.

views 0 Last updated

Integration tests in fullstackhero spin up real infrastructure in containers — PostgreSQL 17, Valkey 8 (a Redis-compatible, BSD-licensed Redis fork), MinIO — and exercise the API end-to-end through Microsoft.AspNetCore.Mvc.Testing’s WebApplicationFactory<T>. The same container set serves every test class in the single Integration.Tests project, so startup cost (~10-30 seconds on first run) is paid once and the per-test cost is small.

The stack

LayerLibraryPurpose
HostMicrosoft.AspNetCore.Mvc.Testing 10.0.8WebApplicationFactory<TEntryPoint> for in-process API
ContainersTestcontainers 4.11Postgres / Valkey / MinIO per test session
HTTPHttpClient from WebApplicationFactory.CreateClient()Real HTTP calls (no marshalling)
AssertionsShouldly, NSubstitute, AutoFixtureSame as unit tests

The fixture pattern

FshWebApplicationFactory (in src/Tests/Integration.Tests/Infrastructure/) extends WebApplicationFactory<Program>. It overrides ConfigureWebHost to swap in the Testcontainers connection strings, runs the database migrator + demo seeder, and exposes helpers for authenticated clients:

public sealed class ProductsEndpointTests(FshWebApplicationFactory factory)
: IClassFixture<FshWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateAuthenticatedClient(tenant: "acme");
[Fact]
public async Task POST_products_Should_ReturnCreated_And_PersistProduct_When_PayloadIsValid()
{
var command = new CreateProductCommand(
Sku: "TEST-001", Name: "Test product", Description: null,
BrandId: Seed.Brands.Default.Id, CategoryId: Seed.Categories.Default.Id,
PriceAmount: 19.99m, PriceCurrency: "USD", Stock: 100m);
var resp = await _client.PostAsJsonAsync("/api/v1/catalog/products", command);
resp.StatusCode.ShouldBe(HttpStatusCode.Created);
var dto = await resp.Content.ReadFromJsonAsync<ProductResponse>();
dto!.Sku.ShouldBe("TEST-001");
}
}

IClassFixture<FshWebApplicationFactory> shares one factory instance across every test in the class (and across classes through xUnit’s collection fixtures if you mark the class).

Authenticated test clients

CreateAuthenticatedClient(tenant) does three things automatically:

  1. Mints a JWT for a seeded test user with all permissions, signed by the test host’s signing key.
  2. Sets the Authorization: Bearer ... header.
  3. Sets the tenant: acme header so Finbuckle resolves the right tenant context.

Adjacent helpers cover edge cases:

HelperPurpose
CreateAuthenticatedClient(tenant)Default — seeded test user, all permissions
CreateAnonymousClient()No Authorization header — for testing public endpoints
CreateClientWithoutPermissions(tenant)Authenticated but no permissions — for testing .RequirePermission denials
CreateClientWithImpersonation(actor, target)Pre-issued impersonation grant — for testing impersonated requests

The seeded users + tenants live in src/Tests/Integration.Tests/Infrastructure/Seed.cs. Reference Seed.Brands.Default.Id, Seed.Tenants.Acme, etc. to satisfy foreign keys without re-seeding per test.

The container fixtures

Three Testcontainers fixtures live in src/Tests/Integration.Tests/Infrastructure/:

  • DatabaseFixturepostgres:17-alpine, runs the kit’s migrations against it on startup.
  • RedisFixturevalkey/valkey:8-alpine for the cache + SignalR backplane + Data Protection key ring.
  • MinioFixture — MinIO for file storage tests.

Each fixture exposes connection details (ConnectionString, Endpoint, AccessKey, SecretKey) that FshWebApplicationFactory reads when overriding configuration.

The Testcontainers 4.11 API uses fluent .WithImage("image:tag") rather than the older new XBuilder(...) constructor — if you bump versions, expect a small migration.

A more realistic integration test

public sealed class ImpersonationTests(FshWebApplicationFactory factory)
: IClassFixture<FshWebApplicationFactory>
{
[Fact]
public async Task StartImpersonation_Should_ReturnGrantedAccessToken_And_AuditEvent_When_CallerIsSuperAdmin()
{
// Arrange — SuperAdmin in root tenant; target in acme
var actorClient = factory.CreateAuthenticatedClient(tenant: "root");
var targetUserId = Seed.Tenants.Acme.AdminUser.Id;
// Act — start impersonation
var startResp = await actorClient.PostAsJsonAsync(
"/api/v1/identity/impersonation/start",
new StartImpersonationCommand(
ImpersonatedUserId: targetUserId,
DurationMinutes: 15,
Reason: "investigating issue ABC-123"));
// Assert
startResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var dto = await startResp.Content.ReadFromJsonAsync<ImpersonationResponse>();
dto!.AccessToken.ShouldNotBeNullOrEmpty();
// Use the impersonation token to call an endpoint as the target user
var impersonatedClient = factory.CreateClient();
impersonatedClient.DefaultRequestHeaders.Authorization = new("Bearer", dto.AccessToken);
impersonatedClient.DefaultRequestHeaders.Add("tenant", "acme");
var meResp = await impersonatedClient.GetAsync("/api/v1/identity/users/me");
meResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var me = await meResp.Content.ReadFromJsonAsync<UserDto>();
me!.Id.ShouldBe(targetUserId);
// Audit row exists
using var scope = factory.Services.CreateScope();
var audits = scope.ServiceProvider.GetRequiredService<IAuditDbContext>();
var audit = await audits.AuditRecords
.IgnoreQueryFilters() // cross-tenant query
.FirstOrDefaultAsync(a => a.PayloadJson.Contains(dto.AccessToken /* or grant id */));
audit.ShouldNotBeNull();
}
}

Real database, real cache, real auth pipeline, real audit interceptor. The test fails if anything along the chain breaks.

Test data discipline

  • Don’t share state across tests. xUnit runs tests in parallel by default. If two tests insert a Product with the same SKU, they’ll fight. Use AutoFixture for unique data, or scope inserts to a per-test transaction that’s rolled back.
  • Don’t mutate the seed. Seed.Brands.Default is for reading. Insert your own brand if you need to mutate it.
  • Don’t trust class ordering. xUnit doesn’t guarantee execution order across classes. If a test needs another test to “have created X first,” the test is wrong.

When integration tests get slow

A few minutes-long integration suite is acceptable in CI but painful locally. Mitigations:

  • Run unit tests first (dotnet test src/Tests/{Module}.Tests/) — they’re fast and they catch most regressions.

  • Use --filter to run just the test you’re iterating on:

    Terminal window
    dotnet test --filter "FullyQualifiedName~ProductsEndpointTests"
  • Keep the containers warm — Testcontainers reuses containers across test runs in the same process by default; running tests via dotnet watch test benefits from this.

  • Don’t add cross-test sleeps. “Wait 200 ms for the cache to settle” is almost always wrong; find the deterministic signal and assert against it.

Running

Docker must be running for Testcontainers to work:

Terminal window
# Run the entire integration suite
dotnet test src/Tests/Integration.Tests/
# Single test class
dotnet test src/Tests/Integration.Tests/ \
--filter "FullyQualifiedName~ProductsEndpointTests"
# Verbose output
dotnet test src/Tests/Integration.Tests/ --logger "console;verbosity=detailed"

CI runs integration tests on every push. First-run cold containers add ~30 seconds; subsequent runs ~5 seconds of overhead.