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 |