Skip to content
fullstackhero

Guide

Fixtures & seed data

The shared test factory (containers, host, seeded root tenant + admin) used across the integration suite, and the service swaps that keep tests deterministic.

views 0 Last updated

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

PieceTypeRole
FshWebApplicationFactoryWebApplicationFactory<Program>, IAsyncLifetimeOwns the postgres:17-alpine + minio/minio:latest Testcontainers, builds the in-process host, migrates + seeds
FshCollectionDefinitionICollectionFixture<FshWebApplicationFactory>Shares one factory instance across the whole suite ([Collection(FshCollectionDefinition.Name)])
AuthHelperPlain class, wraps the factoryIssues real tokens via POST /token/issue and builds authenticated HttpClients
TestConstantsStatic classRoot tenant id, root admin credentials, JWT settings, per-module base paths
NoOpMailServiceIMailService swapNo SMTP, no Hangfire retry noise
DetailedTestExceptionHandlerIExceptionHandler swapFailures 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:

  1. Migrates the tenant catalog (TenantDbContext.Database.MigrateAsync()).
  2. Seeds the root tenant if missing.
  3. Runs every module’s IDbInitializer.MigrateAsync and SeedAsync under the root tenant context — which creates the root admin, roles, permissions, and groups.
  4. Runs the RolePermissionSyncer so 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 password
TestConstants.CatalogBasePath // "/api/v1/catalog" — and friends per module

That’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 permissions
using var admin = await auth.CreateRootAdminClientAsync();
// Any user your test created
using var user = await auth.CreateAuthenticatedClientAsync(email, password, tenant);
// Anonymous (for 401 tests) — set the tenant header yourself
using 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 in InitializeAsync, and overlay its connection details — same shape as the MinIO wiring. For something only one test class needs (like the Valkey container in HybridCacheRedisTests), keep it local to that class via IAsyncLifetime instead.
  • New always-present seed data. Extend the module’s IDbInitializer seed — 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 DbContext across tests. Each scope should be its own — use factory.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 tenant header. Without it, Finbuckle resolves no tenant and tenant-aware endpoints fail. The AuthHelper clients set it for you; raw factory.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.