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.PerTestSession
for expensive resources like containers - Implement
IAsyncInitializer
for async initialization logic - Implement
IAsyncDisposable
for proper cleanup - Use
required
properties to ensure compile-time safety - Keep classes focused - one responsibility per class
- Use TUnit's orchestration - avoid manual dependency management
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.