Skip to main content

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");

await 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
}
}

How It Works

  1. TUnit detects properties marked with data source attributes (like [ClassDataSource<T>])
  2. It builds a dependency graph and initializes in the correct order
  3. Each object's InitializeAsync is called after its dependencies are ready
  4. Disposal happens in reverse order automatically

Best Practices

  1. Implement IAsyncInitializer: For any class that needs async initialization
  2. Use Data Source Attributes: Use attributes like [ClassDataSource<T>] to declare dependencies that must be initialized first
  3. Share Expensive Resources: Use SharedType attributes to avoid creating multiple containers
  4. Dispose Properly: Implement IAsyncDisposable for cleanup
  5. 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.