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