Skip to content
fullstackhero

Guide

Integration tests

WebApplicationFactory in-process API tests against Testcontainers Postgres + MinIO — one container set, all modules, real round trips.

views 0 Last updated

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

LayerLibraryPurpose
HostMicrosoft.AspNetCore.Mvc.TestingWebApplicationFactory<Program> for the in-process API
ContainersTestcontainers.PostgreSql / Testcontainers.Minio 4.11postgres:17-alpine + minio/minio:latest per test run
HTTPHttpClient from factory.CreateClient()Real HTTP calls through the full middleware pipeline
AssertionsShouldly, NSubstitute, AutoFixtureSame 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:

Infrastructure/FshCollectionDefinition.cs
[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:

HelperPurpose
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.InMemory with a real server (1-second polling), so background jobs actually execute.
  • Mail is a NoOpMailService — no SMTP, no retries.
  • Exceptions surface through a DetailedTestExceptionHandler instead 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. TestServer has no WebSocket support, so the chat tests configure HttpTransportType.LongPolling on 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 via IMultiTenantContextSetter in the same method as the call that needs it. The setter writes an AsyncLocal — 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("...") / a Guid suffix) 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 --filter to 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:

Terminal window
# Run the entire integration suite
dotnet test src/Tests/Integration.Tests/
# Single test class
dotnet test src/Tests/Integration.Tests/ \
--filter "FullyQualifiedName~BrandsEndpointTests"
# The production-middleware suite
dotnet test src/Tests/Integration.Middleware.Tests/
# Verbose output
dotnet test src/Tests/Integration.Tests/ --logger "console;verbosity=detailed"

CI runs both integration projects on every backend push — see CI/CD.