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.