Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand All @@ -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
Expand Down
25 changes: 24 additions & 1 deletion TUnit.Engine/TestInitializer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using TUnit.Core;
using TUnit.Engine.Services;
#if NET
using System.Diagnostics;
#endif

namespace TUnit.Engine;

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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;

/// <summary>
/// Demonstrates TUnit's trace capture in the HTML report.
///
/// Showcases:
/// - Custom ActivitySource spans annotating each step of a checkout pipeline
/// - Direct database (PostgreSQL) and cache (Redis) access alongside HTTP calls
/// - Recording an error on a specific span when the operation fails
/// - How the HTML report surfaces the full trace timeline next to a failing test
/// </summary>
[Category("Integration"), Category("Traces")]
public class CheckoutTraceDemoTests
{
private static readonly ActivitySource Source = new("CloudShop.Tests.Checkout");

[ClassDataSource<CustomerApiClient>(Shared = SharedType.PerTestSession)]
public required CustomerApiClient Customer { get; init; }

[ClassDataSource<AdminApiClient>(Shared = SharedType.PerTestSession)]
public required AdminApiClient Admin { get; init; }

[ClassDataSource<RedisFixture>(Shared = SharedType.PerTestSession)]
public required RedisFixture Redis { get; init; }

[ClassDataSource<DatabaseFixture>(Shared = SharedType.PerTestSession)]
public required DatabaseFixture Database { get; init; }

[Test]
[Explicit]
[DisplayName("Checkout fails when requested quantity exceeds available stock")]
public async Task Checkout_FailsWhenRequestedQuantityExceedsStock()
{
// Step 1: Look up a product and its current stock level directly from PostgreSQL
var productId = await Database.QuerySingleAsync<int>(
"SELECT \"Id\" FROM \"Products\" WHERE \"Category\" = 'electronics' AND \"StockQuantity\" > 0 LIMIT 1");

int availableStock;
using (var dbSpan = Source.StartActivity("db.read_product_stock"))
{
availableStock = await Database.QuerySingleAsync<int>(
"SELECT \"StockQuantity\" FROM \"Products\" WHERE \"Id\" = @id",
("id", productId));

dbSpan?.SetTag("product.id", productId);
dbSpan?.SetTag("product.stock_available", availableStock);
}

// Step 2: Fetch the product through the API — this populates the Redis cache
using (var apiSpan = Source.StartActivity("api.get_product"))
{
var response = await Customer.Client.GetAsync($"/api/products/{productId}");
apiSpan?.SetTag("product.id", productId);
apiSpan?.SetTag("http.response.status_code", (int)response.StatusCode);
}

// Step 3: Confirm the product is now cached in Redis
using (var cacheSpan = Source.StartActivity("cache.check_product"))
{
var cached = await Redis.Database.StringGetAsync($"product:{productId}");
cacheSpan?.SetTag("cache.key", $"product:{productId}");
cacheSpan?.SetTag("cache.hit", cached.HasValue);
}

// Step 4: Try to order 500 more units than are actually in stock — the API rejects this
var requestedQuantity = availableStock + 500;

using var orderSpan = Source.StartActivity("api.create_order");
orderSpan?.SetTag("product.id", productId);
orderSpan?.SetTag("order.quantity_requested", requestedQuantity);
orderSpan?.SetTag("order.stock_available", availableStock);
orderSpan?.SetTag("order.overstock_by", 500);

var orderResponse = await Customer.Client.PostAsJsonAsync("/api/orders",
new CreateOrderRequest(
[new OrderItemRequest(productId, requestedQuantity)],
"credit_card",
"standard"));

if (!orderResponse.IsSuccessStatusCode)
{
var errorBody = await orderResponse.Content.ReadAsStringAsync();
orderSpan?.SetStatus(ActivityStatusCode.Error, "Order rejected: insufficient stock");
orderSpan?.SetTag("error.message", errorBody);
}

// This assertion fails — we deliberately ordered more than was available
await Assert.That(orderResponse.StatusCode).IsEqualTo(HttpStatusCode.Created);
}
}
Loading