Complex Test Infrastructure Orchestration
TUnit provides a property injection system that can help orchestrate complex test infrastructure setups. This page demonstrates how TUnit handles test setups that typically require manual coordination in traditional testing approaches.
Real-World Example: Full Stack Integration Testingâ
The TUnit.Example.Asp.Net.TestProject showcases how to spin up an entire test environment including Docker networks, Kafka, PostgreSQL, Redis, and even a Kafka UI - all with minimal code and automatic lifecycle management.
Property Injection Chainsâ
TUnit allows properties to be injected into other properties, creating dependency chains that are resolved and initialized in the correct order.
Example: Docker Network Orchestrationâ
// Step 1: Create a shared Docker network
public class DockerNetwork : IAsyncInitializer, IAsyncDisposable
{
public INetwork Instance { get; } = new NetworkBuilder()
.WithName($"tunit-{Guid.NewGuid():N}")
.Build();
public async Task InitializeAsync() => await Instance.CreateAsync();
public async ValueTask DisposeAsync() => await Instance.DisposeAsync();
}
Example: Kafka Container with Network Injectionâ
// Step 2: Kafka needs the Docker network
public class InMemoryKafka : IAsyncInitializer, IAsyncDisposable
{
// This property is automatically injected BEFORE InitializeAsync runs!
[ClassDataSource<DockerNetwork>(Shared = SharedType.PerTestSession)]
public required DockerNetwork DockerNetwork { get; init; }
public KafkaContainer Container => field ??= new KafkaBuilder()
.WithNetwork(DockerNetwork.Instance) // Uses the injected network
.Build();
public async Task InitializeAsync() => await Container.StartAsync();
public async ValueTask DisposeAsync() => await Container.DisposeAsync();
}
Example: Kafka UI Depending on Kafka Containerâ
// Step 3: Kafka UI needs both the network AND the Kafka container
public class KafkaUI : IAsyncInitializer, IAsyncDisposable
{
// Both dependencies are injected and initialized automatically!
[ClassDataSource<DockerNetwork>(Shared = SharedType.PerTestSession)]
public required DockerNetwork DockerNetwork { get; init; }
[ClassDataSource<InMemoryKafka>(Shared = SharedType.PerTestSession)]
public required InMemoryKafka Kafka { get; init; }
public IContainer Container => field ??= new ContainerBuilder()
.WithNetwork(DockerNetwork.Instance)
.WithImage("provectuslabs/kafka-ui:latest")
.WithPortBinding(8080, 8080)
.WithEnvironment(new Dictionary<string, string>
{
// Can reference the Kafka container that was injected!
["KAFKA_CLUSTERS_0_NAME"] = "tunit_tests",
["KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS"] = $"{Kafka.Container.Name}:9093",
})
.Build();
public async Task InitializeAsync() => await Container.StartAsync();
public async ValueTask DisposeAsync() => await Container.DisposeAsync();
}
Complete Integration: Web Application with Multiple Dependenciesâ
Here's how everything comes together in a WebApplicationFactory that needs multiple infrastructure components:
public class WebApplicationFactory : WebApplicationFactory<Program>, IAsyncInitializer
{
// All these dependencies are automatically initialized in dependency order!
[ClassDataSource<InMemoryKafka>(Shared = SharedType.PerTestSession)]
public required InMemoryKafka Kafka { get; init; }
[ClassDataSource<KafkaUI>(Shared = SharedType.PerTestSession)]
public required KafkaUI KafkaUI { get; init; }
[ClassDataSource<InMemoryRedis>(Shared = SharedType.PerTestSession)]
public required InMemoryRedis Redis { get; init; }
[ClassDataSource<InMemoryPostgreSqlDatabase>(Shared = SharedType.PerTestSession)]
public required InMemoryPostgreSqlDatabase PostgreSql { get; init; }
public Task InitializeAsync()
{
_ = Server; // Force initialization
return Task.CompletedTask;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configBuilder) =>
{
// All containers are already running when this executes!
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Redis:ConnectionString", Redis.Container.GetConnectionString() },
{ "PostgreSql:ConnectionString", PostgreSql.Container.GetConnectionString() },
{ "Kafka:ConnectionString", Kafka.Container.GetBootstrapAddress() },
});
});
}
}
Writing Clean Testsâ
Your actual test code remains clean and focused:
public class Tests : TestsBase
{
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }
[Test]
public async Task Test()
{
// Everything is already initialized and running!
var client = WebApplicationFactory.CreateClient();
var response = await client.GetAsync("/ping");
var content = await response.Content.ReadAsStringAsync();
await Assert.That(content).IsEqualTo("Hello, World!");
}
}
Key Benefitsâ
1. Automatic Dependency Resolutionâ
TUnit determines the initialization order:
- Docker Network â Kafka Container â Kafka UI
- Docker Network â PostgreSQL Container
- Docker Network â Redis Container
- All containers â WebApplicationFactory
2. Reduced Boilerplateâ
Traditional approaches often require:
- Manual initialization order management
- Complex setup/teardown methods
- Careful coordination of shared resources
- Manual dependency injection wiring
3. Resource Sharingâ
Using SharedType.PerTestSession helps:
- Expensive resources (containers) are created once
- They're shared across all tests in the session
- Automatic cleanup when tests complete
- No resource leaks or orphaned containers
4. Clean Separation of Concernsâ
Each class has a single responsibility:
DockerNetwork- manages the networkInMemoryKafka- manages Kafka containerKafkaUI- manages the UI containerWebApplicationFactory- orchestrates the web app
Advanced Scenariosâ
Database Migrationsâ
public class InMemoryPostgreSqlDatabase : IAsyncInitializer, IAsyncDisposable
{
[ClassDataSource<DockerNetwork>(Shared = SharedType.PerTestSession)]
public required DockerNetwork DockerNetwork { get; init; }
public PostgreSqlContainer Container => field ??= new PostgreSqlBuilder()
.WithUsername("User")
.WithPassword("Password")
.WithDatabase("TestDatabase")
.WithNetwork(DockerNetwork.Instance)
.Build();
public async Task InitializeAsync()
{
await Container.StartAsync();
// Run migrations after container starts
using var connection = new NpgsqlConnection(Container.GetConnectionString());
await connection.OpenAsync();
// Run your migration logic here
}
public async ValueTask DisposeAsync() => await Container.DisposeAsync();
}
Comparison with Other Frameworksâ
Without TUnit (Traditional Approach)â
public class TestFixture : IAsyncLifetime
{
private INetwork? _network;
private KafkaContainer? _kafka;
private IContainer? _kafkaUi;
public async Task InitializeAsync()
{
// Manual orchestration required
_network = new NetworkBuilder().Build();
await _network.CreateAsync();
_kafka = new KafkaBuilder()
.WithNetwork(_network)
.Build();
await _kafka.StartAsync();
_kafkaUi = new ContainerBuilder()
.WithNetwork(_network)
.WithEnvironment("KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS",
$"{_kafka.Name}:9093") // Manual wiring
.Build();
await _kafkaUi.StartAsync();
}
public async Task DisposeAsync()
{
// Manual cleanup in reverse order
if (_kafkaUi != null) await _kafkaUi.DisposeAsync();
if (_kafka != null) await _kafka.DisposeAsync();
if (_network != null) await _network.DisposeAsync();
}
}
With TUnitâ
Declare your dependencies with attributes and TUnit manages the orchestration.
Best Practicesâ
- Use
SharedType.PerTestSessionfor expensive resources like containers - Implement
IAsyncInitializerfor async initialization logic - Implement
IAsyncDisposablefor proper cleanup - Use
requiredproperties to ensure compile-time safety - Keep classes focused - one responsibility per class
- Use TUnit's orchestration - avoid manual dependency management
Multiple Test Projects and SharedType.PerTestSessionâ
In larger solutions, it is often beneficial to structure tests into different test projects, sometimes alongside a common test library for shared common code like infrastructure orchestration. Test runners like dotnet test, typically launch separate .NET processes for each test project. And because each test project runs as its own process, they cant share the dependencies.
This means that classes configured with a SharedType.PerTestSession lifetime will be initialized once per test project, rather than once for the entire test session.
If you intend for services or data to be shared across those separate test projects, you will need to consolidate the execution using a Test Orchestrator approach to load all projects into a single process and run dotnet test directly on that.
Summaryâ
TUnit's property injection system helps simplify complex test infrastructure setup through a declarative, type-safe approach. By handling initialization order, lifecycle management, and dependency injection, TUnit allows you to focus on writing tests that validate your application's behavior.
The framework manages the orchestration that would otherwise require manual coordination, helping to create cleaner, more maintainable test code with less boilerplate.