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
| Layer | Library | Purpose |
|---|---|---|
| Host | Microsoft.AspNetCore.Mvc.Testing 10.0.8 | WebApplicationFactory<TEntryPoint> for in-process API |
| Containers | Testcontainers 4.11 | Postgres / Valkey / MinIO per test session |
| HTTP | HttpClient from WebApplicationFactory.CreateClient() | Real HTTP calls (no marshalling) |
| Assertions | Shouldly, NSubstitute, AutoFixture | Same 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:
- Mints a JWT for a seeded test user with all permissions, signed by the test host’s signing key.
- Sets the
Authorization: Bearer ...header. - Sets the
tenant: acmeheader so Finbuckle resolves the right tenant context.
Adjacent helpers cover edge cases:
| Helper | Purpose |
|---|---|
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/:
DatabaseFixture—postgres:17-alpine, runs the kit’s migrations against it on startup.RedisFixture—valkey/valkey:8-alpinefor 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
Productwith 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.Defaultis 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
--filterto 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 testbenefits 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:
# Run the entire integration suitedotnet test src/Tests/Integration.Tests/
# Single test classdotnet test src/Tests/Integration.Tests/ \ --filter "FullyQualifiedName~ProductsEndpointTests"
# Verbose outputdotnet 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.
Related
- Unit tests — for fast in-process tests.
- Architecture tests — for the NetArchTest rule families.
- Catalog module — the most thoroughly integration-tested module.