diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index f9c2388445..ddd7139fd6 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -116,6 +116,10 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks + // This ensures resources like Docker containers are not started until needed + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); + #if NET if (TUnitActivitySource.Source.HasListeners()) { @@ -136,10 +140,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( } #endif - // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks - // This ensures resources like Docker containers are not started until needed - await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); - executableTest.Context.RestoreExecutionContext(); // Early stage test start receivers run before instance-level hooks diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index 73fbb7fbc2..ad6c1f161b 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,5 +1,8 @@ using TUnit.Core; using TUnit.Engine.Services; +#if NET +using System.Diagnostics; +#endif namespace TUnit.Engine; @@ -32,7 +35,27 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Initialize test objects (IAsyncInitializer) - called after BeforeClass hooks + // Data source initialization runs before the test case span starts, so any spans it + // creates (container startup, auth calls, connection pools, etc.) do not appear nested + // inside the individual test's trace timeline. We briefly set Activity.Current to the + // session span so those spans are parented there instead. +#if NET + var sessionActivity = test.Context.ClassContext.AssemblyContext.TestSessionContext.Activity; + var previousActivity = Activity.Current; + if (sessionActivity is not null) + { + Activity.Current = sessionActivity; + } + try + { + await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); + } + finally + { + Activity.Current = previousActivity; + } +#else await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); +#endif } } diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Traces/CheckoutTraceDemoTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Traces/CheckoutTraceDemoTests.cs new file mode 100644 index 0000000000..e4460764cc --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Traces/CheckoutTraceDemoTests.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Infrastructure; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace CloudShop.Tests.Tests.Traces; + +/// +/// Demonstrates TUnit's trace capture in the HTML report. +/// +/// The test drives the API using HTTP calls — the application handles its own +/// Redis caching and database queries internally. Custom spans provide semantic +/// context around the steps of the checkout flow so the trace timeline tells +/// a readable story. +/// +/// Run explicitly to see the trace timeline in the generated HTML report. +/// +[Category("Integration"), Category("Traces")] +public class CheckoutTraceDemoTests +{ + private static readonly ActivitySource Source = new("CloudShop.Tests.Checkout"); + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [Test] + [Explicit] + [DisplayName("Checkout fails when requested quantity exceeds available stock")] + public async Task Checkout_FailsWhenRequestedQuantityExceedsStock() + { + // Step 1: Browse the catalogue — the API queries PostgreSQL and returns products + ProductResponse product; + using (var browseSpan = Source.StartActivity("browse.list_products")) + { + var catalogue = await Customer.Client + .GetFromJsonAsync>("/api/products?category=electronics&pageSize=1"); + product = catalogue!.Items.First(); + browseSpan?.SetTag("product.id", product.Id); + browseSpan?.SetTag("product.stock_available", product.StockQuantity); + } + + // Step 2: View the product detail — the API checks Redis, falls back to PostgreSQL on + // a cold cache, then caches the result for subsequent requests + using (var detailSpan = Source.StartActivity("browse.get_product_detail")) + { + await Customer.Client.GetAsync($"/api/products/{product.Id}"); + detailSpan?.SetTag("product.id", product.Id); + } + + // Step 3: View it again — this time the API serves it directly from Redis + using (var cachedSpan = Source.StartActivity("browse.get_product_detail_cached")) + { + await Customer.Client.GetAsync($"/api/products/{product.Id}"); + cachedSpan?.SetTag("product.id", product.Id); + cachedSpan?.SetTag("cache.expected", true); + } + + // Step 4: Attempt to order more units than are in stock — the API validates against + // the database and rejects the request + var requestedQuantity = product.StockQuantity + 500; + using var orderSpan = Source.StartActivity("checkout.create_order"); + orderSpan?.SetTag("product.id", product.Id); + orderSpan?.SetTag("order.quantity_requested", requestedQuantity); + orderSpan?.SetTag("order.stock_available", product.StockQuantity); + + var orderResponse = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest( + [new OrderItemRequest(product.Id, requestedQuantity)], + "credit_card", + "standard")); + + if (!orderResponse.IsSuccessStatusCode) + { + var body = await orderResponse.Content.ReadAsStringAsync(); + orderSpan?.SetStatus(ActivityStatusCode.Error, "Order rejected: insufficient stock"); + orderSpan?.SetTag("error.message", body); + } + + // This assertion fails — we deliberately requested more than was available + await Assert.That(orderResponse.StatusCode).IsEqualTo(HttpStatusCode.Created); + } +}