Skip to content

feat: TUnit.Aspire integration package for Aspire distributed application testing #4768

@thomhurst

Description

@thomhurst

Problem

Setting up Aspire distributed application testing with TUnit requires significant boilerplate. Every Aspire test project needs to:

  1. Create a fixture that starts/stops the DistributedApplication
  2. Wait for all resources to reach Running state with proper timeouts
  3. Handle disposal and cleanup
  4. Provide CreateHttpClient() and GetConnectionStringAsync() helpers
  5. Wire up nested fixtures (database, Redis, RabbitMQ connections) that depend on the app fixture

This is ~50+ lines of boilerplate that every Aspire test project must replicate, and it's easy to get wrong (missing disposal, incorrect resource waiting, timeout handling, etc.).

// Current: manual boilerplate in every project
public class DistributedAppFixture : IAsyncInitializer, IAsyncDisposable
{
    private DistributedApplication? _app;

    public async Task InitializeAsync()
    {
        var builder = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.CloudShop_AppHost>();
        builder.Services.ConfigureHttpClientDefaults(http =>
            http.AddStandardResilienceHandler());
        _app = await builder.BuildAsync();
        await _app.StartAsync();

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
        await _app.ResourceNotifications
            .WaitForResourceAsync("postgres", KnownResourceStates.Running, cts.Token);
        await _app.ResourceNotifications
            .WaitForResourceAsync("redis", KnownResourceStates.Running, cts.Token);
        // ... repeat for every resource
    }

    public HttpClient CreateHttpClient(string name) => _app!.CreateHttpClient(name);

    public async ValueTask DisposeAsync()
    {
        if (_app is not null) { await _app.StopAsync(); await _app.DisposeAsync(); }
    }
}

Proposed Package: TUnit.Aspire

Core: AspireFixture<TAppHost>

A base class (similar to how TUnit.AspNetCore provides WebApplicationTest<TFactory, TEntryPoint>) that handles the Aspire app lifecycle:

public class AppFixture : AspireFixture<Projects.CloudShop_AppHost>
{
    // Override to customize the builder (optional)
    protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder)
    {
        builder.Services.ConfigureHttpClientDefaults(http =>
            http.AddStandardResilienceHandler());
    }

    // Override to specify resource wait timeout (optional, default 60s)
    protected override TimeSpan ResourceTimeout => TimeSpan.FromSeconds(120);

    // Override to customize which resources to wait for (optional, default: all)
    protected override ResourceWaitStrategy WaitStrategy => ResourceWaitStrategy.All;
}

AspireFixture<TAppHost> should provide:

  • Automatic lifecycle: IAsyncInitializer + IAsyncDisposable — build, start, stop, dispose
  • Resource waiting: Automatically wait for all (or specified) resources to reach Running state before tests begin
  • Configurable timeout: ResourceTimeout property with sensible default (60s)
  • CreateHttpClient(string resourceName): Create pre-configured HTTP clients for named resources
  • GetConnectionStringAsync(string resourceName): Get connection strings for infrastructure resources
  • GetResourceEndpoint(string resourceName): Get endpoint URIs for resources
  • Builder customization: ConfigureBuilder() virtual method for adding services, configuration, etc.
  • Designed for [ClassDataSource<T>(Shared = SharedType.PerTestSession)]: One app instance per test session

Resource Wait Strategies

public enum ResourceWaitStrategy
{
    All,       // Wait for all resources (default)
    None,      // Don't wait (manual control)
    Named      // Wait for specific resources only
}

// For Named strategy:
protected override IEnumerable<string> ResourcesToWaitFor()
{
    yield return "postgres";
    yield return "apiservice";
}

Test Isolation Helpers

Following TUnit.AspNetCore's UniqueId / GetIsolatedName() pattern:

// Inherited from AspireFixture<T>
public int UniqueId { get; }
protected string GetIsolatedName(string baseName) => $"Test_{UniqueId}_{baseName}";

Useful for creating isolated database names, Redis key prefixes, queue names, etc. in parallel test scenarios.

TUnit Logging Integration

// Aspire logs are automatically captured and forwarded to TUnit's test output
protected override LogLevel MinimumLogLevel => LogLevel.Information;

Similar to TUnit.AspNetCore's AddTUnitLogging(), but wired into Aspire's DistributedApplicationTestingBuilder.

Nested Fixture Support

Works naturally with TUnit's nested [ClassDataSource] pattern for infrastructure fixtures:

// Database fixture that depends on the Aspire app fixture
public class DatabaseFixture : IAsyncInitializer, IAsyncDisposable
{
    [ClassDataSource<AppFixture>(Shared = SharedType.PerTestSession)]
    public required AppFixture App { get; init; }

    public async Task InitializeAsync()
    {
        var connectionString = await App.GetConnectionStringAsync("postgres");
        // Set up NpgsqlDataSource, etc.
    }
}

Complete Example

// Fixture - minimal code thanks to TUnit.Aspire
public class AppFixture : AspireFixture<Projects.CloudShop_AppHost>
{
    protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder)
    {
        builder.Services.ConfigureHttpClientDefaults(http =>
            http.AddStandardResilienceHandler());
    }
}

// Test class - clean and focused
public class ProductTests
{
    [ClassDataSource<AppFixture>(Shared = SharedType.PerTestSession)]
    public required AppFixture App { get; init; }

    [Test]
    public async Task Can_Get_Products()
    {
        var client = App.CreateHttpClient("apiservice");
        var response = await client.GetAsync("/api/products");
        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }
}

Scope

In Scope

  • AspireFixture<TAppHost> base class with lifecycle management
  • Resource waiting with configurable strategies and timeouts
  • CreateHttpClient / GetConnectionStringAsync / GetResourceEndpoint helpers
  • Builder customization via virtual methods
  • Test isolation helpers (UniqueId, GetIsolatedName)
  • TUnit logging integration
  • Works with [ClassDataSource] and SharedType sharing patterns

Out of Scope (Future)

  • HTTP exchange capture middleware (could be added later, similar to TUnit.AspNetCore)
  • Aspire dashboard integration
  • Custom health check polling beyond resource state

Context

Built a full Aspire + TUnit example in #4761. The DistributedAppFixture was ~50 lines of boilerplate that every Aspire test project would need to replicate. Aspire is a growing part of the .NET ecosystem and first-class TUnit support would be a strong differentiator. The design follows the same patterns established by TUnit.AspNetCore (WebApplicationTest<TFactory, TEntryPoint>).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions