Documentation
how-to/write-integration-tests.md
Writing Integration Tests with Testcontainers
This guide covers how to write integration tests that exercise real database behavior using Testcontainers, Respawn, and xUnit collection fixtures. It walks through the pattern established in the Prism JournalTests and codified in the project template at cortex/project-templates/IntegrationTests/.
For architectural context and the reasoning behind these decisions, see ADR-062: Testcontainers Integration Testing.
Why Testcontainers?
Some behavior only exists in the database. Triggers, stored procedures, constraint enforcement, schema-qualified foreign keys, session variables — none of these are exercised by EF Core's in-memory provider or SQLite. If the correctness guarantee lives in PostgreSQL, the test must run against PostgreSQL.
Testcontainers solves this by spinning up a real database instance in a Docker container for the duration of your test run. No shared state, no pre-provisioned infrastructure, no flaky ordering issues.
Prerequisites
- Docker must be running (already required for Aspire local development)
- The
Testcontainers.PostgreSqlandRespawnNuGet packages (managed centrally inDirectory.Packages.props)
Project Structure
Integration test projects live alongside unit tests in the engine's tests/ directory:
engines/your-engine/
├── src/
│ ├── Acsis.Dynaplex.Engines.YourEngine/
│ └── Acsis.Dynaplex.Engines.YourEngine.Database/
└── tests/
├── Acsis.Dynaplex.Engines.YourEngine.UnitTests/ # Mocks, no containers
└── Acsis.Dynaplex.Engines.YourEngine.IntegrationTests/ # Testcontainers
├── Fixtures/
│ └── PostgresFixture.cs
├── xunit.runner.json
└── YourFeatureTests.cs
Step 1: Create the Test Project
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Integration Testing -->
<PackageReference Include="Shouldly" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Respawn" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Acsis.Dynaplex.Engines.YourEngine.Database\Acsis.Dynaplex.Engines.YourEngine.Database.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Step 2: Disable Intra-Project Parallelism
Create xunit.runner.json in the test project root. Since all tests in a collection share a single database container, they must run sequentially:
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}
This only affects parallelism within this test assembly. Inter-project parallelism is controlled by the solution-level acsis-core.runsettings file.
Step 3: Write the Database Fixture
The fixture is the heart of the pattern. It manages the container lifecycle and provides helpers for tests.
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Respawn;
using Testcontainers.PostgreSql;
namespace Acsis.Dynaplex.Engines.YourEngine.IntegrationTests.Fixtures;
public class PostgresFixture : IAsyncLifetime
{
private PostgreSqlContainer _container = null!;
private Respawner _respawner = null!;
public string ConnectionString { get; private set; } = null!;
public async Task InitializeAsync()
{
// Start a real PostgreSQL container
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("yourengine_test")
.WithUsername("test")
.WithPassword("test")
.Build();
await _container.StartAsync();
ConnectionString = _container.GetConnectionString();
// If your engine depends on schemas/tables from other engines,
// create stubs here before running migrations.
// Example: the Prism JournalTests stub the core.identifier_types table
// because PrismDb has a foreign key to it.
// Apply EF Core migrations
await using var context = CreateDbContext();
await context.Database.MigrateAsync();
// Seed any reference data your tests depend on
// (platform types, identifier types, etc.)
// Initialize Respawn for between-test cleanup
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
_respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = ["yourschema", "public"],
WithReseed = true
});
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
public YourEngineDb CreateDbContext()
{
var options = new DbContextOptionsBuilder<YourEngineDb>()
.UseNpgsql(ConnectionString)
.Options;
return new YourEngineDb(options);
}
public async Task ResetDatabaseAsync()
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
await _respawner.ResetAsync(connection);
// Re-seed reference data that Respawn deleted.
// This is necessary because Respawn clears all data from the
// specified schemas, including reference data that tests depend on.
}
}
[CollectionDefinition("PostgresDatabase")]
public class PostgresDatabaseCollection : ICollectionFixture<PostgresFixture>
{
}
Key points
- Container image: Use
postgres:16-alpinefor fast startup (~2-3 seconds). Match the major version to what you run in production. - Stub dependencies: If your engine's migrations reference tables from other engines (cross-schema foreign keys), create minimal stubs before applying migrations. Don't pull in the entire dependency chain — just the tables and columns your FKs point to.
- Respawn schemas: List only the schemas your engine owns. Don't include schemas from stubs unless your tests insert data there.
- Re-seed after reset: Respawn deletes everything. If tests assume reference data exists, re-seed it in
ResetDatabaseAsync().
Step 4: Write Tests
Tests use the [Collection] attribute to share the fixture:
using Shouldly;
namespace Acsis.Dynaplex.Engines.YourEngine.IntegrationTests;
[Collection("PostgresDatabase")]
public class YourFeatureTests
{
private readonly PostgresFixture _fixture;
public YourFeatureTests(PostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateEntity_PersistsToDatabase()
{
// Arrange — clean slate
await _fixture.ResetDatabaseAsync();
// Act — use the real DbContext against the real database
await using var context = _fixture.CreateDbContext();
context.YourEntities.Add(new YourEntity { Id = Guid.NewGuid(), Name = "Test" });
await context.SaveChangesAsync();
// Assert — query from a fresh context to avoid EF cache
await using var verifyContext = _fixture.CreateDbContext();
var entity = await verifyContext.YourEntities.FirstOrDefaultAsync();
entity.ShouldNotBeNull();
entity.Name.ShouldBe("Test");
}
}
Test patterns
- Always call
ResetDatabaseAsync()at the start of each test (not in a constructor or setup method). This makes each test self-contained and independent of execution order. - Use separate DbContext instances for writes and reads. EF Core's change tracker will return cached entities from the same context, which can mask persistence bugs.
- Use raw SQL for trigger/function tests. If you're testing database-native behavior (triggers, functions, session variables), use
NpgsqlConnectiondirectly rather than EF Core. EF Core abstracts away the very behavior you're trying to verify.
When to Use Testcontainers vs. Unit Tests
| Scenario | Approach |
|---|---|
| Service/business logic | Unit tests with mocked repositories |
| Validation rules | Unit tests |
| API endpoint routing | Unit tests with WebApplicationFactory |
| EF Core queries (LINQ → SQL correctness) | Testcontainers |
| Database triggers and functions | Testcontainers |
| Migration correctness | Testcontainers |
| Cross-schema foreign key integrity | Testcontainers |
| Session variable behavior | Testcontainers |
| Respawn/cleanup strategy validation | Testcontainers |
The rule of thumb: if the behavior you're testing lives in application code, unit test it. If it lives in the database, use Testcontainers.
Troubleshooting
ResourceReaperException during full-solution test runs
This happens when too many test assemblies start simultaneously. The solution-level acsis-core.runsettings file limits inter-project parallelism to 4 concurrent assemblies. If you still see this:
- Verify
.runsettingsis being picked up:dotnet test --list-settings - Check Docker is responsive:
docker info - Check for zombie containers:
docker ps -a | grep testcontainers
Container startup timeout
If the container takes too long to start, check:
- Docker Desktop resource allocation (CPU/memory)
- Whether the image is cached:
docker images | grep postgres - Network connectivity for first-time image pulls
Migration failures in the container
If MigrateAsync() fails, it's usually because of cross-schema dependencies. Create stubs for any tables your migrations reference from other engines before applying migrations.
References
- Testcontainers for .NET documentation
- Respawn GitHub
- xUnit shared context documentation
- ADR-062: Testcontainers Integration Testing
- ADR-035: Prism Journal Tables
- Reference implementation:
engines/prism/tests/Acsis.Dynaplex.Engines.Prism.JournalTests/ - Project template:
cortex/project-templates/IntegrationTests/