Documentation
adrs/062-testcontainers-integration-testing.md
title: "ADR-062: Testcontainers Integration Testing"
authors:
- Elvex
ADR-062: Testcontainers Integration Testing
Status: Accepted
Context
Dynaplex engines use PostgreSQL with features that cannot be faithfully simulated by in-memory providers or SQLite: schema-qualified tables, custom triggers, PL/pgSQL functions (e.g., journal audit triggers), uuidv7(), and Respawn-based cleanup across named schemas. Unit tests that mock the database layer give confidence in application logic but zero confidence in the data layer itself — and the data layer is where some of the most critical correctness guarantees live.
The Prism engine's journal system is a clear example: PostgreSQL triggers automatically maintain temporal audit trails (journal tables) whenever source rows are inserted, updated, or deleted. The triggers set seq_id, valid_from, valid_to, initiated_by_user_id, and invalidated_by_user_id columns based on session variables and database clock functions. None of this behavior exists in application code — it lives entirely in the database. If we only test application code, we are testing everything except the most safety-critical part.
We evaluated several approaches:
- EF Core In-Memory Provider: Fast but lacks support for schemas, triggers, raw SQL, transactions, and provider-specific features. Fundamentally unsuitable for testing database-native behavior.
- SQLite In-Memory: Better than InMemory but still lacks PostgreSQL-specific features (schemas, PL/pgSQL, session variables). Also introduces provider-behavioral divergence.
- Shared Development Database: Fragile. Tests become order-dependent, concurrent runs conflict, and CI environments need pre-provisioned infrastructure.
- Testcontainers: Spins up a real PostgreSQL instance per test fixture in a Docker container. Full behavioral fidelity, complete isolation, deterministic lifecycle, no shared state.
Decision
Adopt Testcontainers as the standard infrastructure for integration tests that need real database behavior. This decision has three pillars:
1. Testcontainers for Database Lifecycle
Each integration test project uses Testcontainers to spin up an ephemeral database container. The container is created once per test collection (via xUnit's IAsyncLifetime and ICollectionFixture<>), shared across tests in that collection, and destroyed when the collection completes.
Test Collection Start
→ Testcontainers starts PostgreSQL container
→ EF Core migrations applied
→ Reference data seeded
→ Tests execute (with Respawn resets between tests)
→ Container destroyed
Test Collection End
The container uses a lightweight Alpine-based image (postgres:16-alpine) and configures a test-specific database, username, and password. Connection strings are obtained dynamically from the container — no hardcoded ports.
2. Respawn for Test Isolation
Between tests, Respawn resets the database to a clean state by intelligently deleting data in foreign-key-safe order, then re-seeding reference data. This is dramatically faster than dropping and recreating the database or re-running migrations for each test.
Configuration targets specific schemas (e.g., prism, public) and uses WithReseed = true to reset identity sequences.
3. xUnit Collection Fixtures for Container Sharing
Tests that share a database container are grouped into a named xUnit collection (e.g., [Collection("PostgresDatabase")]). The collection fixture (ICollectionFixture<PostgresFixture>) ensures a single container instance serves all tests in the collection. This avoids the overhead of starting a new container per test class while maintaining isolation through Respawn resets.
Intra-project parallelism is disabled via xunit.runner.json for container-based test projects, since tests within a collection share a single database and must execute sequentially:
{
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}
4. Controlled Inter-Project Parallelism
When dotnet test runs the full solution, it launches all test assemblies concurrently by default. With 20+ test projects, this creates 20+ simultaneous attempts to initialize Docker containers, overwhelming the Docker socket and causing Testcontainers' Ryuk resource reaper to time out (ResourceReaperException: Initialization has been cancelled).
A solution-level .runsettings file caps inter-project parallelism at 4 concurrent assemblies:
<RunSettings>
<RunConfiguration>
<MaxCpuCount>4</MaxCpuCount>
</RunConfiguration>
</RunSettings>
This is wired into the build via Directory.Build.props so it applies automatically without --settings flags:
<RunSettingsFilePath>$(MSBuildThisFileDirectory)acsis-core.runsettings</RunSettingsFilePath>
The value of 4 is a pragmatic balance: fast enough for the majority of unit test assemblies (which have no container overhead), constrained enough to prevent Docker socket saturation.
5. Project Template
The cortex/project-templates/IntegrationTests/ template provides scaffolding for new integration test projects, including:
DatabaseFixturewith Testcontainers lifecycle and Respawn configurationApiWebApplicationFactoryfor testing HTTP endpoints against the containerized database- Proper xUnit collection definitions
New engines should use this template as their starting point and adapt the fixture for their specific database provider and schema requirements.
Consequences
Positive
- Full behavioral fidelity: Tests exercise the exact same database engine, triggers, functions, and constraints as production.
- Complete isolation: Each test run gets a fresh container. No shared state, no flaky order-dependent failures.
- CI-friendly: No pre-provisioned infrastructure required. Docker is the only prerequisite.
- Fast feedback: Container startup is ~2-3 seconds. Respawn resets are sub-millisecond. The overhead is negligible compared to the confidence gained.
- Discoverable pattern: The project template and collection fixture pattern make it straightforward to add integration tests to any engine.
- Safe parallel execution: The
.runsettingsthrottle prevents resource exhaustion during full-solution test runs without noticeably slowing individual projects.
Negative
- Docker dependency: Developers and CI agents must have Docker running. This is already a requirement for Aspire-based local development, so it adds no new dependency.
- Startup latency: The first test in a collection pays a ~2-3 second cost for container initialization. Subsequent tests in the same collection are near-instant.
- Image pulls: First-time runs download the PostgreSQL image. After caching, this is not a concern.
Neutral
- Unit test projects (those not using Testcontainers) are unaffected by the
.runsettingsparallelism cap — they still execute their internal tests in parallel per xUnit's default behavior. - The pattern is database-provider-agnostic. Testcontainers supports PostgreSQL, SQL Server, MySQL, MongoDB, and many others. The project template currently scaffolds SQL Server; the Prism JournalTests use PostgreSQL. Both follow the same structural pattern.
Key Files
| File | Purpose |
|---|---|
acsis-core.runsettings |
Solution-level inter-project parallelism cap |
Directory.Build.props |
Wires .runsettings automatically via RunSettingsFilePath |
engines/prism/tests/.../Fixtures/PostgresFixture.cs |
Reference implementation: PostgreSQL container + Respawn + collection fixture |
engines/prism/tests/.../JournalInsertTests.cs |
Reference implementation: tests using the fixture |
cortex/project-templates/IntegrationTests/ |
Scaffolding template for new integration test projects |
References
- Testcontainers for .NET
- Respawn
- xUnit Collection Fixtures
- ADR-035: Prism Journal Tables — the journal system that motivated the first Testcontainers adoption
- ADR-056: E2E Test Infrastructure Conventions — the complementary E2E testing strategy