The single Integration.Tests project shares one factory — and therefore one set of containers, one host, and one seeded database — across every test in the suite. Each test runs in milliseconds once the containers are warm. This page is how the sharing works. Everything described here lives in src/Tests/Integration.Tests/Infrastructure/.
The shared pieces
| Piece | Type | Role |
|---|---|---|
FshWebApplicationFactory | WebApplicationFactory<Program>, IAsyncLifetime | Owns the postgres:17-alpine + minio/minio:latest Testcontainers, builds the in-process host, migrates + seeds |
FshCollectionDefinition | ICollectionFixture<FshWebApplicationFactory> | Shares one factory instance across the whole suite ([Collection(FshCollectionDefinition.Name)]) |
AuthHelper | Plain class, wraps the factory | Issues real tokens via POST /token/issue and builds authenticated HttpClients |
TestConstants | Static class | Root tenant id, root admin credentials, JWT settings, per-module base paths |
NoOpMailService | IMailService swap | No SMTP, no Hangfire retry noise |
DetailedTestExceptionHandler | IExceptionHandler swap | Failures show the real exception instead of “An unexpected error occurred” |
Because the factory is a collection fixture (not a class fixture), the containers start once at the beginning of the test session and dispose at the end. They’re created with WithAutoRemove(true), so nothing lingers after the run.
Connection wiring
The factory pulls connection details from its containers during ConfigureWebHost and overlays them on the production configuration:
builder.ConfigureAppConfiguration((_, config) =>{ config.AddInMemoryCollection(new Dictionary<string, string?> { ["DatabaseOptions:Provider"] = "POSTGRESQL", ["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(), ["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL", ["CachingOptions:Redis"] = "", // HybridCache in-memory fallback — no cache container ["JwtOptions:SigningKey"] = TestConstants.JwtSigningKey, ["RateLimitingOptions:Enabled"] = "false", ["SecurityHeadersOptions:Enabled"] = "false", ["Storage:Provider"] = "s3", ["Storage:S3:ServiceUrl"] = _minio.GetConnectionString(), // ... bucket, keys, region });});The factory also swaps Hangfire to Hangfire.InMemory (with a real server polling every second, so jobs actually run), replaces mail with NoOpMailService, and removes hosted services that would race the test migrations (role-permission sync, outbox dispatcher).
Seeded data
On first initialization the factory provisions the database through the production code paths:
- Migrates the tenant catalog (
TenantDbContext.Database.MigrateAsync()). - Seeds the root tenant if missing.
- Runs every module’s
IDbInitializer.MigrateAsyncandSeedAsyncunder the root tenant context — which creates the root admin, roles, permissions, and groups. - Runs the
RolePermissionSyncerso permissions match the live permission catalog.
What you can rely on from TestConstants:
TestConstants.RootTenantId // "root"TestConstants.RootAdminEmail // "admin@root.com"TestConstants.DefaultPassword // the seeded admin passwordTestConstants.CatalogBasePath // "/api/v1/catalog" — and friends per moduleThat’s it — no demo tenants, no seeded brands or products. Tests that need an acme-style tenant, a low-privilege user, or catalog rows create them through the API in their own Arrange step. That keeps every test self-describing and safe to run in parallel.
Authentication helpers
AuthHelper doesn’t mint JWTs by hand — it logs in through the real identity endpoint, so the token has been through the production issuing path:
var auth = new AuthHelper(factory);
// Root admin, all permissionsusing var admin = await auth.CreateRootAdminClientAsync();
// Any user your test createdusing var user = await auth.CreateAuthenticatedClientAsync(email, password, tenant);
// Anonymous (for 401 tests) — set the tenant header yourselfusing var anon = factory.CreateClient();anon.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId);Both authenticated helpers set the Authorization: Bearer and tenant headers for you.
When tests need their own state
Tests should insert, not mutate shared rows — and insert with unique names:
[Fact]public async Task MyTest(){ using var client = await _auth.CreateRootAdminClientAsync();
// Arrange — unique name avoids parallel-test collisions var name = $"test-brand-{Guid.NewGuid():N}"[..20]; var createResponse = await client.PostAsJsonAsync( $"{TestConstants.CatalogBasePath}/brands", new { name, description = "mine", logoUrl = (string?)null });
// Act / Assert against the row this test owns}Prefer going through the API. When a test must reach into the database directly, create a scope from factory.Services — and mind the tenant-context gotcha below.
When to add to the harness
- New shared infrastructure. If you need RabbitMQ, Elasticsearch, etc., add the container as another field on
FshWebApplicationFactory, start it inInitializeAsync, and overlay its connection details — same shape as the MinIO wiring. For something only one test class needs (like the Valkey container inHybridCacheRedisTests), keep it local to that class viaIAsyncLifetimeinstead. - New always-present seed data. Extend the module’s
IDbInitializerseed — that’s the production path, and the factory runs it automatically. - One-off setup per test class. Don’t touch the harness; arrange it in the test class constructor or the test itself.
Common mistakes
- Sharing a
DbContextacross tests. Each scope should be its own — usefactory.Services.CreateScope()inside the test; don’t reach into the factory’s root provider. - Mutating shared rows. The root admin and root tenant are read-only in spirit; a test that disables the root admin breaks every test after it.
- Forgetting the
tenantheader. Without it, Finbuckle resolves no tenant and tenant-aware endpoints fail. TheAuthHelperclients set it for you; rawfactory.CreateClient()does not. Thread.Sleep(...)in tests. “Wait 200 ms for the job to run” is the smell of a missing signal. Find the deterministic completion (a poll with timeout, a hub event, a domain assertion) and assert against that.
Related
- Integration tests — the test layer that uses this harness.
- Running tests — how to invoke the suite locally + in CI.
- Writing new tests — recipes for each layer.