Integration tests in fullstackhero spin up real infrastructure in containers — PostgreSQL 17 and MinIO — and exercise the API end-to-end through Microsoft.AspNetCore.Mvc.Testing’s WebApplicationFactory<T>. One factory (and therefore one container set) serves the entire Integration.Tests project via an xUnit collection fixture, so the startup cost is paid once and the per-test cost is small.
The stack
| Layer | Library | Purpose |
|---|---|---|
| Host | Microsoft.AspNetCore.Mvc.Testing | WebApplicationFactory<Program> for the in-process API |
| Containers | Testcontainers.PostgreSql / Testcontainers.Minio 4.11 | postgres:17-alpine + minio/minio:latest per test run |
| HTTP | HttpClient from factory.CreateClient() | Real HTTP calls through the full middleware pipeline |
| Assertions | Shouldly, NSubstitute, AutoFixture | Same as unit tests |
There is no Redis/Valkey container in the shared harness — CachingOptions:Redis is set to an empty string, so HybridCache runs on its in-memory fallback and Hangfire runs on Hangfire.InMemory. The one place a real cache engine matters, Tests/Caching/HybridCacheRedisTests.cs, spins up its own valkey/valkey:9.1.0-alpine container to guard against in-memory-vs-distributed serialization divergence.
The fixture pattern
FshWebApplicationFactory (in src/Tests/Integration.Tests/Infrastructure/) extends WebApplicationFactory<Program> and owns the Postgres + MinIO containers as fields. It is shared across the whole suite through a collection fixture:
[CollectionDefinition(Name)]public sealed class FshCollectionDefinition : ICollectionFixture<FshWebApplicationFactory>{ public const string Name = "FshIntegration";}Every test class joins the collection and takes the factory in its constructor:
[Collection(FshCollectionDefinition.Name)]public sealed class BrandsEndpointTests{ private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth;
public BrandsEndpointTests(FshWebApplicationFactory factory) { _factory = factory; _auth = new AuthHelper(factory); }
[Fact] public async Task CreateBrand_Should_Return200_And_Persist_When_AuthorizedAdmin() { using var client = await _auth.CreateRootAdminClientAsync(); var name = UniqueName("CreateOk");
var createResponse = await client.PostAsJsonAsync($"{TestConstants.CatalogBasePath}/brands", new { name, description = "Brand created by integration test", logoUrl = (string?)null, });
createResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
var brandId = await createResponse.DeserializeAsync<Guid>(); var getResponse = await client.GetAsync($"{TestConstants.CatalogBasePath}/brands/{brandId}"); var fetched = await getResponse.DeserializeAsync<BrandDto>(); fetched.Name.ShouldBe(name); }}On startup (IAsyncLifetime.InitializeAsync) the factory starts both containers in parallel, creates the MinIO bucket, then migrates the tenant catalog, seeds the root tenant, and runs every module’s IDbInitializer migrate + seed — including the production RolePermissionSyncer. A semaphore stops test classes from racing the migration.
Authenticated test clients
There are no hand-minted JWTs. AuthHelper logs in through the real token endpoint (POST /api/v1/identity/token/issue) as the seeded root admin (admin@root.com), so tokens go through the production auth pipeline:
| Helper | Purpose |
|---|---|
CreateRootAdminClientAsync() | HttpClient authenticated as the seeded root admin, tenant: root header set |
CreateAuthenticatedClientAsync(email, password, tenant) | Same, for any user/tenant your test has created |
GetTokenAsync(email, password, tenant) | Just the token, when you need to build the client yourself |
_factory.CreateClient() | Anonymous — for 401 tests; remember to add the tenant header |
Constants like TestConstants.RootAdminEmail, TestConstants.DefaultPassword, and the per-module base paths (TestConstants.CatalogBasePath etc.) live in Infrastructure/TestConstants.cs. Tests that need a non-root tenant or a low-privilege user create them through the API — there are no pre-seeded demo tenants in the test database.
What the factory swaps out
The factory overrides configuration and services so tests are deterministic:
- Hangfire runs on
Hangfire.InMemorywith a real server (1-second polling), so background jobs actually execute. - Mail is a
NoOpMailService— no SMTP, no retries. - Exceptions surface through a
DetailedTestExceptionHandlerinstead of the generic production error response, so failures tell you what broke. - Rate limiting and security headers are disabled (they get their own dedicated suite — see below).
- Storage is rewired to S3-against-MinIO after registration:
Two more harness gotchas
- SignalR tests force long polling.
TestServerhas no WebSocket support, so the chat tests configureHttpTransportType.LongPollingon their hub connections. A hub test that “hangs forever” is almost always a missing transport override. - Set the tenant context inline. When a test reaches past HTTP into a scoped service (
UserManager, a DbContext), it must set the Finbuckle tenant context viaIMultiTenantContextSetterin the same method as the call that needs it. The setter writes anAsyncLocal— set it inside an awaited helper and the value is gone when the helper returns, and the tenant query filter NREs.
The middleware suite
Integration.Middleware.Tests is a separate, smaller project that keeps the production middleware wiring: the real GlobalExceptionHandler (RFC 9457 responses), rate limiting with a tiny deterministic window, and security headers enabled. It lives in its own assembly because the module loader is static per process — a second differently-configured host can’t share a process with the main suite.
Test data discipline
- Don’t share state across tests. The whole suite shares one database, and a single collection means hundreds of tests touch the same rows over a run. Use unique names per test (
UniqueName("...")/ aGuidsuffix) so tests stay independent and re-runnable. - Don’t mutate seeded data. The root tenant and root admin are shared by every test. Create your own tenant/user/brand when you need to mutate one.
- 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 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 src/Tests/Integration.Tests/ --filter "FullyQualifiedName~BrandsEndpointTests" -
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~BrandsEndpointTests"
# The production-middleware suitedotnet test src/Tests/Integration.Middleware.Tests/
# Verbose outputdotnet test src/Tests/Integration.Tests/ --logger "console;verbosity=detailed"CI runs both integration projects on every backend push — see CI/CD.
Related
- Unit tests — for fast in-process tests.
- Architecture tests — for the structural rule families.
- Fixtures & seed data — how the sharing works in detail.