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.PostgreSql and Respawn NuGet packages (managed centrally in Directory.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-alpine for 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 NpgsqlConnection directly 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:

  1. Verify .runsettings is being picked up: dotnet test --list-settings
  2. Check Docker is responsive: docker info
  3. Check for zombie containers: docker ps -a | grep testcontainers

Container startup timeout

If the container takes too long to start, check:

  1. Docker Desktop resource allocation (CPU/memory)
  2. Whether the image is cached: docker images | grep postgres
  3. 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