Skip to content

Integration Testing

Deepstaging supports dual-mode testing: the same test suite runs as fast unit tests (with generated stubs) or as integration tests against real infrastructure. A single environment variable controls the switch.

TestMode

The TestMode class reads the INTEGRATION environment variable:

public static class TestMode
{
    public static bool IsIntegration =>
        string.Equals(
            Environment.GetEnvironmentVariable("INTEGRATION"),
            "true",
            StringComparison.OrdinalIgnoreCase);
}
# Unit tests — in-memory stubs, no external dependencies
dotnet test

# Integration tests — real services via Docker
INTEGRATION=true dotnet test

When to use each mode

  • Unit mode (default): fast feedback during development, CI pull request checks
  • Integration mode: pre-merge validation, nightly CI runs, infrastructure adapter verification

Architecture

Integration testing uses the generated OnConfigure/OnConfigureIntegration partial methods on your test runtime. You never check TestMode.IsIntegration in test code — CreateConfigured() handles the switching.

Test Runtime Pattern

Centralize all stub/infrastructure configuration in partial methods:

[TestRuntime<AppRuntime>]
public partial class TestAppRuntime
{
    // Unit mode — in-memory stubs
    partial void OnConfigure() =>
        WithArticleStore(new InMemoryArticleStore())
        .WithStubAuditStore(s => s)
        .WithStubFileStore(s => s)
        .WithStubSearchIndex(s => s);

    // Integration mode — override with real infrastructure
    partial void OnConfigureIntegration()
    {
        OnConfigure();
        IntegrationDefaults.Configure(this);
    }
}

IntegrationDefaults

IntegrationDefaults

Manage real infrastructure lifecycle in a static helper class:

// Integration mode — real infrastructure via Testcontainers
public static class IntegrationDefaults
{
    private static PostgreSqlContainer? _container;
    private static ServiceProvider? _provider;

    public static async Task StartAsync()
    {
        _container = new PostgreSqlBuilder("postgres:17-alpine")
            .WithDatabase("myapp-tests")
            .Build();
        await _container.StartAsync();

        var services = new ServiceCollection();
        services.AddMyStorePostgres(_container.GetConnectionString());
        _provider = services.BuildServiceProvider();

        using var scope = _provider.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<MyStoreDbContext>();
        await db.Database.EnsureCreatedAsync();
    }

    public static async Task StopAsync()
    {
        if (_provider is not null) await _provider.DisposeAsync();
        if (_container is not null) await _container.DisposeAsync();
    }

    public static void Configure(TestMyRuntime runtime)
    {
        var scope = _provider!.CreateScope();
        runtime.WithArticleStore(
            scope.ServiceProvider.GetRequiredService<IArticleStore>());
    }
}

Container Lifecycle

Start containers once per test run using TUnit's assembly hooks:

public static class IntegrationSetup
{
    [Before(Assembly)]
    public static async Task StartContainers()
    {
        if (!TestMode.IsIntegration) return;
        await IntegrationDefaults.StartAsync();
    }

    [After(Assembly)]
    public static async Task StopContainers()
    {
        await IntegrationDefaults.StopAsync();
    }
}

TestMode.IsIntegration short-circuits in unit mode — zero Docker overhead.

WebTestHost

WebTestHost<TApp> wraps WebApplicationFactory and handles mode switching for DI:

public class MyWebHost : WebTestHost<Program>
{
    protected override Dictionary<string, string?> Configuration => new()
    {
        ["ConnectionStrings:mydb"] = "Host=localhost;Database=test"
    };

    protected override void ConfigureServices(IServiceCollection services)
    {
        services.RemoveAll<IHostedService>();
        services.AddSingleton<IArticleStore, InMemoryArticleStore>();
    }

    protected override void ConfigureIntegrationServices(IServiceCollection services)
    {
        services.RemoveAll<IHostedService>();
        services.AddMyStorePostgres(IntegrationDefaults.ConnectionString);
    }
}

The host is shared per test session via TUnit's [ClassDataSource]:

[ClassDataSource<MyWebHost>(Shared = SharedType.PerTestSession)]
public required MyWebHost WebHost { get; init; }

WebTestHost automatically calls ConfigureServices or ConfigureIntegrationServices based on TestMode.IsIntegration. It also auto-wires JWT authentication so generated tokens are accepted by the test server.

WebAppTestBase

WebAppTestBase<TApp, TRuntime> provides three testing contexts in one base class:

Context API Use case
Effects CreateRuntime() Test effect compositions directly
Endpoints GetAsync(), PostAsync(), PutAsync(), DeleteAsync() Test HTTP endpoints
Event queues Queue(), .Clear(), .AssertSingle<T>() Test event-driven behavior
public abstract class AppTestBase : WebAppTestBase<Program, TestMyRuntime>
{
    [ClassDataSource<MyWebHost>(Shared = SharedType.PerTestSession)]
    public required MyWebHost WebHost { get; init; }

    protected override WebTestHost<Program> Host => WebHost;
}

WebAppTestBase.CreateRuntime() delegates to the test runtime's CreateConfigured(), which calls OnConfigure() or OnConfigureIntegration() based on TestMode.IsIntegration.

Authentication

// Authenticated (default) — requests include a valid Bearer token
var ctx = await PostAsync("/articles", new { Title = "Hello" });

// Switch to anonymous
AsAnonymous();
var ctx = await GetAsync("/articles");

// Switch to a specific identity
AsAuthenticated(userId: "user-42", email: "admin@example.com");
var ctx = await GetAsync("/admin/dashboard");

Testcontainers

For integration mode, use Testcontainers to spin up real services in Docker. The IntegrationDefaults class manages container lifecycle and wires real implementations.

Supported Infrastructure

Deepstaging provides infrastructure adapters for:

Package Provides Testcontainer
Deepstaging.Data.Postgres PgArticleStore<TContext>, MyStoreDbContext, AddMyStorePostgres() Testcontainers.PostgreSql
Deepstaging.Cloud.Azure.Storage BlobFileStore (implements IFileStore) Testcontainers.Azurite
Deepstaging.Search.Meilisearch MeilisearchIndex<T> (implements ISearchIndex<T>) Testcontainers (custom)

Multiple Containers

Start all containers in parallel for faster setup:

public static async Task StartAsync()
{
    _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build();
    _azurite = new AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite:latest")
        .WithInMemoryPersistence()
        .Build();
    _meilisearch = new ContainerBuilder("getmeili/meilisearch:v1.12")
        .WithPortBinding(7700, true)
        .WithEnvironment("MEILI_MASTER_KEY", "test-key")
        .WithWaitStrategy(Wait.ForUnixContainer()
            .UntilHttpRequestIsSucceeded(r => r.ForPort(7700).ForPath("/health")))
        .Build();

    await Task.WhenAll(
        _postgres.StartAsync(),
        _azurite.StartAsync(),
        _meilisearch.StartAsync());
}

Container Image Pinning

Always pin container images to specific versions:

// ✅ Deterministic builds
new PostgreSqlBuilder("postgres:17-alpine")

// ❌ May break unexpectedly
new PostgreSqlBuilder("postgres:latest")

CI/CD Configuration

GitHub Actions

Run unit tests on every PR, integration tests on merge to main:

name: Build & Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - run: dotnet build -c Release
      - run: dotnet test --project test/MyApp.Tests -c Release --no-build

  integration-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    env:
      INTEGRATION: 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - run: dotnet build -c Release
      - run: dotnet test --project test/MyApp.Tests -c Release --no-build

Docker required

Integration tests require Docker. GitHub Actions ubuntu-latest runners include Docker by default. Self-hosted runners must have Docker installed.

Running Locally

# Fast unit tests during development
dotnet test --project test/MyApp.Tests

# Full integration suite before pushing
INTEGRATION=true dotnet test --project test/MyApp.Tests

# Run a specific test class
dotnet test --project test/MyApp.Tests \
    --treenode-filter /*/*/OrderWorkflowTests/*

Best Practices

Keep Tests Mode-Agnostic

Tests should never check TestMode.IsIntegration. The OnConfigure/OnConfigureIntegration partial methods handle the switching:

// ✅ Mode-agnostic — works in both unit and integration mode
[Test]
public async Task CreateArticle_Succeeds()
{
    var runtime = TestAppRuntime.CreateConfigured();
    var fin = await AppDispatch.CreateArticle("Test", "Body", "me")
        .RunAsync(runtime);
    await Assert.That(fin).IsSucc();
}

// ❌ Don't do this — checking TestMode in tests
[Test]
public async Task CreateArticle_Succeeds()
{
    var store = TestMode.IsIntegration
        ? new PgArticleStore(db) : new InMemoryArticleStore();
    // ...
}

Start from Unit Defaults

Begin with everything stubbed in OnConfigure(). Override individual capabilities in OnConfigureIntegration():

partial void OnConfigure() =>
    WithArticleStore(new InMemoryArticleStore())
    .WithStubAuditStore(s => s)
    .WithStubSearchIndex(s => s);

partial void OnConfigureIntegration()
{
    OnConfigure();  // start from stubs
    IntegrationDefaults.Configure(this);  // override with real infra
}

Performance

Technique Impact
[Before(Assembly)] for container startup Containers start once per test run
SharedType.PerTestSession on WebHost One test server per run
WithStub*(s => s) for unused capabilities Zero overhead
Task.WhenAll() for container startup Parallel container initialization
Pin Alpine images Faster container pulls
In-memory persistence for Azurite Faster I/O
TUnit parallel execution Default on