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(); - } - } }