From e250148d390e858c8b07b81d5b2cc0b79dd6aa52 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:35:59 +0000 Subject: [PATCH 01/18] feat: add TUnit.Aspire package for Aspire distributed app testing Adds AspireFixture 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 --- Directory.Packages.props | 1 + TUnit.Aspire/AspireFixture.cs | 183 ++++++++++++++++++ TUnit.Aspire/ResourceWaitBehavior.cs | 27 +++ TUnit.Aspire/TUnit.Aspire.csproj | 25 +++ TUnit.Core/TUnit.Core.csproj | 1 + .../Modules/GetPackageProjectsModule.cs | 1 + .../AppFixture.cs | 14 ++ .../Data/HttpClientDataClass.cs | 22 --- .../ExampleNamespace.TestProject.csproj | 7 +- .../GlobalSetup.cs | 37 ---- .../Tests/ApiTests.cs | 11 +- TUnit.sln | 14 ++ 12 files changed, 273 insertions(+), 70 deletions(-) create mode 100644 TUnit.Aspire/AspireFixture.cs create mode 100644 TUnit.Aspire/ResourceWaitBehavior.cs create mode 100644 TUnit.Aspire/TUnit.Aspire.csproj create mode 100644 TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/AppFixture.cs delete mode 100644 TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Data/HttpClientDataClass.cs delete mode 100644 TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/GlobalSetup.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d4b3102c69..4aab58c57e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs new file mode 100644 index 0000000000..ab0280dd45 --- /dev/null +++ b/TUnit.Aspire/AspireFixture.cs @@ -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; + +/// +/// A fixture that manages the lifecycle of an Aspire distributed application for testing. +/// Implements and to integrate +/// with TUnit's lifecycle automatically. +/// +/// The Aspire AppHost project type (e.g., Projects.MyAppHost). +/// +/// +/// Use directly with [ClassDataSource<AspireFixture<Projects.MyAppHost>>(Shared = SharedType.PerTestSession)] +/// or subclass to customize behavior via the virtual configuration hooks. +/// +/// +public class AspireFixture : IAsyncInitializer, IAsyncDisposable + where TAppHost : class +{ + private DistributedApplication? _app; + + /// + /// The running Aspire distributed application. + /// + /// Thrown if accessed before completes. + public DistributedApplication App => _app ?? throw new InvalidOperationException( + "App not initialized. Ensure InitializeAsync has completed."); + + /// + /// Creates an for the named resource. + /// + /// The name of the resource to connect to. + /// Optional endpoint name if the resource exposes multiple endpoints. + /// An configured to connect to the resource. + public HttpClient CreateHttpClient(string resourceName, string? endpointName = null) + => App.CreateHttpClient(resourceName, endpointName); + + /// + /// Gets the connection string for the named resource. + /// + /// The name of the resource. + /// A cancellation token. + /// The connection string, or null if not available. + public async Task GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default) + => await App.GetConnectionStringAsync(resourceName, cancellationToken); + + /// + /// Subscribes to logs from the named resource and routes them to the current test's output. + /// Dispose the returned value to stop watching. + /// + /// The name of the resource to watch logs for. + /// An that stops the log subscription when disposed. + /// Thrown if called outside of a test context. + 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(); + + _ = 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) --- + + /// + /// Override to customize the builder before building the application. + /// + /// The distributed application testing builder. + protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) { } + + /// + /// Resource wait timeout. Default: 60 seconds. + /// + protected virtual TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60); + + /// + /// Which resources to wait for. Default: . + /// + protected virtual ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.AllHealthy; + + /// + /// Resources to wait for when is . + /// + protected virtual IEnumerable ResourcesToWaitFor() => []; + + /// + /// Override for full control over the resource waiting logic. + /// + /// The running distributed application. + /// A cancellation token that will be cancelled after . + protected virtual async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken) + { + var notificationService = app.Services.GetRequiredService(); + + switch (WaitBehavior) + { + case ResourceWaitBehavior.AllHealthy: + var model = app.Services.GetRequiredService(); + await Task.WhenAll(model.Resources.Select(r => + notificationService.WaitForResourceHealthyAsync(r.Name, cancellationToken))); + break; + + case ResourceWaitBehavior.AllRunning: + var runModel = app.Services.GetRequiredService(); + 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 --- + + /// + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + ConfigureBuilder(builder); + + _app = await builder.BuildAsync(); + await _app.StartAsync(); + + using var cts = new CancellationTokenSource(ResourceTimeout); + await WaitForResourcesAsync(_app, cts.Token); + } + + /// + 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; + } + } +} diff --git a/TUnit.Aspire/ResourceWaitBehavior.cs b/TUnit.Aspire/ResourceWaitBehavior.cs new file mode 100644 index 0000000000..29bdf9635c --- /dev/null +++ b/TUnit.Aspire/ResourceWaitBehavior.cs @@ -0,0 +1,27 @@ +namespace TUnit.Aspire; + +/// +/// Specifies how should wait for resources during initialization. +/// +public enum ResourceWaitBehavior +{ + /// + /// Wait for all resources to pass health checks (default). + /// + AllHealthy, + + /// + /// Wait for all resources to reach the Running state. + /// + AllRunning, + + /// + /// Wait only for resources returned by . + /// + Named, + + /// + /// Don't wait for any resources - the user handles readiness manually. + /// + None +} diff --git a/TUnit.Aspire/TUnit.Aspire.csproj b/TUnit.Aspire/TUnit.Aspire.csproj new file mode 100644 index 0000000000..06e727b61f --- /dev/null +++ b/TUnit.Aspire/TUnit.Aspire.csproj @@ -0,0 +1,25 @@ + + + + + + net8.0;net9.0;net10.0 + + + + + + + + + + + + + + + + + diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 56920ff81f..6b204c39aa 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs index 6a3f4ce7f3..65733759de 100644 --- a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs +++ b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs @@ -21,6 +21,7 @@ public class GetPackageProjectsModule : Module> 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 ]; } diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/AppFixture.cs b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/AppFixture.cs new file mode 100644 index 0000000000..da4cb50b7e --- /dev/null +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/AppFixture.cs @@ -0,0 +1,14 @@ +using TUnit.Aspire; + +namespace ExampleNamespace.TestProject; + +public class AppFixture : AspireFixture +{ + protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) + { + builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + } +} diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Data/HttpClientDataClass.cs b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Data/HttpClientDataClass.cs deleted file mode 100644 index 4d66d0c343..0000000000 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Data/HttpClientDataClass.cs +++ /dev/null @@ -1,22 +0,0 @@ -using TUnit.Core.Interfaces; - -namespace ExampleNamespace.TestProject.Data -{ - public class HttpClientDataClass : IAsyncInitializer, IAsyncDisposable - { - public HttpClient HttpClient { get; private set; } = new(); - public async Task InitializeAsync() - { - HttpClient = (GlobalSetup.App ?? throw new NullReferenceException()).CreateHttpClient("apiservice"); - if (GlobalSetup.NotificationService != null) - { - await GlobalSetup.NotificationService.WaitForResourceAsync("apiservice", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - } - } - - public async ValueTask DisposeAsync() - { - await Console.Out.WriteLineAsync("And when the class is finished with, we can clean up any resources."); - } - } -} diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index d80d9600cd..2093fc04d7 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,13 +8,10 @@ true - - - + - diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/GlobalSetup.cs b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/GlobalSetup.cs deleted file mode 100644 index 0afa19610d..0000000000 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/GlobalSetup.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Here you could define global logic that would affect all tests - -// You can use attributes at the assembly level to apply to all tests in the assembly - -using Aspire.Hosting; - -[assembly: Retry(3)] -[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - -namespace ExampleNamespace.TestProject; - -public class GlobalSetup -{ - public static DistributedApplication? App { get; private set; } - public static ResourceNotificationService? NotificationService { get; private set; } - - [Before(TestSession)] - public static async Task SetUp() - { - // Arrange - var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); - appHost.Services.ConfigureHttpClientDefaults(clientBuilder => - { - clientBuilder.AddStandardResilienceHandler(); - }); - - App = await appHost.BuildAsync(); - NotificationService = App.Services.GetRequiredService(); - await App.StartAsync(); - } - - [After(TestSession)] - public static void CleanUp() - { - Console.WriteLine("...and after!"); - } -} diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Tests/ApiTests.cs b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Tests/ApiTests.cs index 81354b9c1a..e051a9f7c2 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Tests/ApiTests.cs +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/Tests/ApiTests.cs @@ -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] -public class ApiTests(HttpClientDataClass httpClientData) +[ClassDataSource(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 @@ -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(); diff --git a/TUnit.sln b/TUnit.sln index 3ff1056ec5..b0f7659a68 100644 --- a/TUnit.sln +++ b/TUnit.sln @@ -177,6 +177,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Tests", "examples EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Logging.Microsoft", "TUnit.Logging.Microsoft\TUnit.Logging.Microsoft.csproj", "{A8CE013A-6B96-4C4C-84DD-845AD8674AA9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Aspire", "TUnit.Aspire\TUnit.Aspire.csproj", "{84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1039,6 +1041,18 @@ Global {A8CE013A-6B96-4C4C-84DD-845AD8674AA9}.Release|x64.Build.0 = Release|Any CPU {A8CE013A-6B96-4C4C-84DD-845AD8674AA9}.Release|x86.ActiveCfg = Release|Any CPU {A8CE013A-6B96-4C4C-84DD-845AD8674AA9}.Release|x86.Build.0 = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|x64.ActiveCfg = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|x64.Build.0 = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|x86.ActiveCfg = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Debug|x86.Build.0 = Debug|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|Any CPU.Build.0 = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|x64.ActiveCfg = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|x64.Build.0 = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|x86.ActiveCfg = Release|Any CPU + {84BA7D6C-5B4D-493A-9A76-BD4583FD1E23}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From bf85a9c2b0f4472cd29e461e63473260df3c76c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:39:25 +0000 Subject: [PATCH 02/18] refactor: update Aspire test template and CloudShop example to use TUnit.Aspire Update TUnit.Aspire.Test template to reference TUnit.Aspire instead of raw Aspire.Hosting.Testing boilerplate. Update CloudShop example's DistributedAppFixture to subclass AspireFixture, demonstrating ConfigureBuilder, ResourceTimeout, and WaitForResourcesAsync overrides. Co-Authored-By: Claude Opus 4.6 --- .../Data/HttpClientDataClass.cs | 22 ------- .../TUnit.Aspire.Test/ExampleNamespace.csproj | 5 +- .../content/TUnit.Aspire.Test/GlobalSetup.cs | 38 ----------- .../TUnit.Aspire.Test/IntegrationTest1.cs | 65 ++++++++++++------- .../CloudShop.Tests/CloudShop.Tests.csproj | 2 +- .../Infrastructure/DistributedAppFixture.cs | 42 ++++-------- 6 files changed, 56 insertions(+), 118 deletions(-) delete mode 100644 TUnit.Templates/content/TUnit.Aspire.Test/Data/HttpClientDataClass.cs delete mode 100644 TUnit.Templates/content/TUnit.Aspire.Test/GlobalSetup.cs diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/Data/HttpClientDataClass.cs b/TUnit.Templates/content/TUnit.Aspire.Test/Data/HttpClientDataClass.cs deleted file mode 100644 index 3ebd972c46..0000000000 --- a/TUnit.Templates/content/TUnit.Aspire.Test/Data/HttpClientDataClass.cs +++ /dev/null @@ -1,22 +0,0 @@ -using TUnit.Core.Interfaces; - -namespace ExampleNamespace.Data -{ - public class HttpClientDataClass : IAsyncInitializer, IAsyncDisposable - { - public HttpClient HttpClient { get; private set; } = new(); - public async Task InitializeAsync() - { - HttpClient = (GlobalHooks.App ?? throw new NullReferenceException()).CreateHttpClient("webfrontend"); - if (GlobalHooks.NotificationService != null) - { - await GlobalHooks.NotificationService.WaitForResourceAsync("webfrontend", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - } - } - - public async ValueTask DisposeAsync() - { - await Console.Out.WriteLineAsync("And when the class is finished with, we can clean up any resources."); - } - } -} diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index c11674a380..62fe16065e 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,8 +9,7 @@ - - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/GlobalSetup.cs b/TUnit.Templates/content/TUnit.Aspire.Test/GlobalSetup.cs deleted file mode 100644 index a0ad17ea62..0000000000 --- a/TUnit.Templates/content/TUnit.Aspire.Test/GlobalSetup.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Here you could define global logic that would affect all tests - -// You can use attributes at the assembly level to apply to all tests in the assembly - -using Aspire.Hosting; - -[assembly: Retry(3)] -[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - -namespace ExampleNamespace; - -public class GlobalHooks -{ - public static DistributedApplication? App { get; private set; } - public static ResourceNotificationService? NotificationService { get; private set; } - - // Uncomment out and replace Projects reference with your app host - //[Before(TestSession)] - //public static async Task SetUp() - //{ - // // Arrange - // var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); - // appHost.Services.ConfigureHttpClientDefaults(clientBuilder => - // { - // clientBuilder.AddStandardResilienceHandler(); - // }); - - // App = await appHost.BuildAsync(); - // NotificationService = App.Services.GetRequiredService(); - // await App.StartAsync(); - //} - - [After(TestSession)] - public static void CleanUp() - { - Console.WriteLine("...and after!"); - } -} diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/IntegrationTest1.cs b/TUnit.Templates/content/TUnit.Aspire.Test/IntegrationTest1.cs index 4d3d685b43..c17a71f193 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/IntegrationTest1.cs +++ b/TUnit.Templates/content/TUnit.Aspire.Test/IntegrationTest1.cs @@ -1,26 +1,43 @@ -namespace ExampleNamespace +using TUnit.Aspire; + +namespace ExampleNamespace; + +// Instructions: +// 1. Add a project reference to the target AppHost project, e.g.: +// +// +// +// +// +// 2. Create a fixture class for your AppHost (or use AspireFixture directly): +// +// public class AppFixture : AspireFixture +// { +// protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) +// { +// builder.Services.ConfigureHttpClientDefaults(clientBuilder => +// { +// clientBuilder.AddStandardResilienceHandler(); +// }); +// } +// } +// +// 3. Uncomment the following example test and update 'AppFixture' to match your fixture: +// +//[ClassDataSource(Shared = SharedType.PerTestSession)] +//public class IntegrationTest1(AppFixture fixture) +//{ +// [Test] +// public async Task GetWebResourceRootReturnsOkStatusCode() +// { +// // Arrange +// var httpClient = fixture.CreateHttpClient("webfrontend"); +// // Act +// var response = await httpClient.GetAsync("/"); +// // Assert +// await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); +// } +//} +public class IntegrationTest1 { - public class IntegrationTest1 - { - // Instructions: - // 1. Add a project reference to the target AppHost project, e.g.: - // - // - // - // - // - // 2. Uncomment the following example test and update 'Projects.MyAspireApp_AppHost' in GlobalSetup.cs to match your AppHost project: - // - //[ClassDataSource] - //[Test] - //public async Task GetWebResourceRootReturnsOkStatusCode(HttpClientDataClass httpClientData) - //{ - // // Arrange - // var httpClient = httpClientData.HttpClient; - // // Act - // var response = await httpClient.GetAsync("/"); - // // Assert - // await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - //} - } } diff --git a/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj index 67f7010d11..497f8da23b 100644 --- a/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj +++ b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj @@ -20,9 +20,9 @@ + - diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs index 0a44944632..a39135d64e 100644 --- a/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs @@ -1,7 +1,8 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; -using TUnit.Core.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using TUnit.Aspire; namespace CloudShop.Tests.Infrastructure; @@ -9,48 +10,29 @@ namespace CloudShop.Tests.Infrastructure; /// Root fixture that starts the entire Aspire distributed application. /// Shared across all tests in the session - the app is started once and reused. /// -public class DistributedAppFixture : IAsyncInitializer, IAsyncDisposable +public class DistributedAppFixture : AspireFixture { - private DistributedApplication? _app; + protected override TimeSpan ResourceTimeout => TimeSpan.FromMinutes(2); - public DistributedApplication App => _app ?? throw new InvalidOperationException("App not initialized"); - - public async Task InitializeAsync() + protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) { // Allow HTTP transport so DCP doesn't require trusted dev certificates. // This is necessary in CI/test environments where certificates may not be trusted. Environment.SetEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "true"); + } - var builder = await DistributedApplicationTestingBuilder - .CreateAsync(); - - _app = await builder.BuildAsync(); - - await _app.StartAsync(); - + protected override async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken) + { // The AppHost defines WaitFor dependencies: // apiservice waits for postgres, redis, rabbitmq // worker waits for postgres, rabbitmq, apiservice // So waiting for the leaf services ensures all infrastructure is ready too. - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - await _app.ResourceNotifications.WaitForResourceAsync("apiservice", KnownResourceStates.Running, cts.Token); - await _app.ResourceNotifications.WaitForResourceAsync("worker", KnownResourceStates.Running, cts.Token); + var notificationService = app.Services.GetRequiredService(); + await notificationService.WaitForResourceAsync("apiservice", KnownResourceStates.Running, cancellationToken); + await notificationService.WaitForResourceAsync("worker", KnownResourceStates.Running, cancellationToken); } - public HttpClient CreateHttpClient(string resourceName) - => App.CreateHttpClient(resourceName); - public async Task GetConnectionStringAsync(string resourceName) - => await App.GetConnectionStringAsync(resourceName) + => await GetConnectionStringAsync(resourceName, CancellationToken.None) ?? throw new InvalidOperationException($"No connection string for '{resourceName}'"); - - public async ValueTask DisposeAsync() - { - if (_app is not null) - { - await _app.StopAsync(); - await _app.DisposeAsync(); - } - } } From 7169e619a3cbc76db32b34fddabfe89958a88f0b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:46:01 +0000 Subject: [PATCH 03/18] fix: add fail-fast and timeout diagnostics to AspireFixture Two improvements to resource waiting: 1. Fail-fast: races each resource's ready-wait against a FailedToStart watcher. If a container crashes during startup, throws immediately with the resource name instead of hanging until timeout. 2. Timeout wrapping: catches OperationCanceledException from the timeout CTS and rethrows as TimeoutException listing all resource names, the wait behavior, and actionable suggestions (increase timeout, use WatchResourceLogs, check dashboard). Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 88 +++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index ab0280dd45..002ecaa3e6 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -121,22 +121,27 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C switch (WaitBehavior) { case ResourceWaitBehavior.AllHealthy: + { var model = app.Services.GetRequiredService(); - await Task.WhenAll(model.Resources.Select(r => - notificationService.WaitForResourceHealthyAsync(r.Name, cancellationToken))); + var names = model.Resources.Select(r => r.Name).ToList(); + await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: true, cancellationToken); break; + } case ResourceWaitBehavior.AllRunning: - var runModel = app.Services.GetRequiredService(); - await Task.WhenAll(runModel.Resources.Select(r => - notificationService.WaitForResourceAsync(r.Name, - KnownResourceStates.Running, cancellationToken))); + { + var model = app.Services.GetRequiredService(); + var names = model.Resources.Select(r => r.Name).ToList(); + await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: false, cancellationToken); break; + } case ResourceWaitBehavior.Named: - await Task.WhenAll(ResourcesToWaitFor().Select(name => - notificationService.WaitForResourceHealthyAsync(name, cancellationToken))); + { + var names = ResourcesToWaitFor().ToList(); + await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: true, cancellationToken); break; + } case ResourceWaitBehavior.None: break; @@ -155,7 +160,22 @@ public async Task InitializeAsync() await _app.StartAsync(); using var cts = new CancellationTokenSource(ResourceTimeout); - await WaitForResourcesAsync(_app, cts.Token); + + try + { + await WaitForResourcesAsync(_app, cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + var model = _app.Services.GetRequiredService(); + var resourceNames = string.Join(", ", model.Resources.Select(r => $"'{r.Name}'")); + + throw new TimeoutException( + $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for Aspire resources to be ready. " + + $"Resources: [{resourceNames}]. " + + $"Wait behavior: {WaitBehavior}. " + + $"Consider increasing ResourceTimeout, checking resource health, or using WatchResourceLogs() to diagnose startup issues."); + } } /// @@ -171,6 +191,56 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + /// + /// Waits for all named resources to reach the desired state, while simultaneously + /// watching for any resource entering FailedToStart. If a resource fails, throws + /// immediately instead of waiting for the full timeout. + /// + private static async Task WaitForResourcesWithFailFastAsync( + ResourceNotificationService notificationService, + List resourceNames, + bool waitForHealthy, + CancellationToken cancellationToken) + { + if (resourceNames.Count == 0) + { + return; + } + + // Linked CTS lets us cancel the failure watchers once all resources are ready + using var failureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Success path: wait for all resources to reach the desired state + var readyTask = Task.WhenAll(resourceNames.Select(name => + waitForHealthy + ? notificationService.WaitForResourceHealthyAsync(name, cancellationToken) + : notificationService.WaitForResourceAsync(name, KnownResourceStates.Running, cancellationToken))); + + // Fail-fast path: complete as soon as ANY resource enters FailedToStart + var failureTasks = resourceNames.Select(async name => + { + await notificationService.WaitForResourceAsync(name, KnownResourceStates.FailedToStart, failureCts.Token); + return name; + }).ToList(); + var anyFailureTask = Task.WhenAny(failureTasks); + + // Race: all resources ready vs. any resource failed + var completed = await Task.WhenAny(readyTask, anyFailureTask); + + if (completed == anyFailureTask) + { + failureCts.Cancel(); + var failedName = await await anyFailureTask; + throw new InvalidOperationException( + $"Resource '{failedName}' failed to start. " + + $"Use WatchResourceLogs(\"{failedName}\") or check the Aspire dashboard for details."); + } + + // All resources are ready - cancel the failure watchers and propagate any exceptions + failureCts.Cancel(); + await readyTask; + } + private sealed class ResourceLogWatcher(CancellationTokenSource cts) : IAsyncDisposable { public ValueTask DisposeAsync() From 8103bc1cb64a9014f23327821b8d61baeced49e6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:53:37 +0000 Subject: [PATCH 04/18] feat: surface resource logs in startup failure and timeout errors When a resource enters FailedToStart, the error message now includes the last 20 lines of that resource's logs (e.g., Docker pull failures, config errors). On timeout, the message distinguishes ready vs. pending resources and includes logs from each pending resource. Example FailedToStart output: Resource 'redis' failed to start. --- redis logs --- Error: no matching manifest for linux/amd64 Example timeout output: Resources not ready: ['postgres']. Resources ready: ['redis'] --- postgres logs --- waiting for server to start... Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 137 +++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 18 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 002ecaa3e6..aeb3b4a182 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Text; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; @@ -124,7 +126,7 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C { var model = app.Services.GetRequiredService(); var names = model.Resources.Select(r => r.Name).ToList(); - await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: true, cancellationToken); + await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: true, cancellationToken); break; } @@ -132,14 +134,14 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C { var model = app.Services.GetRequiredService(); var names = model.Resources.Select(r => r.Name).ToList(); - await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: false, cancellationToken); + await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: false, cancellationToken); break; } case ResourceWaitBehavior.Named: { var names = ResourcesToWaitFor().ToList(); - await WaitForResourcesWithFailFastAsync(notificationService, names, waitForHealthy: true, cancellationToken); + await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: true, cancellationToken); break; } @@ -167,6 +169,9 @@ public async Task InitializeAsync() } catch (OperationCanceledException) when (cts.IsCancellationRequested) { + // Fallback for custom WaitForResourcesAsync overrides that don't use the + // default fail-fast helper. The default implementation throws its own + // TimeoutException with resource logs before this catch is reached. var model = _app.Services.GetRequiredService(); var resourceNames = string.Join(", ", model.Resources.Select(r => $"'{r.Name}'")); @@ -194,9 +199,11 @@ public async ValueTask DisposeAsync() /// /// Waits for all named resources to reach the desired state, while simultaneously /// watching for any resource entering FailedToStart. If a resource fails, throws - /// immediately instead of waiting for the full timeout. + /// immediately with its recent logs. On timeout, reports which resources are still + /// pending and includes their logs. /// private static async Task WaitForResourcesWithFailFastAsync( + DistributedApplication app, ResourceNotificationService notificationService, List resourceNames, bool waitForHealthy, @@ -207,14 +214,26 @@ private static async Task WaitForResourcesWithFailFastAsync( return; } + // Track which resources have become ready (for timeout reporting) + var readyResources = new ConcurrentBag(); + // Linked CTS lets us cancel the failure watchers once all resources are ready using var failureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Success path: wait for all resources to reach the desired state - var readyTask = Task.WhenAll(resourceNames.Select(name => - waitForHealthy - ? notificationService.WaitForResourceHealthyAsync(name, cancellationToken) - : notificationService.WaitForResourceAsync(name, KnownResourceStates.Running, cancellationToken))); + var readyTask = Task.WhenAll(resourceNames.Select(async name => + { + if (waitForHealthy) + { + await notificationService.WaitForResourceHealthyAsync(name, cancellationToken); + } + else + { + await notificationService.WaitForResourceAsync(name, KnownResourceStates.Running, cancellationToken); + } + + readyResources.Add(name); + })); // Fail-fast path: complete as soon as ANY resource enters FailedToStart var failureTasks = resourceNames.Select(async name => @@ -224,21 +243,103 @@ private static async Task WaitForResourcesWithFailFastAsync( }).ToList(); var anyFailureTask = Task.WhenAny(failureTasks); - // Race: all resources ready vs. any resource failed - var completed = await Task.WhenAny(readyTask, anyFailureTask); + try + { + // Race: all resources ready vs. any resource failed + var completed = await Task.WhenAny(readyTask, anyFailureTask); + + if (completed == anyFailureTask) + { + failureCts.Cancel(); + var failedName = await await anyFailureTask; + var logs = await CollectResourceLogsAsync(app, failedName); + + throw new InvalidOperationException( + $"Resource '{failedName}' failed to start." + + $"{Environment.NewLine}{Environment.NewLine}" + + $"--- {failedName} logs ---{Environment.NewLine}" + + logs); + } - if (completed == anyFailureTask) + // All resources are ready - cancel the failure watchers + failureCts.Cancel(); + await readyTask; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + // Timeout - report which resources are ready vs. still pending, with logs failureCts.Cancel(); - var failedName = await await anyFailureTask; - throw new InvalidOperationException( - $"Resource '{failedName}' failed to start. " + - $"Use WatchResourceLogs(\"{failedName}\") or check the Aspire dashboard for details."); + + var readySet = new HashSet(readyResources); + var pending = resourceNames.Where(n => !readySet.Contains(n)).ToList(); + + var sb = new StringBuilder(); + sb.Append("Resources not ready: ["); + sb.Append(string.Join(", ", pending.Select(n => $"'{n}'"))); + sb.Append(']'); + + if (readySet.Count > 0) + { + sb.Append(". Resources ready: ["); + sb.Append(string.Join(", ", readySet.Select(n => $"'{n}'"))); + sb.Append(']'); + } + + foreach (var name in pending) + { + var logs = await CollectResourceLogsAsync(app, name); + sb.AppendLine().AppendLine(); + sb.AppendLine($"--- {name} logs ---"); + sb.Append(logs); + } + + throw new TimeoutException(sb.ToString()); + } + } + + /// + /// Collects recent log lines from a resource via the . + /// Returns the last lines, or "(no logs available)" if none. + /// Uses a short timeout to avoid hanging if the log service is unresponsive. + /// + private static async Task CollectResourceLogsAsync( + DistributedApplication app, + string resourceName, + int maxLines = 20) + { + var loggerService = app.Services.GetRequiredService(); + var lines = new List(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + try + { + await foreach (var batch in loggerService.WatchAsync(resourceName) + .WithCancellation(cts.Token)) + { + foreach (var line in batch) + { + lines.Add($" {line}"); + } + } + } + catch (OperationCanceledException) + { + // Expected - we use a short timeout to collect buffered logs + } + + if (lines.Count == 0) + { + return " (no logs available)"; + } + + if (lines.Count > maxLines) + { + return $" ... ({lines.Count - maxLines} earlier lines omitted){Environment.NewLine}" + + string.Join(Environment.NewLine, lines.Skip(lines.Count - maxLines)); } - // All resources are ready - cancel the failure watchers and propagate any exceptions - failureCts.Cancel(); - await readyTask; + return string.Join(Environment.NewLine, lines); } private sealed class ResourceLogWatcher(CancellationTokenSource cts) : IAsyncDisposable From ba9bddceb54c55b77819b171745230ba8a3f498c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:20:11 +0000 Subject: [PATCH 05/18] fix: swap TUnit.Aspire PackageReference for ProjectReference in template builds Template content projects reference TUnit.Aspire as a NuGet PackageReference, but the package hasn't been published yet. During solution builds (CI), this causes NU1101. The content Directory.Build.props (excluded from the template package) now swaps to a local ProjectReference and adds the implicit usings that would normally come from TUnit's NuGet props/targets. Co-Authored-By: Claude Opus 4.6 --- TUnit.Templates/content/Directory.Build.props | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/TUnit.Templates/content/Directory.Build.props b/TUnit.Templates/content/Directory.Build.props index 621de2131d..4f8ec58eec 100644 --- a/TUnit.Templates/content/Directory.Build.props +++ b/TUnit.Templates/content/Directory.Build.props @@ -4,4 +4,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file From 5f930496d9509f7b7c2338a91a7a4cebcec7a78d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:49:24 +0000 Subject: [PATCH 06/18] fix: update public API snapshots for TUnit.Aspire InternalsVisibleTo Adding InternalsVisibleTo("TUnit.Aspire") to TUnit.Core changed the assembly-level attributes, causing the Core_Library_Has_No_API_Changes snapshot test to fail. Co-Authored-By: Claude Opus 4.6 --- ...Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 1 + 4 files changed, 4 insertions(+) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 93aa773a6b..97bd027d6b 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1,5 +1,6 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 9e36a1bd1f..74ecb07bd5 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1,5 +1,6 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index e16b310772..b083ed0b84 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1,5 +1,6 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 68272bad15..f1106c74fd 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1,5 +1,6 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] From e738ef53baeb3e197d1a4784f6bca59b5ff53348 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:07:39 +0000 Subject: [PATCH 07/18] fix: pre-pull Docker images and increase timeouts for CloudShop CI The CloudShop integration tests have been consistently timing out at the 10-minute job limit because pulling Docker images for PostgreSQL, Redis, and RabbitMQ from scratch on GitHub Actions runners takes too long. - Add a pre-pull step that pulls all 5 Docker images in parallel before the test run, giving visibility into pull times - Increase job timeout from 10 to 15 minutes - Increase fixture ResourceTimeout from 2 to 5 minutes Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cloudshop-example.yml | 11 ++++++++++- .../Infrastructure/DistributedAppFixture.cs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml index 170bb18fcc..9436db6a83 100644 --- a/.github/workflows/cloudshop-example.yml +++ b/.github/workflows/cloudshop-example.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 @@ -22,6 +22,15 @@ jobs: with: dotnet-version: 10.0.x + - name: Pre-pull Docker images + run: | + docker pull postgres:latest & + docker pull redis:latest & + docker pull rabbitmq:4-management & + docker pull dpage/pgadmin4:latest & + docker pull redis/redisinsight:latest & + wait + - name: Cache NuGet packages uses: actions/cache@v5 continue-on-error: true diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs index a39135d64e..ffc8c39d6c 100644 --- a/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs @@ -12,7 +12,7 @@ namespace CloudShop.Tests.Infrastructure; /// public class DistributedAppFixture : AspireFixture { - protected override TimeSpan ResourceTimeout => TimeSpan.FromMinutes(2); + protected override TimeSpan ResourceTimeout => TimeSpan.FromMinutes(5); protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) { From fd4ef421524824ec3687d1d49d8615c916cdf8f5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:16:26 +0000 Subject: [PATCH 08/18] feat: add progress logging to AspireFixture initialization During fixture initialization (before any tests run), the process could appear stuck with zero output in CI. Add a virtual LogProgress method that writes to stderr for immediate CI visibility, logging each lifecycle step and per-resource readiness. Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index aeb3b4a182..57b4ac30df 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -90,6 +90,16 @@ public IAsyncDisposable WatchResourceLogs(string resourceName) // --- Configuration hooks (virtual) --- + /// + /// Logs progress messages during initialization. Override to route to a custom logger. + /// Default implementation writes to for immediate CI visibility. + /// + /// The progress message. + protected virtual void LogProgress(string message) + { + Console.Error.WriteLine($"[Aspire] {message}"); + } + /// /// Override to customize the builder before building the application. /// @@ -155,24 +165,31 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C /// public async Task InitializeAsync() { + LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}..."); var builder = await DistributedApplicationTestingBuilder.CreateAsync(); ConfigureBuilder(builder); + LogProgress("Building application..."); _app = await builder.BuildAsync(); + + var model = _app.Services.GetRequiredService(); + var resourceList = string.Join(", ", model.Resources.Select(r => r.Name)); + LogProgress($"Starting application with resources: [{resourceList}]"); await _app.StartAsync(); + LogProgress($"Application started. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})..."); using var cts = new CancellationTokenSource(ResourceTimeout); try { await WaitForResourcesAsync(_app, cts.Token); + LogProgress("All resources ready."); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { // Fallback for custom WaitForResourcesAsync overrides that don't use the // default fail-fast helper. The default implementation throws its own // TimeoutException with resource logs before this catch is reached. - var model = _app.Services.GetRequiredService(); var resourceNames = string.Join(", ", model.Resources.Select(r => $"'{r.Name}'")); throw new TimeoutException( @@ -202,7 +219,7 @@ public async ValueTask DisposeAsync() /// immediately with its recent logs. On timeout, reports which resources are still /// pending and includes their logs. /// - private static async Task WaitForResourcesWithFailFastAsync( + private async Task WaitForResourcesWithFailFastAsync( DistributedApplication app, ResourceNotificationService notificationService, List resourceNames, @@ -220,6 +237,9 @@ private static async Task WaitForResourcesWithFailFastAsync( // Linked CTS lets us cancel the failure watchers once all resources are ready using var failureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var targetState = waitForHealthy ? "healthy" : "running"; + LogProgress($"Waiting for {resourceNames.Count} resource(s) to become {targetState}: [{string.Join(", ", resourceNames)}]"); + // Success path: wait for all resources to reach the desired state var readyTask = Task.WhenAll(resourceNames.Select(async name => { @@ -233,6 +253,7 @@ private static async Task WaitForResourcesWithFailFastAsync( } readyResources.Add(name); + LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{resourceNames.Count})"); })); // Fail-fast path: complete as soon as ANY resource enters FailedToStart @@ -302,7 +323,7 @@ private static async Task WaitForResourcesWithFailFastAsync( /// Returns the last lines, or "(no logs available)" if none. /// Uses a short timeout to avoid hanging if the log service is unresponsive. /// - private static async Task CollectResourceLogsAsync( + private async Task CollectResourceLogsAsync( DistributedApplication app, string resourceName, int maxLines = 20) From 3719a152777fe76a3db7b4402fe464f8858e0031 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:38:51 +0000 Subject: [PATCH 09/18] fix: write progress logs directly to raw stderr stream Console.Error.WriteLine was not producing visible output in CI - likely intercepted or buffered by the testing platform. Switch to writing directly to Console.OpenStandardError() with explicit flush to bypass any .NET TextWriter wrapping. Also add progress logging to DisposeAsync since stopping containers can also hang. Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 57b4ac30df..b7dd0c33ae 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -90,14 +90,19 @@ public IAsyncDisposable WatchResourceLogs(string resourceName) // --- Configuration hooks (virtual) --- + private static readonly Stream StdErr = Console.OpenStandardError(); + /// /// Logs progress messages during initialization. Override to route to a custom logger. - /// Default implementation writes to for immediate CI visibility. + /// Default implementation writes directly to the standard error stream, bypassing any + /// interceptors or buffering, for immediate CI visibility. /// /// The progress message. protected virtual void LogProgress(string message) { - Console.Error.WriteLine($"[Aspire] {message}"); + var bytes = Encoding.UTF8.GetBytes($"[Aspire] {message}{Environment.NewLine}"); + StdErr.Write(bytes, 0, bytes.Length); + StdErr.Flush(); } /// @@ -205,9 +210,12 @@ public async ValueTask DisposeAsync() { if (_app is not null) { + LogProgress("Stopping application..."); await _app.StopAsync(); + LogProgress("Disposing application..."); await _app.DisposeAsync(); _app = null; + LogProgress("Application disposed."); } GC.SuppressFinalize(this); From 555f77758369f61649e168bf0fdbc32c5935c06d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:58:49 +0000 Subject: [PATCH 10/18] fix: pre-pull exact Aspire 13.1.1 Docker image tags The previous pre-pull used :latest tags which don't match what Aspire actually pulls. Aspire's DCP was still spending 14+ minutes pulling the version-specific images during StartAsync(). Updated to the exact tags from Aspire.Hosting 13.1.1: postgres:17.6, redis:8.2, rabbitmq:4.2-management, dpage/pgadmin4:9.9.0, redis/redisinsight:2.70 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cloudshop-example.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml index 9436db6a83..98fb46ac70 100644 --- a/.github/workflows/cloudshop-example.yml +++ b/.github/workflows/cloudshop-example.yml @@ -24,11 +24,12 @@ jobs: - name: Pre-pull Docker images run: | - docker pull postgres:latest & - docker pull redis:latest & - docker pull rabbitmq:4-management & - docker pull dpage/pgadmin4:latest & - docker pull redis/redisinsight:latest & + # Exact image tags used by Aspire.Hosting 13.1.1 + docker pull postgres:17.6 & + docker pull redis:8.2 & + docker pull rabbitmq:4.2-management & + docker pull dpage/pgadmin4:9.9.0 & + docker pull redis/redisinsight:2.70 & wait - name: Cache NuGet packages From 5c76833b3f1a5c45dfb9cd4427bc70a39dff1efa Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:36:53 +0000 Subject: [PATCH 11/18] fix: add timeout to StartAsync and set env var earlier StartAsync() hangs for 14+ minutes even with pre-pulled images. DCP processes are running but StartAsync never returns. Two fixes: 1. Pass CancellationToken with ResourceTimeout to StartAsync() so it can't hang indefinitely 2. Set ASPIRE_ALLOW_UNSECURED_TRANSPORT as process env var in the workflow step (not just in ConfigureBuilder) so it's available from process start 3. Add elapsed time logging to each initialization phase for diagnostics Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cloudshop-example.yml | 2 ++ TUnit.Aspire/AspireFixture.cs | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml index 98fb46ac70..d43390aa04 100644 --- a/.github/workflows/cloudshop-example.yml +++ b/.github/workflows/cloudshop-example.yml @@ -46,3 +46,5 @@ jobs: - name: Run integration tests run: dotnet run --project examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj -c Release --no-build + env: + ASPIRE_ALLOW_UNSECURED_TRANSPORT: "true" diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index b7dd0c33ae..720d30463c 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Text; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; @@ -170,19 +171,35 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C /// public async Task InitializeAsync() { + var sw = Stopwatch.StartNew(); + LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}..."); var builder = await DistributedApplicationTestingBuilder.CreateAsync(); ConfigureBuilder(builder); + LogProgress($"Builder created in {sw.Elapsed.TotalSeconds:0.0}s"); LogProgress("Building application..."); _app = await builder.BuildAsync(); + LogProgress($"Application built in {sw.Elapsed.TotalSeconds:0.0}s"); var model = _app.Services.GetRequiredService(); var resourceList = string.Join(", ", model.Resources.Select(r => r.Name)); LogProgress($"Starting application with resources: [{resourceList}]"); - await _app.StartAsync(); - LogProgress($"Application started. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})..."); + using var startCts = new CancellationTokenSource(ResourceTimeout); + try + { + await _app.StartAsync(startCts.Token); + } + catch (OperationCanceledException) when (startCts.IsCancellationRequested) + { + throw new TimeoutException( + $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start. " + + $"This usually means Docker containers are still being pulled or started. " + + $"Consider increasing ResourceTimeout or pre-pulling Docker images."); + } + + LogProgress($"Application started in {sw.Elapsed.TotalSeconds:0.0}s. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})..."); using var cts = new CancellationTokenSource(ResourceTimeout); try From c982bb56ba2ba1bc48caa3bb98e9f7947dbae17e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:53:06 +0000 Subject: [PATCH 12/18] fix: disable TLS on Redis container for CI compatibility Aspire 13.1 enables TLS termination on Redis containers by default via a developer certificate. In CI, the DCP health checks fail with SSL errors (error:0A000126) because the certificate isn't trusted, preventing StartAsync from completing. Adding .WithoutHttpsCertificate() disables this, matching the approach already used on the project resources. Co-Authored-By: Claude Opus 4.6 --- examples/CloudShop/CloudShop.AppHost/AppHost.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/CloudShop/CloudShop.AppHost/AppHost.cs b/examples/CloudShop/CloudShop.AppHost/AppHost.cs index 2a22da532b..ede595ab5e 100644 --- a/examples/CloudShop/CloudShop.AppHost/AppHost.cs +++ b/examples/CloudShop/CloudShop.AppHost/AppHost.cs @@ -6,7 +6,8 @@ .AddDatabase("postgresdb"); var redis = builder.AddRedis("redis") - .WithRedisInsight(); + .WithRedisInsight() + .WithoutHttpsCertificate(); var rabbitmq = builder.AddRabbitMQ("rabbitmq") .WithManagementPlugin(); From 41003095c8caf65f329b695509b66eb2d8eaf427 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:57:42 +0000 Subject: [PATCH 13/18] feat: add real-time resource monitoring and log collection on startup timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements that would have caught the Redis TLS issue immediately: 1. Real-time resource state monitoring during StartAsync() — subscribes to ResourceNotificationService.WatchAsync() and logs state transitions (e.g. [redis] unknown -> Running) to stderr as they happen, providing immediate visibility into health check failures and SSL errors. 2. Resource log collection on StartAsync() timeout — when startup times out, the error now includes actual container logs from all resources, showing exactly why startup hung (e.g. Redis SSL errors) instead of a generic "timed out" message. Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 96 +++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 720d30463c..ac4c2900ee 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -186,6 +186,12 @@ public async Task InitializeAsync() var resourceList = string.Join(", ", model.Resources.Select(r => r.Name)); LogProgress($"Starting application with resources: [{resourceList}]"); + // Monitor resource state changes in the background during startup. + // This provides real-time visibility into container health check failures, + // SSL errors, etc. that would otherwise be invisible during StartAsync(). + using var monitorCts = new CancellationTokenSource(); + var monitorTask = MonitorResourceEventsAsync(_app, monitorCts.Token); + using var startCts = new CancellationTokenSource(ResourceTimeout); try { @@ -193,12 +199,19 @@ public async Task InitializeAsync() } catch (OperationCanceledException) when (startCts.IsCancellationRequested) { - throw new TimeoutException( - $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start. " + - $"This usually means Docker containers are still being pulled or started. " + - $"Consider increasing ResourceTimeout or pre-pulling Docker images."); + await StopMonitorAsync(monitorCts, monitorTask); + + // Collect resource logs so the timeout error shows WHY startup hung + var sb = new StringBuilder(); + sb.Append($"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start."); + + await AppendResourceLogsAsync(sb, _app, model.Resources.Select(r => r.Name)); + + throw new TimeoutException(sb.ToString()); } + await StopMonitorAsync(monitorCts, monitorTask); + LogProgress($"Application started in {sw.Elapsed.TotalSeconds:0.0}s. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})..."); using var cts = new CancellationTokenSource(ResourceTimeout); @@ -238,6 +251,81 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + /// + /// Monitors resource notification events during startup, logging state transitions + /// in real time. This provides immediate visibility into issues like health check + /// failures, SSL errors, or containers stuck in a restart loop — problems that would + /// otherwise be invisible until a timeout fires. + /// + private async Task MonitorResourceEventsAsync(DistributedApplication app, CancellationToken cancellationToken) + { + try + { + var notificationService = app.Services.GetRequiredService(); + var trackedStates = new ConcurrentDictionary(); + + await foreach (var evt in notificationService.WatchAsync(cancellationToken)) + { + var name = evt.Resource.Name; + var state = evt.Snapshot.State?.Text; + + if (state is null) + { + continue; + } + + // Only log when state actually changes + var previousState = trackedStates.GetValueOrDefault(name); + if (state != previousState) + { + trackedStates[name] = state; + LogProgress($" [{name}] {previousState ?? "unknown"} -> {state}"); + } + } + } + catch (OperationCanceledException) + { + // Expected when monitoring is stopped + } + catch (Exception ex) + { + // Don't let monitoring failures break startup + LogProgress($" (resource monitoring stopped: {ex.Message})"); + } + } + + private static async Task StopMonitorAsync(CancellationTokenSource monitorCts, Task monitorTask) + { + await monitorCts.CancelAsync(); + + try + { + await monitorTask; + } + catch (OperationCanceledException) + { + // Expected + } + } + + /// + /// Appends resource logs for the given resource names to a . + /// Only includes resources that have logs available. + /// + private async Task AppendResourceLogsAsync(StringBuilder sb, DistributedApplication app, IEnumerable resourceNames) + { + foreach (var name in resourceNames) + { + var logs = await CollectResourceLogsAsync(app, name); + if (logs != " (no logs available)") + { + sb.AppendLine().AppendLine(); + sb.AppendLine($"--- {name} logs ---"); + sb.Append(logs); + } + } + } + /// /// Waits for all named resources to reach the desired state, while simultaneously /// watching for any resource entering FailedToStart. If a resource fails, throws From 4995aeb95040d616825c3581c8e56f963196366e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:04:02 +0000 Subject: [PATCH 14/18] chore: remove Docker image pre-pull step from CloudShop CI No longer needed now that the Redis TLS issue is fixed. The pre-pulls were a workaround for slow image pulls, but the actual root cause was Redis TLS causing StartAsync to hang indefinitely. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cloudshop-example.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml index d43390aa04..7c17af6b86 100644 --- a/.github/workflows/cloudshop-example.yml +++ b/.github/workflows/cloudshop-example.yml @@ -22,16 +22,6 @@ jobs: with: dotnet-version: 10.0.x - - name: Pre-pull Docker images - run: | - # Exact image tags used by Aspire.Hosting 13.1.1 - docker pull postgres:17.6 & - docker pull redis:8.2 & - docker pull rabbitmq:4.2-management & - docker pull dpage/pgadmin4:9.9.0 & - docker pull redis/redisinsight:2.70 & - wait - - name: Cache NuGet packages uses: actions/cache@v5 continue-on-error: true From e15a566ea3ba5c38843adaedc572b225b30697e1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:09:27 +0000 Subject: [PATCH 15/18] docs: add comprehensive TUnit.Aspire documentation Covers installation, quick start, core concepts (lifecycle, shared sessions, resource waiting, timeouts), full API reference, resource log watching, fixture chains for real-world apps, diagnostics (progress logging, timeout diagnostics, fail-fast), CI/CD setup, templates, and FAQ/troubleshooting. Co-Authored-By: Claude Opus 4.6 --- docs/docs/examples/aspire.md | 496 +++++++++++++++++++++++++++++++++++ docs/sidebars.ts | 1 + 2 files changed, 497 insertions(+) create mode 100644 docs/docs/examples/aspire.md diff --git a/docs/docs/examples/aspire.md b/docs/docs/examples/aspire.md new file mode 100644 index 0000000000..8bbd90a879 --- /dev/null +++ b/docs/docs/examples/aspire.md @@ -0,0 +1,496 @@ +# Aspire Integration Testing + +TUnit provides first-class support for [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/overview) integration testing through the `TUnit.Aspire` package. This package eliminates the boilerplate of managing an Aspire distributed application in tests, handling the full lifecycle (build, start, wait for resources, stop, dispose) automatically. + +## Installation + +```bash +dotnet add package TUnit.Aspire +``` + +:::info Prerequisites +- An Aspire AppHost project in your solution +- Docker running (Aspire uses containers for infrastructure resources) +- .NET 8.0 or later +::: + +## Quick Start + +### 1. Use the Fixture Directly + +The simplest approach requires no subclassing at all: + +```csharp +[ClassDataSource>(Shared = SharedType.PerTestSession)] +public class ApiTests(AspireFixture fixture) +{ + [Test] + public async Task GetWeatherForecast_ReturnsOk() + { + var client = fixture.CreateHttpClient("apiservice"); + + var response = await client.GetAsync("/weatherforecast"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } +} +``` + +That's it. The fixture will: +1. Build your Aspire AppHost +2. Start all containers and projects +3. Wait for all resources to become healthy +4. Provide HTTP clients and connection strings +5. Stop and dispose everything when tests complete + +### 2. Subclass for Customization + +For more control, create a subclass: + +```csharp +using TUnit.Aspire; + +public class AppFixture : AspireFixture +{ + protected override TimeSpan ResourceTimeout => TimeSpan.FromMinutes(3); + + protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) + { + // Configure the builder before the app is built + builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + } +} +``` + +Then use it in tests: + +```csharp +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class ApiTests(AppFixture fixture) +{ + [Test] + public async Task GetWeatherForecast_ReturnsOk() + { + var client = fixture.CreateHttpClient("apiservice"); + var response = await client.GetAsync("/weatherforecast"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } +} +``` + +## Core Concepts + +### Lifecycle + +`AspireFixture` implements `IAsyncInitializer` and `IAsyncDisposable`, integrating with TUnit's lifecycle automatically: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ FIXTURE LIFECYCLE │ +├──────────────────────────────────────────────────────────────────┤ +│ 1. CreateAsync() Build the Aspire test builder │ +│ 2. ConfigureBuilder() Your customization hook │ +│ 3. BuildAsync() Build the distributed app │ +│ 4. StartAsync() Start containers & projects │ +│ ↳ Resource monitoring Real-time state change logging │ +│ 5. WaitForResources() Wait for healthy/running state │ +│ ↳ Fail-fast detection Immediate error on FailedToStart │ +│ ─────────────────────────────────────────────────────────────── │ +│ 6. Tests run Use CreateHttpClient, App, etc. │ +│ ─────────────────────────────────────────────────────────────── │ +│ 7. StopAsync() Stop the application │ +│ 8. DisposeAsync() Clean up all resources │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Shared Session + +Use `Shared = SharedType.PerTestSession` to start the Aspire app once and share it across all tests: + +```csharp +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class OrderTests(AppFixture fixture) { /* ... */ } + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class ProductTests(AppFixture fixture) { /* ... */ } +// Both test classes share the same AppFixture instance +``` + +This is the recommended approach since starting an Aspire distributed application is expensive (containers, databases, etc.). + +### Resource Waiting + +By default, the fixture waits for **all resources to become healthy** before tests run. You can customize this: + +```csharp +public class AppFixture : AspireFixture +{ + // Option 1: Change the wait behavior via property + protected override ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.AllRunning; + + // Option 2: Wait for specific resources only + protected override ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.Named; + protected override IEnumerable ResourcesToWaitFor() => ["apiservice", "worker"]; + + // Option 3: Full control over the waiting logic + protected override async Task WaitForResourcesAsync( + DistributedApplication app, CancellationToken cancellationToken) + { + var notifications = app.Services.GetRequiredService(); + await notifications.WaitForResourceAsync("apiservice", + KnownResourceStates.Running, cancellationToken); + await notifications.WaitForResourceAsync("worker", + KnownResourceStates.Running, cancellationToken); + } +} +``` + +Available `ResourceWaitBehavior` values: + +| Value | Description | +|-------|-------------| +| `AllHealthy` | Wait for all resources to pass health checks (default) | +| `AllRunning` | Wait for all resources to reach the Running state | +| `Named` | Wait only for resources returned by `ResourcesToWaitFor()` | +| `None` | Don't wait — handle readiness manually in tests | + +### Timeouts + +The `ResourceTimeout` controls how long the fixture waits for both `StartAsync()` and resource readiness: + +```csharp +public class AppFixture : AspireFixture +{ + // Default is 60 seconds. Increase for slow containers or CI environments. + protected override TimeSpan ResourceTimeout => TimeSpan.FromMinutes(3); +} +``` + +When a timeout occurs, the error includes: +- Which resources are ready vs. still pending +- Recent container logs from pending resources +- Diagnostic information about the failure + +## Public API + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `App` | `DistributedApplication` | The running Aspire app. Access for advanced scenarios. | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `CreateHttpClient(resourceName, endpointName?)` | `HttpClient` | Creates an HTTP client connected to the named resource | +| `GetConnectionStringAsync(resourceName, ct?)` | `Task` | Gets the connection string for the named resource | +| `WatchResourceLogs(resourceName)` | `IAsyncDisposable` | Streams resource logs to the current test's output | + +### Virtual Methods (Override to Customize) + +| Method | Default | Description | +|--------|---------|-------------| +| `ConfigureBuilder(builder)` | No-op | Customize the builder before building | +| `ResourceTimeout` | 60 seconds | How long to wait for startup and resources | +| `WaitBehavior` | `AllHealthy` | Which resources to wait for | +| `ResourcesToWaitFor()` | Empty | Resource names when `WaitBehavior` is `Named` | +| `WaitForResourcesAsync(app, ct)` | Waits per `WaitBehavior` | Full control over resource waiting | +| `LogProgress(message)` | Writes to stderr | Override to route progress logs elsewhere | + +## Watching Resource Logs + +Use `WatchResourceLogs()` inside a test to stream a resource's container logs to the test output. This is invaluable for debugging failures: + +```csharp +[Test] +public async Task Debug_Api_Behavior() +{ + await using var _ = fixture.WatchResourceLogs("apiservice"); + + var client = fixture.CreateHttpClient("apiservice"); + var response = await client.PostAsJsonAsync("/api/orders", new { /* ... */ }); + + // If this fails, the apiservice container logs will be in the test output + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); +} +``` + +Dispose the returned value (or use `await using`) to stop watching. + +## Building Fixture Chains + +For real-world apps, you'll want layered fixtures. Use TUnit's `[ClassDataSource]` property injection to create dependency chains: + +### HTTP Client Fixture + +```csharp +public class ApiClientFixture : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AppFixture App { get; init; } + + public HttpClient Client { get; private set; } = null!; + + public Task InitializeAsync() + { + Client = App.CreateHttpClient("apiservice"); + Client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + return Task.CompletedTask; + } +} +``` + +### Database Fixture + +```csharp +public class DatabaseFixture : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AppFixture App { get; init; } + + public NpgsqlConnection Connection { get; private set; } = null!; + + public async Task InitializeAsync() + { + var connectionString = await App.GetConnectionStringAsync("postgresdb"); + Connection = new NpgsqlConnection(connectionString); + await Connection.OpenAsync(); + } + + public async ValueTask DisposeAsync() => await Connection.DisposeAsync(); +} +``` + +### Redis Fixture + +```csharp +public class RedisFixture : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AppFixture App { get; init; } + + public IConnectionMultiplexer Connection { get; private set; } = null!; + public IDatabase Database => Connection.GetDatabase(); + + public async Task InitializeAsync() + { + var connectionString = await App.GetConnectionStringAsync("redis"); + Connection = await ConnectionMultiplexer.ConnectAsync(connectionString); + } + + public async ValueTask DisposeAsync() => await Connection.DisposeAsync(); +} +``` + +### Using Fixtures in Tests + +```csharp +[Category("Integration"), Category("Cache")] +public class ProductCacheTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required ApiClientFixture Api { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required RedisFixture Redis { get; init; } + + [Test] + public async Task Product_Is_Cached_After_Fetch() + { + // Create a product via API + var response = await Api.Client.PostAsJsonAsync("/api/products", + new { Name = "Test", Category = "electronics", Price = 9.99m }); + var product = await response.Content.ReadFromJsonAsync(); + + // Fetch it (triggers caching) + await Api.Client.GetAsync($"/api/products/{product!.Id}"); + + // Verify Redis has the cached entry + var cached = await Redis.Database.StringGetAsync($"product:{product.Id}"); + await Assert.That(cached.HasValue).IsTrue(); + } +} +``` + +TUnit resolves the dependency chain automatically: `AppFixture` starts first, then `ApiClientFixture` and `RedisFixture` initialize using the running app. + +## Diagnostics + +### Progress Logging + +During initialization, the fixture logs progress to stderr for CI visibility: + +``` +[Aspire] Creating distributed application builder for MyAppHost... +[Aspire] Builder created in 0.3s +[Aspire] Building application... +[Aspire] Application built in 1.2s +[Aspire] Starting application with resources: [postgres, redis, apiservice, worker] +[Aspire] [postgres] unknown -> Starting +[Aspire] [redis] unknown -> Starting +[Aspire] [postgres] Starting -> Running +[Aspire] [redis] Starting -> Running +[Aspire] Application started in 8.5s. Waiting for resources... +[Aspire] Resource 'apiservice' is healthy (1/4) +[Aspire] Resource 'worker' is healthy (2/4) +[Aspire] All resources ready. +``` + +Override `LogProgress` to route these messages elsewhere: + +```csharp +public class AppFixture : AspireFixture +{ + protected override void LogProgress(string message) + { + // Route to your preferred logger + Console.WriteLine(message); + } +} +``` + +### Timeout Diagnostics + +When a timeout occurs, the error message includes container logs from the failing resources, so you can see exactly what went wrong without having to reproduce the failure: + +``` +TimeoutException: Timed out after 60s waiting for the Aspire application to start. + +--- redis logs --- + Error accepting a client connection: error:0A000126:SSL routines::unexpected eof + Error accepting a client connection: error:0A000126:SSL routines::unexpected eof +``` + +### Fail-Fast Detection + +The default resource waiting logic watches for resources entering a `FailedToStart` state. If any resource fails, the fixture throws immediately with that resource's logs instead of waiting for the full timeout. + +## CI/CD + +### GitHub Actions + +```yaml +jobs: + integration-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - run: dotnet build MyApp.Tests -c Release + - run: dotnet run --project MyApp.Tests -c Release --no-build + env: + ASPIRE_ALLOW_UNSECURED_TRANSPORT: "true" +``` + +:::warning ASPIRE_ALLOW_UNSECURED_TRANSPORT +Set `ASPIRE_ALLOW_UNSECURED_TRANSPORT=true` in CI environments where the ASP.NET Core developer certificate isn't trusted. Without this, container health checks may fail with TLS errors. +::: + +### Tips for CI + +- **Increase `ResourceTimeout`** — CI runners are slower than local machines. 2-5 minutes is typical. +- **Use `Shared = SharedType.PerTestSession`** — Start the app once, not per test class. +- **Check Docker availability** — Aspire requires Docker. Ensure your CI runner has it installed. + +## Templates + +TUnit includes project templates for Aspire testing: + +```bash +# Install TUnit templates +dotnet new install TUnit.Templates + +# Scaffold a complete Aspire solution with tests +dotnet new tunit-aspire-starter -n MyApp + +# Add a test project to an existing Aspire solution +dotnet new tunit-aspire-test -n MyApp.Tests +``` + +## FAQ & Troubleshooting + +### StartAsync hangs or times out + +**Symptom:** Tests time out during startup with no obvious error. + +**Common causes:** +1. **TLS/SSL errors** — Set `ASPIRE_ALLOW_UNSECURED_TRANSPORT=true` or call `.WithoutHttpsCertificate()` on container resources in your AppHost. +2. **Docker images not pulled** — First run pulls container images, which can take minutes. Increase `ResourceTimeout`. +3. **Docker not running** — Aspire requires Docker. Verify with `docker info`. + +The fixture logs resource state changes in real time to stderr, so check your CI output for lines like `[redis] Running -> unhealthy`. + +### How do I access infrastructure directly? + +Use `App` to access the full `DistributedApplication`, then get services or connection strings: + +```csharp +// Direct service access +var notifications = fixture.App.Services.GetRequiredService(); + +// Connection strings +var connStr = await fixture.GetConnectionStringAsync("postgresdb"); +``` + +### Can I run different AppHosts in different test classes? + +Yes. Create separate fixtures for each AppHost: + +```csharp +public class AppAFixture : AspireFixture { } +public class AppBFixture : AspireFixture { } + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class AppATests(AppAFixture fixture) { /* ... */ } + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class AppBTests(AppBFixture fixture) { /* ... */ } +``` + +### How do I skip waiting for tool containers? + +Tool containers like pgAdmin or RedisInsight don't need to be ready before tests run. Use `Named` wait behavior: + +```csharp +public class AppFixture : AspireFixture +{ + protected override ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.Named; + + protected override IEnumerable ResourcesToWaitFor() + => ["apiservice", "worker", "postgres", "redis"]; + // pgadmin, redisinsight are excluded — tests don't need them +} +``` + +### My resource never becomes healthy + +If a resource stays in `Running` but never reaches `Healthy`, check: +1. The resource has a health check configured (`.WithHttpHealthCheck("/health")` or similar) +2. The health check endpoint is reachable from inside the container network +3. Use `WatchResourceLogs("resourceName")` in a test to see the resource's output + +If the resource doesn't have health checks, use `AllRunning` instead of `AllHealthy`: + +```csharp +protected override ResourceWaitBehavior WaitBehavior => ResourceWaitBehavior.AllRunning; +``` + +### What's the difference between TUnit.Aspire and TUnit.AspNetCore? + +| | TUnit.Aspire | TUnit.AspNetCore | +|---|---|---| +| **Purpose** | Test distributed apps (multiple services + infrastructure) | Test a single ASP.NET Core app | +| **Infrastructure** | Real containers via Aspire/Docker | In-process `TestServer` or Testcontainers | +| **Isolation** | Shared app, per-test HTTP clients | Per-test `WebApplicationFactory` | +| **Use when** | Your app uses Aspire orchestration | Your app is a single ASP.NET Core project | + +They can be used together — for example, using Aspire to manage infrastructure while using `TestWebApplicationFactory` for per-test app isolation. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index cc6cdb21b2..91fedef350 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -194,6 +194,7 @@ const sidebars: SidebarsConfig = { collapsed: false, items: [ 'examples/aspnet', + 'examples/aspire', 'examples/playwright', 'examples/fscheck', 'examples/complex-test-infrastructure', From ba8b18bbc49c2e878cba6978c6a01f9e4e629088 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:16:01 +0000 Subject: [PATCH 16/18] feat: make InitializeAsync and DisposeAsync virtual Allows users to override the lifecycle methods to add post-start logic (database migrations, data seeding) or custom cleanup without needing a wrapping fixture. Co-Authored-By: Claude Opus 4.6 --- TUnit.Aspire/AspireFixture.cs | 27 +++++++++++++++++++++++---- docs/docs/examples/aspire.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index ac4c2900ee..2c0a23032e 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -168,8 +168,18 @@ protected virtual async Task WaitForResourcesAsync(DistributedApplication app, C // --- Lifecycle --- - /// - public async Task InitializeAsync() + /// + /// Initializes the Aspire distributed application. Override to add post-start + /// logic such as database migrations or data seeding: + /// + /// public override async Task InitializeAsync() + /// { + /// await base.InitializeAsync(); + /// await RunMigrationsAsync(); + /// } + /// + /// + public virtual async Task InitializeAsync() { var sw = Stopwatch.StartNew(); @@ -235,8 +245,17 @@ public async Task InitializeAsync() } } - /// - public async ValueTask DisposeAsync() + /// + /// Disposes the Aspire distributed application. Override to add custom cleanup: + /// + /// public override async ValueTask DisposeAsync() + /// { + /// // Custom cleanup before app stops + /// await base.DisposeAsync(); + /// } + /// + /// + public virtual async ValueTask DisposeAsync() { if (_app is not null) { diff --git a/docs/docs/examples/aspire.md b/docs/docs/examples/aspire.md index 8bbd90a879..d668ff40fb 100644 --- a/docs/docs/examples/aspire.md +++ b/docs/docs/examples/aspire.md @@ -194,6 +194,8 @@ When a timeout occurs, the error includes: | Method | Default | Description | |--------|---------|-------------| +| `InitializeAsync()` | Full lifecycle | Override to add post-start logic (migrations, seeding) | +| `DisposeAsync()` | Stop and dispose app | Override to add custom cleanup | | `ConfigureBuilder(builder)` | No-op | Customize the builder before building | | `ResourceTimeout` | 60 seconds | How long to wait for startup and resources | | `WaitBehavior` | `AllHealthy` | Which resources to wait for | @@ -201,6 +203,32 @@ When a timeout occurs, the error includes: | `WaitForResourcesAsync(app, ct)` | Waits per `WaitBehavior` | Full control over resource waiting | | `LogProgress(message)` | Writes to stderr | Override to route progress logs elsewhere | +### Overriding the Lifecycle + +`InitializeAsync` and `DisposeAsync` are virtual, so you can add post-start or pre-dispose logic: + +```csharp +public class AppFixture : AspireFixture +{ + public override async Task InitializeAsync() + { + await base.InitializeAsync(); // Build, start, wait for resources + + // Post-start: run migrations, seed data, warm caches, etc. + var connectionString = await GetConnectionStringAsync("postgresdb"); + await RunMigrationsAsync(connectionString!); + await SeedTestDataAsync(connectionString!); + } + + public override async ValueTask DisposeAsync() + { + // Pre-dispose: dump diagnostics on failure, clean up external state, etc. + LogProgress("Cleaning up test data..."); + await base.DisposeAsync(); + } +} +``` + ## Watching Resource Logs Use `WatchResourceLogs()` inside a test to stream a resource's container logs to the test output. This is invaluable for debugging failures: From 6b31ef848544793c43d47478ab8b12d6217f3624 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:18:03 +0000 Subject: [PATCH 17/18] fix: update Aspire Starter template snapshot files The template was updated to use AspireFixture/AppFixture instead of GlobalSetup + HttpClientDataClass, but the verification snapshots weren't updated to match, causing template tests to fail. Co-Authored-By: Claude Opus 4.6 --- .../AppFixture.cs | 14 +++++++ .../Data/HttpClientDataClass.cs | 22 ----------- .../GlobalSetup.cs | 37 ------------------- .../TUnit.Aspire.Starter.TestProject.csproj | 7 +--- .../Tests/ApiTests.cs | 11 +++--- 5 files changed, 21 insertions(+), 70 deletions(-) create mode 100644 TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/AppFixture.cs delete mode 100644 TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Data/HttpClientDataClass.cs delete mode 100644 TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/GlobalSetup.cs diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/AppFixture.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/AppFixture.cs new file mode 100644 index 0000000000..7737f9ace1 --- /dev/null +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/AppFixture.cs @@ -0,0 +1,14 @@ +using TUnit.Aspire; + +namespace TUnit.Aspire.Starter.TestProject; + +public class AppFixture : AspireFixture +{ + protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) + { + builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + } +} diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Data/HttpClientDataClass.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Data/HttpClientDataClass.cs deleted file mode 100644 index 4b98b98641..0000000000 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Data/HttpClientDataClass.cs +++ /dev/null @@ -1,22 +0,0 @@ -using TUnit.Core.Interfaces; - -namespace TUnit.Aspire.Starter.TestProject.Data -{ - public class HttpClientDataClass : IAsyncInitializer, IAsyncDisposable - { - public HttpClient HttpClient { get; private set; } = new(); - public async Task InitializeAsync() - { - HttpClient = (GlobalSetup.App ?? throw new NullReferenceException()).CreateHttpClient("apiservice"); - if (GlobalSetup.NotificationService != null) - { - await GlobalSetup.NotificationService.WaitForResourceAsync("apiservice", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - } - } - - public async ValueTask DisposeAsync() - { - await Console.Out.WriteLineAsync("And when the class is finished with, we can clean up any resources."); - } - } -} diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/GlobalSetup.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/GlobalSetup.cs deleted file mode 100644 index 48d85eeac4..0000000000 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/GlobalSetup.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Here you could define global logic that would affect all tests - -// You can use attributes at the assembly level to apply to all tests in the assembly - -using Aspire.Hosting; - -[assembly: Retry(3)] -[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - -namespace TUnit.Aspire.Starter.TestProject; - -public class GlobalSetup -{ - public static DistributedApplication? App { get; private set; } - public static ResourceNotificationService? NotificationService { get; private set; } - - [Before(TestSession)] - public static async Task SetUp() - { - // Arrange - var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); - appHost.Services.ConfigureHttpClientDefaults(clientBuilder => - { - clientBuilder.AddStandardResilienceHandler(); - }); - - App = await appHost.BuildAsync(); - NotificationService = App.Services.GetRequiredService(); - await App.StartAsync(); - } - - [After(TestSession)] - public static void CleanUp() - { - Console.WriteLine("...and after!"); - } -} diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj index 241b9eb367..7e57bf0ce9 100644 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,13 +8,10 @@ true - - - + - diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Tests/ApiTests.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Tests/ApiTests.cs index 59002ddca2..83efd2b8f7 100644 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Tests/ApiTests.cs +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/Tests/ApiTests.cs @@ -1,17 +1,16 @@ -using System.Text.Json; -using TUnit.Aspire.Starter.TestProject.Data; +using System.Text.Json; using TUnit.Aspire.Starter.TestProject.Models; namespace TUnit.Aspire.Starter.TestProject.Tests; -[ClassDataSource] -public class ApiTests(HttpClientDataClass httpClientData) +[ClassDataSource(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 @@ -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(); From c893ee8768c95004f0473ae843aec024f27b2d1e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:13:39 +0000 Subject: [PATCH 18/18] fix: update template snapshot verified files for TUnit.Aspire changes - Scrub TUnit.Aspire version to 1.0.0 in Aspire Starter TestProject csproj - Update Aspire Test template snapshots to use TUnit.Aspire package - Remove old GlobalSetup.cs and HttpClientDataClass.cs from Aspire Test snapshots - Update IntegrationTest1.cs snapshot to match new AspireFixture-based template Co-Authored-By: Claude Opus 4.6 --- .../TUnit.Aspire.Starter.TestProject.csproj | 2 +- .../Data/HttpClientDataClass.cs | 22 ------- .../TUnit.Aspire.Test/GlobalSetup.cs | 38 ----------- .../TUnit.Aspire.Test/IntegrationTest1.cs | 65 ++++++++++++------- .../TUnit.Aspire.Test.csproj | 5 +- 5 files changed, 44 insertions(+), 88 deletions(-) delete mode 100644 TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/Data/HttpClientDataClass.cs delete mode 100644 TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/GlobalSetup.cs diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj index 7e57bf0ce9..026d0c8dd8 100644 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Starter._.verified/TUnit.Aspire.Starter/TUnit.Aspire.Starter.TestProject/TUnit.Aspire.Starter.TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/Data/HttpClientDataClass.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/Data/HttpClientDataClass.cs deleted file mode 100644 index c9403da841..0000000000 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/Data/HttpClientDataClass.cs +++ /dev/null @@ -1,22 +0,0 @@ -using TUnit.Core.Interfaces; - -namespace TUnit.Aspire.Test.Data -{ - public class HttpClientDataClass : IAsyncInitializer, IAsyncDisposable - { - public HttpClient HttpClient { get; private set; } = new(); - public async Task InitializeAsync() - { - HttpClient = (GlobalHooks.App ?? throw new NullReferenceException()).CreateHttpClient("webfrontend"); - if (GlobalHooks.NotificationService != null) - { - await GlobalHooks.NotificationService.WaitForResourceAsync("webfrontend", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - } - } - - public async ValueTask DisposeAsync() - { - await Console.Out.WriteLineAsync("And when the class is finished with, we can clean up any resources."); - } - } -} diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/GlobalSetup.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/GlobalSetup.cs deleted file mode 100644 index 7ae3a6f0cd..0000000000 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/GlobalSetup.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Here you could define global logic that would affect all tests - -// You can use attributes at the assembly level to apply to all tests in the assembly - -using Aspire.Hosting; - -[assembly: Retry(3)] -[assembly: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - -namespace TUnit.Aspire.Test; - -public class GlobalHooks -{ - public static DistributedApplication? App { get; private set; } - public static ResourceNotificationService? NotificationService { get; private set; } - - // Uncomment out and replace Projects reference with your app host - //[Before(TestSession)] - //public static async Task SetUp() - //{ - // // Arrange - // var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); - // appHost.Services.ConfigureHttpClientDefaults(clientBuilder => - // { - // clientBuilder.AddStandardResilienceHandler(); - // }); - - // App = await appHost.BuildAsync(); - // NotificationService = App.Services.GetRequiredService(); - // await App.StartAsync(); - //} - - [After(TestSession)] - public static void CleanUp() - { - Console.WriteLine("...and after!"); - } -} diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/IntegrationTest1.cs b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/IntegrationTest1.cs index 1222d172e5..29a2b5d657 100644 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/IntegrationTest1.cs +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/IntegrationTest1.cs @@ -1,26 +1,43 @@ -namespace TUnit.Aspire.Test +using TUnit.Aspire; + +namespace TUnit.Aspire.Test; + +// Instructions: +// 1. Add a project reference to the target AppHost project, e.g.: +// +// +// +// +// +// 2. Create a fixture class for your AppHost (or use AspireFixture directly): +// +// public class AppFixture : AspireFixture +// { +// protected override void ConfigureBuilder(IDistributedApplicationTestingBuilder builder) +// { +// builder.Services.ConfigureHttpClientDefaults(clientBuilder => +// { +// clientBuilder.AddStandardResilienceHandler(); +// }); +// } +// } +// +// 3. Uncomment the following example test and update 'AppFixture' to match your fixture: +// +//[ClassDataSource(Shared = SharedType.PerTestSession)] +//public class IntegrationTest1(AppFixture fixture) +//{ +// [Test] +// public async Task GetWebResourceRootReturnsOkStatusCode() +// { +// // Arrange +// var httpClient = fixture.CreateHttpClient("webfrontend"); +// // Act +// var response = await httpClient.GetAsync("/"); +// // Assert +// await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); +// } +//} +public class IntegrationTest1 { - public class IntegrationTest1 - { - // Instructions: - // 1. Add a project reference to the target AppHost project, e.g.: - // - // - // - // - // - // 2. Uncomment the following example test and update 'Projects.MyAspireApp_AppHost' in GlobalSetup.cs to match your AppHost project: - // - //[ClassDataSource] - //[Test] - //public async Task GetWebResourceRootReturnsOkStatusCode(HttpClientDataClass httpClientData) - //{ - // // Arrange - // var httpClient = httpClientData.HttpClient; - // // Act - // var response = await httpClient.GetAsync("/"); - // // Assert - // await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - //} - } } diff --git a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/TUnit.Aspire.Test.csproj b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/TUnit.Aspire.Test.csproj index 3646299f7b..40f4a6931d 100644 --- a/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/TUnit.Aspire.Test.csproj +++ b/TUnit.Templates.Tests/Snapshots/InstantiationTest.TUnit.Aspire.Test._.verified/TUnit.Aspire.Test/TUnit.Aspire.Test.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -9,8 +9,7 @@ - - +