Nested Data Sources with Initialization
When writing integration tests, you often need complex test fixtures that depend on other initialized resources. TUnit's nested data source initialization feature makes this elegant and automatic.
The Problemâ
Traditional integration test setup often requires:
- Starting test containers (databases, message queues, etc.)
- Initializing WebApplicationFactory with custom services
- Ensuring proper initialization order
- Managing resource lifecycle
This typically leads to complex setup code with manual initialization chains.
The Solutionâ
TUnit automatically initializes nested data sources in the correct order using any data source attribute that implements IDataSourceAttribute (such as [ClassDataSource<T>]).
Basic Exampleâ
Here's a complete example of setting up integration tests with Redis and WebApplicationFactory:
using Testcontainers.Redis;
using TUnit.Core;
using Microsoft.AspNetCore.Mvc.Testing;
using StackExchange.Redis;
// 1. Define a test container that needs initialization
public class RedisTestContainer : IAsyncInitializer, IAsyncDisposable
{
private readonly RedisContainer _container;
public string ConnectionString => _container.GetConnectionString();
public RedisTestContainer()
{
_container = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
}
// 2. Create a test application that depends on Redis
public class TestApplication : IAsyncInitializer, IAsyncDisposable
{
private WebApplicationFactory<Program>? _factory;
// This property will be initialized BEFORE InitializeAsync is called
[ClassDataSource<RedisTestContainer>]
public required RedisTestContainer Redis { get; init; }
public HttpClient Client { get; private set; } = null!;
public async Task InitializeAsync()
{
// At this point, Redis is already started and ready!
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace production Redis with our test container
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(Redis.ConnectionString));
});
});
Client = _factory.CreateClient();
}
public async ValueTask DisposeAsync()
{
Client?.Dispose();
if (_factory != null) await _factory.DisposeAsync();
}
}
// 3. Use in tests - TUnit automatically handles nested initialization
public class UserApiTests
{
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.PerClass)]
public async Task CreateUser_Should_Cache_In_Redis(TestApplication app)
{
// Arrange
var user = new { Name = "John", Email = "john@example.com" };
// Act
var response = await app.Client.PostAsJsonAsync("/api/users", user);
// Assert
response.EnsureSuccessStatusCode();
// Verify the user was cached in Redis
var services = app.Client.Services;
var redis = services.GetRequiredService<IConnectionMultiplexer>();
var cached = await redis.GetDatabase().StringGetAsync("user:john@example.com");
Assert.That(cached.HasValue).IsTrue();
}
}
Multiple Dependenciesâ
You can have multiple nested dependencies, and TUnit will initialize them in the correct order:
public class CompleteTestEnvironment : IAsyncInitializer, IAsyncDisposable
{
private WebApplicationFactory<Program>? _factory;
// All of these will be initialized before InitializeAsync
[ClassDataSource<RedisTestContainer>]
public required RedisTestContainer Redis { get; init; }
[ClassDataSource<PostgresTestContainer>]
public required PostgresTestContainer Database { get; init; }
[ClassDataSource<LocalStackContainer>]
public required LocalStackContainer LocalStack { get; init; }
public HttpClient Client { get; private set; } = null!;
public async Task InitializeAsync()
{
// All containers are running at this point
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Wire up all test services
ConfigureRedis(services);
ConfigureDatabase(services);
ConfigureAwsServices(services);
});
});
Client = _factory.CreateClient();
// Run any post-initialization setup
await SeedTestData();
}
// ... configuration methods
}
Sharing Resourcesâ
Expensive resources like test containers should be shared across tests using the Shared parameter:
public class OrderApiTests
{
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.PerClass)]
public async Task Test1(TestApplication app)
{
// First test - creates new instance
}
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.PerClass)]
public async Task Test2(TestApplication app)
{
// Reuses the same instance as Test1
}
}
// Or share with a specific key for fine-grained control across multiple test classes
public class UserApiTests
{
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.Keyed, Key = "integration-tests")]
public async Task CreateUser(TestApplication app) { /* ... */ }
}
public class ProductApiTests
{
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.Keyed, Key = "integration-tests")]
public async Task CreateProduct(TestApplication app)
{
// Shares the same TestApplication instance with UserApiTests.CreateUser
}
}
Combining with Method Data Sourcesâ
You can combine nested data sources with parameterized tests using method data sources:
public class UserPermissionTests
{
[Test]
[ClassDataSource<TestApplication>(Shared = SharedType.PerClass)]
[MethodDataSource(nameof(UserScenarios))]
public async Task User_Should_Have_Correct_Permissions(
TestApplication app,
string userEmail,
string role,
string[] expectedPermissions)
{
// Create user through API
var createResponse = await app.Client.PostAsJsonAsync("/api/users",
new { Email = userEmail, Role = role });
createResponse.EnsureSuccessStatusCode();
// Verify permissions
var permissionsResponse = await app.Client.GetAsync($"/api/users/{userEmail}/permissions");
var permissions = await permissionsResponse.Content.ReadFromJsonAsync<string[]>();
await Assert.That(permissions).IsEquivalentTo(expectedPermissions);
}
private static IEnumerable<(string Email, string Role, string[] Permissions)> UserScenarios()
{
yield return ("admin@test.com", "Admin", new[] { "read", "write", "delete" });
yield return ("user@test.com", "User", new[] { "read" });
yield return ("guest@test.com", "Guest", Array.Empty<string>());
}
}
For more complex scenarios where you need to query the initialized application during data generation, you can access it through the test context metadata.
How It Worksâ
- TUnit detects properties marked with data source attributes (like
[ClassDataSource<T>]) - It builds a dependency graph and initializes in the correct order
- Each object's
InitializeAsyncis called after its dependencies are ready - Disposal happens in reverse order automatically
Best Practicesâ
- Implement IAsyncInitializer: For any class that needs async initialization
- Use Data Source Attributes: Use attributes like
[ClassDataSource<T>]to declare dependencies that must be initialized first - Share Expensive Resources: Use
SharedTypeattributes to avoid creating multiple containers - Dispose Properly: Implement
IAsyncDisposablefor cleanup - Keep Initialization Fast: Do only essential setup in
InitializeAsync
Common Patternsâ
Database Migrationsâ
public async Task InitializeAsync()
{
await _container.StartAsync();
// Run migrations after container starts
using var connection = new NpgsqlConnection(ConnectionString);
await connection.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
)");
}
Seeding Test Dataâ
public async Task InitializeAsync()
{
// ... create WebApplicationFactory
// Seed data after app starts
using var scope = _factory.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<ITestDataSeeder>();
await seeder.SeedAsync();
}
Health Checksâ
public async Task InitializeAsync()
{
// ... create client
// Wait for app to be healthy
var healthCheck = await Client.GetAsync("/health");
healthCheck.EnsureSuccessStatusCode();
}
Summaryâ
Nested data source initialization in TUnit:
- â Eliminates manual initialization chains
- â Ensures correct initialization order
- â Supports complex dependency graphs
- â Works seamlessly with async operations
- â Provides automatic cleanup
This makes integration testing with complex dependencies simple and maintainable.