Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e250148
feat: add TUnit.Aspire package for Aspire distributed app testing
thomhurst Feb 15, 2026
bf85a9c
refactor: update Aspire test template and CloudShop example to use TU…
thomhurst Feb 15, 2026
7169e61
fix: add fail-fast and timeout diagnostics to AspireFixture
thomhurst Feb 15, 2026
8103bc1
feat: surface resource logs in startup failure and timeout errors
thomhurst Feb 15, 2026
ba9bddc
fix: swap TUnit.Aspire PackageReference for ProjectReference in templ…
thomhurst Feb 15, 2026
5f93049
fix: update public API snapshots for TUnit.Aspire InternalsVisibleTo
thomhurst Feb 15, 2026
e738ef5
fix: pre-pull Docker images and increase timeouts for CloudShop CI
thomhurst Feb 15, 2026
fd4ef42
feat: add progress logging to AspireFixture initialization
thomhurst Feb 15, 2026
3719a15
fix: write progress logs directly to raw stderr stream
thomhurst Feb 15, 2026
555f777
fix: pre-pull exact Aspire 13.1.1 Docker image tags
thomhurst Feb 15, 2026
5c76833
fix: add timeout to StartAsync and set env var earlier
thomhurst Feb 15, 2026
c982bb5
fix: disable TLS on Redis container for CI compatibility
thomhurst Feb 15, 2026
4100309
feat: add real-time resource monitoring and log collection on startup…
thomhurst Feb 15, 2026
4995aeb
chore: remove Docker image pre-pull step from CloudShop CI
thomhurst Feb 15, 2026
e15a566
docs: add comprehensive TUnit.Aspire documentation
thomhurst Feb 15, 2026
ba8b18b
feat: make InitializeAsync and DisposeAsync virtual
thomhurst Feb 15, 2026
6b31ef8
fix: update Aspire Starter template snapshot files
thomhurst Feb 15, 2026
c893ee8
fix: update template snapshot verified files for TUnit.Aspire changes
thomhurst Feb 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add TUnit.Aspire package for Aspire distributed app testing
Adds AspireFixture<TAppHost> that manages the full Aspire app lifecycle
(build, start, wait-for-resources, stop, dispose), eliminating ~50 lines
of boilerplate per test project. Supports direct use via ClassDataSource
or subclassing with virtual configuration hooks.

Closes #4768

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
thomhurst and claude committed Feb 15, 2026
commit e250148d390e858c8b07b81d5b2cc0b79dd6aa52
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
</GlobalPackageReference>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.1.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
Expand Down
183 changes: 183 additions & 0 deletions TUnit.Aspire/AspireFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Testing;
using Microsoft.Extensions.DependencyInjection;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.Aspire;

/// <summary>
/// A fixture that manages the lifecycle of an Aspire distributed application for testing.
/// Implements <see cref="IAsyncInitializer"/> and <see cref="IAsyncDisposable"/> to integrate
/// with TUnit's lifecycle automatically.
/// </summary>
/// <typeparam name="TAppHost">The Aspire AppHost project type (e.g., <c>Projects.MyAppHost</c>).</typeparam>
/// <remarks>
/// <para>
/// Use directly with <c>[ClassDataSource&lt;AspireFixture&lt;Projects.MyAppHost&gt;&gt;(Shared = SharedType.PerTestSession)]</c>
/// or subclass to customize behavior via the virtual configuration hooks.
/// </para>
/// </remarks>
public class AspireFixture<TAppHost> : IAsyncInitializer, IAsyncDisposable
where TAppHost : class
{
private DistributedApplication? _app;

/// <summary>
/// The running Aspire distributed application.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if accessed before <see cref="InitializeAsync"/> completes.</exception>
public DistributedApplication App => _app ?? throw new InvalidOperationException(
"App not initialized. Ensure InitializeAsync has completed.");

/// <summary>
/// Creates an <see cref="HttpClient"/> for the named resource.
/// </summary>
/// <param name="resourceName">The name of the resource to connect to.</param>
/// <param name="endpointName">Optional endpoint name if the resource exposes multiple endpoints.</param>
/// <returns>An <see cref="HttpClient"/> configured to connect to the resource.</returns>
public HttpClient CreateHttpClient(string resourceName, string? endpointName = null)
=> App.CreateHttpClient(resourceName, endpointName);

/// <summary>
/// Gets the connection string for the named resource.
/// </summary>
/// <param name="resourceName">The name of the resource.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The connection string, or <c>null</c> if not available.</returns>
public async Task<string?> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default)
=> await App.GetConnectionStringAsync(resourceName, cancellationToken);

/// <summary>
/// Subscribes to logs from the named resource and routes them to the current test's output.
/// Dispose the returned value to stop watching.
/// </summary>
/// <param name="resourceName">The name of the resource to watch logs for.</param>
/// <returns>An <see cref="IAsyncDisposable"/> that stops the log subscription when disposed.</returns>
/// <exception cref="InvalidOperationException">Thrown if called outside of a test context.</exception>
public IAsyncDisposable WatchResourceLogs(string resourceName)
{
var testContext = TestContext.Current ?? throw new InvalidOperationException(
"WatchResourceLogs must be called from within a test.");

var cts = new CancellationTokenSource();
var loggerService = App.Services.GetRequiredService<ResourceLoggerService>();

_ = Task.Run(async () =>
{
try
{
await foreach (var batch in loggerService.WatchAsync(resourceName)
.WithCancellation(cts.Token))
{
foreach (var line in batch)
{
testContext.Output.WriteLine($"[{resourceName}] {line}");
}
}
}
catch (OperationCanceledException)
{
// Expected when the watcher is disposed
}
});

return new ResourceLogWatcher(cts);
}

// --- Configuration hooks (virtual) ---

/// <summary>
/// Override to customize the builder before building the application.
/// </summary>
/// <param name="builder">The distributed application testing builder.</param>
protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) { }

/// <summary>
/// Resource wait timeout. Default: 60 seconds.
/// </summary>
protected virtual TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60);

/// <summary>
/// Which resources to wait for. Default: <see cref="ResourceWaitBehavior.AllHealthy"/>.
/// </summary>
protected virtual ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.AllHealthy;

/// <summary>
/// Resources to wait for when <see cref="WaitBehavior"/> is <see cref="ResourceWaitBehavior.Named"/>.
/// </summary>
protected virtual IEnumerable<string> ResourcesToWaitFor() => [];

/// <summary>
/// Override for full control over the resource waiting logic.
/// </summary>
/// <param name="app">The running distributed application.</param>
/// <param name="cancellationToken">A cancellation token that will be cancelled after <see cref="ResourceTimeout"/>.</param>
protected virtual async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken)
{
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();

switch (WaitBehavior)
{
case ResourceWaitBehavior.AllHealthy:
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await Task.WhenAll(model.Resources.Select(r =>
notificationService.WaitForResourceHealthyAsync(r.Name, cancellationToken)));
break;

case ResourceWaitBehavior.AllRunning:
var runModel = app.Services.GetRequiredService<DistributedApplicationModel>();
await Task.WhenAll(runModel.Resources.Select(r =>
notificationService.WaitForResourceAsync(r.Name,
KnownResourceStates.Running, cancellationToken)));
break;

case ResourceWaitBehavior.Named:
await Task.WhenAll(ResourcesToWaitFor().Select(name =>
notificationService.WaitForResourceHealthyAsync(name, cancellationToken)));
break;

case ResourceWaitBehavior.None:
break;
}
}

// --- Lifecycle ---

/// <inheritdoc />
public async Task InitializeAsync()
{
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>();
ConfigureBuilder(builder);

_app = await builder.BuildAsync();
await _app.StartAsync();

using var cts = new CancellationTokenSource(ResourceTimeout);
await WaitForResourcesAsync(_app, cts.Token);
}

/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
_app = null;
}

GC.SuppressFinalize(this);
}

private sealed class ResourceLogWatcher(CancellationTokenSource cts) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
cts.Cancel();
cts.Dispose();
return ValueTask.CompletedTask;
}
}
}
27 changes: 27 additions & 0 deletions TUnit.Aspire/ResourceWaitBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace TUnit.Aspire;

/// <summary>
/// Specifies how <see cref="AspireFixture{TAppHost}"/> should wait for resources during initialization.
/// </summary>
public enum ResourceWaitBehavior
{
/// <summary>
/// Wait for all resources to pass health checks (default).
/// </summary>
AllHealthy,

/// <summary>
/// Wait for all resources to reach the Running state.
/// </summary>
AllRunning,

/// <summary>
/// Wait only for resources returned by <see cref="AspireFixture{TAppHost}.ResourcesToWaitFor"/>.
/// </summary>
Named,

/// <summary>
/// Don't wait for any resources - the user handles readiness manually.
/// </summary>
None
}
25 changes: 25 additions & 0 deletions TUnit.Aspire/TUnit.Aspire.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\Library.props" />

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\TUnit\TUnit.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TUnit.Analyzers\TUnit.Analyzers.csproj" OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\TUnit.Core.SourceGenerator\TUnit.Core.SourceGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<Import Project="..\Library.targets" />
</Project>
1 change: 1 addition & 0 deletions TUnit.Core/TUnit.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<InternalsVisibleTo Include="TUnit.UnitTests" />
<InternalsVisibleTo Include="TUnit.AspNetCore" />
<InternalsVisibleTo Include="TUnit.Logging.Microsoft" />
<InternalsVisibleTo Include="TUnit.Aspire" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions TUnit.Pipeline/Modules/GetPackageProjectsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class GetPackageProjectsModule : Module<List<File>>
Sourcy.DotNet.Projects.TUnit_Templates,
Sourcy.DotNet.Projects.TUnit_Logging_Microsoft,
Sourcy.DotNet.Projects.TUnit_AspNetCore,
Sourcy.DotNet.Projects.TUnit_Aspire,
Sourcy.DotNet.Projects.TUnit_FsCheck
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using TUnit.Aspire;

namespace ExampleNamespace.TestProject;

public class AppFixture : AspireFixture<Projects.ExampleNamespace_AppHost>
{
protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder)
{
builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
Expand All @@ -8,13 +8,10 @@
<IsTestProject>true</IsTestProject>
</PropertyGroup>


<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="13.1.1" />
<PackageReference Include="TUnit" Version="1.14.0" />
<PackageReference Include="TUnit.Aspire" Version="1.14.0" />
</ItemGroup>


<ItemGroup>
<ProjectReference Include="..\ExampleNamespace.AppHost\ExampleNamespace.AppHost.csproj" />
</ItemGroup>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
using System.Text.Json;
using ExampleNamespace.TestProject.Data;
using System.Text.Json;
using ExampleNamespace.TestProject.Models;

namespace ExampleNamespace.TestProject.Tests;

[ClassDataSource<HttpClientDataClass>]
public class ApiTests(HttpClientDataClass httpClientData)
[ClassDataSource<AppFixture>(Shared = SharedType.PerTestSession)]
public class ApiTests(AppFixture fixture)
{
[Test]
public async Task GetWeatherForecastReturnsOkStatusCode()
{
// Arrange
var httpClient = httpClientData.HttpClient;
var httpClient = fixture.CreateHttpClient("apiservice");
// Act
var response = await httpClient.GetAsync("/weatherforecast");
// Assert
Expand All @@ -25,7 +24,7 @@ public async Task GetWeatherForecastReturnsCorrectData(
)
{
// Arrange
var httpClient = httpClientData.HttpClient;
var httpClient = fixture.CreateHttpClient("apiservice");
// Act
var response = await httpClient.GetAsync("/weatherforecast");
var content = await response.Content.ReadAsStringAsync();
Expand Down
Loading
Loading