diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml
index 170bb18fcc..7c17af6b86 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
@@ -36,3 +36,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/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..2c0a23032e
--- /dev/null
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -0,0 +1,507 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text;
+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) ---
+
+ private static readonly Stream StdErr = Console.OpenStandardError();
+
+ ///
+ /// Logs progress messages during initialization. Override to route to a custom logger.
+ /// 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)
+ {
+ var bytes = Encoding.UTF8.GetBytes($"[Aspire] {message}{Environment.NewLine}");
+ StdErr.Write(bytes, 0, bytes.Length);
+ StdErr.Flush();
+ }
+
+ ///
+ /// 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();
+ var names = model.Resources.Select(r => r.Name).ToList();
+ await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: true, cancellationToken);
+ break;
+ }
+
+ case ResourceWaitBehavior.AllRunning:
+ {
+ var model = app.Services.GetRequiredService();
+ var names = model.Resources.Select(r => r.Name).ToList();
+ await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: false, cancellationToken);
+ break;
+ }
+
+ case ResourceWaitBehavior.Named:
+ {
+ var names = ResourcesToWaitFor().ToList();
+ await WaitForResourcesWithFailFastAsync(app, notificationService, names, waitForHealthy: true, cancellationToken);
+ break;
+ }
+
+ case ResourceWaitBehavior.None:
+ break;
+ }
+ }
+
+ // --- Lifecycle ---
+
+ ///
+ /// 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();
+
+ 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}]");
+
+ // 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
+ {
+ await _app.StartAsync(startCts.Token);
+ }
+ catch (OperationCanceledException) when (startCts.IsCancellationRequested)
+ {
+ 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);
+
+ 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 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.");
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ LogProgress("Stopping application...");
+ await _app.StopAsync();
+ LogProgress("Disposing application...");
+ await _app.DisposeAsync();
+ _app = null;
+ LogProgress("Application disposed.");
+ }
+
+ 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
+ /// immediately with its recent logs. On timeout, reports which resources are still
+ /// pending and includes their logs.
+ ///
+ private async Task WaitForResourcesWithFailFastAsync(
+ DistributedApplication app,
+ ResourceNotificationService notificationService,
+ List resourceNames,
+ bool waitForHealthy,
+ CancellationToken cancellationToken)
+ {
+ if (resourceNames.Count == 0)
+ {
+ 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);
+
+ 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 =>
+ {
+ if (waitForHealthy)
+ {
+ await notificationService.WaitForResourceHealthyAsync(name, cancellationToken);
+ }
+ else
+ {
+ await notificationService.WaitForResourceAsync(name, KnownResourceStates.Running, cancellationToken);
+ }
+
+ readyResources.Add(name);
+ LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{resourceNames.Count})");
+ }));
+
+ // 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);
+
+ 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);
+ }
+
+ // 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 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 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));
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
+ 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.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")]
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..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
@@ -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();
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 @@
-
-
+
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
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.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/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
diff --git a/docs/docs/examples/aspire.md b/docs/docs/examples/aspire.md
new file mode 100644
index 0000000000..d668ff40fb
--- /dev/null
+++ b/docs/docs/examples/aspire.md
@@ -0,0 +1,524 @@
+# 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 |
+|--------|---------|-------------|
+| `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 |
+| `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 |
+
+### 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:
+
+```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',
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();
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..ffc8c39d6c 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(5);
- 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();
- }
- }
}