From cde8bc2a37c1f8df8b0d72f0c42d1c493ea76b66 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 15:01:36 +0000
Subject: [PATCH 01/28] Track initialization and disposal times with
OpenTelemetry activity spans
- Move "test case" activity span to start before data source initialization
- Add "data source initialization" child activity span in TestExecutor
- Add "test instance disposal" activity span in TestCoordinator
- Set TestStart before initialization so HTML report duration includes init time
- Simplify TestInitializer now that test case span covers initialization
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/7e7dc4cb-23dd-4723-ba82-6355cfc6d354
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Services/TestExecution/TestCoordinator.cs | 23 +++++++++++
TUnit.Engine/TestExecutor.cs | 39 +++++++++++++++----
TUnit.Engine/TestInitializer.cs | 28 ++-----------
3 files changed, 59 insertions(+), 31 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index cc79d87fb7..e006e6ef11 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -6,6 +6,9 @@
using TUnit.Engine.Helpers;
using TUnit.Engine.Interfaces;
using TUnit.Engine.Logging;
+#if NET
+using System.Diagnostics;
+#endif
namespace TUnit.Engine.Services.TestExecution;
@@ -301,6 +304,19 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
}
finally
{
+#if NET
+ Activity? disposalActivity = null;
+ if (TUnitActivitySource.Source.HasListeners())
+ {
+ disposalActivity = TUnitActivitySource.StartActivity(
+ "test instance disposal",
+ ActivityKind.Internal,
+ test.Context.ClassContext.Activity?.Context ?? default,
+ [new("tunit.test.id", test.Context.Id)]);
+ }
+ try
+ {
+#endif
// Dispose test instance and fire OnDispose after each attempt
// This ensures each retry gets a fresh instance
var onDispose = test.Context.InternalEvents.OnDispose;
@@ -327,6 +343,13 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
{
await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false);
}
+#if NET
+ }
+ finally
+ {
+ TUnitActivitySource.StopActivity(disposalActivity);
+ }
+#endif
}
}
}
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index 4f1336b6ab..34582085b9 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -116,10 +116,6 @@ 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())
{
@@ -140,6 +136,38 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
}
#endif
+ // Set test start time before initialization so the HTML report duration
+ // includes data source initialization time
+ executableTest.Context.TestStart = DateTimeOffset.UtcNow;
+
+ // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks
+ // This ensures resources like Docker containers are not started until needed
+#if NET
+ Activity? initActivity = null;
+ if (TUnitActivitySource.Source.HasListeners())
+ {
+ initActivity = TUnitActivitySource.StartActivity(
+ "data source initialization",
+ ActivityKind.Internal,
+ executableTest.Context.Activity?.Context ?? default);
+ }
+ try
+ {
+ await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ TUnitActivitySource.RecordException(initActivity, ex);
+ throw;
+ }
+ finally
+ {
+ TUnitActivitySource.StopActivity(initActivity);
+ }
+#else
+ await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
+#endif
+
executableTest.Context.RestoreExecutionContext();
// Early stage test start receivers run before instance-level hooks
@@ -320,9 +348,6 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl
return;
}
- // Set the test start time when we actually begin executing the test
- executableTest.Context.TestStart = DateTimeOffset.UtcNow;
-
// Set the cancellation token on the context so source-generated tests can access it
executableTest.Context.CancellationToken = cancellationToken;
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index ad6c1f161b..aa98cef708 100644
--- a/TUnit.Engine/TestInitializer.cs
+++ b/TUnit.Engine/TestInitializer.cs
@@ -1,8 +1,5 @@
using TUnit.Core;
using TUnit.Engine.Services;
-#if NET
-using System.Diagnostics;
-#endif
namespace TUnit.Engine;
@@ -35,27 +32,10 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat
public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
- // 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
+ // Data source initialization now runs inside the test case span, so any spans it
+ // creates (container startup, auth calls, connection pools, etc.) will appear nested
+ // inside the test's trace timeline via the "data source initialization" child activity
+ // created by TestExecutor.
await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken);
-#endif
}
}
From d8ee5eb5c4db391a73d7db466d804ffa5e4cf363 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 15:10:37 +0000
Subject: [PATCH 02/28] Address code review: rename activity, add explanatory
comments
- Rename "data source initialization" to "test object initialization" for accuracy
- Add comment explaining TestStart semantic change (now includes init time)
- Add comment explaining why disposal activity is parented under class activity
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/7e7dc4cb-23dd-4723-ba82-6355cfc6d354
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Engine/Services/TestExecution/TestCoordinator.cs | 3 +++
TUnit.Engine/TestExecutor.cs | 6 ++++--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index e006e6ef11..0fffe17156 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -308,6 +308,9 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
Activity? disposalActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
+ // Parented under the class activity because the test case activity has already
+ // been stopped by this point (disposal runs after TestExecutor.ExecuteAsync completes).
+ // The initialization activity is a child of the test case span since it runs within it.
disposalActivity = TUnitActivitySource.StartActivity(
"test instance disposal",
ActivityKind.Internal,
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index 34582085b9..e3d0cfdcae 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -137,7 +137,9 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
#endif
// Set test start time before initialization so the HTML report duration
- // includes data source initialization time
+ // includes data source initialization time. This means "test start" reflects
+ // the beginning of the full test lifecycle (init + hooks + body), not just
+ // the test body execution.
executableTest.Context.TestStart = DateTimeOffset.UtcNow;
// Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks
@@ -147,7 +149,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
if (TUnitActivitySource.Source.HasListeners())
{
initActivity = TUnitActivitySource.StartActivity(
- "data source initialization",
+ "test object initialization",
ActivityKind.Internal,
executableTest.Context.Activity?.Context ?? default);
}
From 7657adf6631c29b29ec16457484b0cf3502f3a71 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 22:09:14 +0000
Subject: [PATCH 03/28] Address PR review: extract disposal helper, add error
recording, fix stale comment
- Extract disposal span logic into DisposeTestInstanceWithSpanAsync() helper to
reduce #if NET nesting in the finally block (review issue #1)
- Add TUnitActivitySource.RecordException() calls on disposal span when OnDispose
or DisposeTestInstance throws (review issue #2)
- Fix stale comment in TestInitializer.cs referencing "data source initialization"
instead of "test object initialization" (review issue #4)
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/3802acb9-a7fe-4976-8c3f-5bb246c098d2
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Services/TestExecution/TestCoordinator.cs | 94 +++++++++++--------
TUnit.Engine/TestInitializer.cs | 2 +-
2 files changed, 55 insertions(+), 41 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index 0fffe17156..a953a24b01 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -304,55 +304,69 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
}
finally
{
+ await DisposeTestInstanceWithSpanAsync(test).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Disposes the test instance and fires OnDispose callbacks, wrapped in an OpenTelemetry
+ /// activity span for trace timeline visibility.
+ /// Parented under the class activity because the test case activity has already been stopped
+ /// by this point (disposal runs after TestExecutor.ExecuteAsync completes).
+ ///
+ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test)
+ {
#if NET
- Activity? disposalActivity = null;
- if (TUnitActivitySource.Source.HasListeners())
- {
- // Parented under the class activity because the test case activity has already
- // been stopped by this point (disposal runs after TestExecutor.ExecuteAsync completes).
- // The initialization activity is a child of the test case span since it runs within it.
- disposalActivity = TUnitActivitySource.StartActivity(
- "test instance disposal",
- ActivityKind.Internal,
- test.Context.ClassContext.Activity?.Context ?? default,
- [new("tunit.test.id", test.Context.Id)]);
- }
- try
- {
+ Activity? disposalActivity = null;
+ if (TUnitActivitySource.Source.HasListeners())
+ {
+ disposalActivity = TUnitActivitySource.StartActivity(
+ "test instance disposal",
+ ActivityKind.Internal,
+ test.Context.ClassContext.Activity?.Context ?? default,
+ [new("tunit.test.id", test.Context.Id)]);
+ }
+ try
+ {
#endif
- // Dispose test instance and fire OnDispose after each attempt
- // This ensures each retry gets a fresh instance
- var onDispose = test.Context.InternalEvents.OnDispose;
- if (onDispose?.InvocationList != null)
+ // Dispose test instance and fire OnDispose after each attempt
+ // This ensures each retry gets a fresh instance
+ var onDispose = test.Context.InternalEvents.OnDispose;
+ if (onDispose?.InvocationList != null)
+ {
+ foreach (var invocation in onDispose.InvocationList)
{
- foreach (var invocation in onDispose.InvocationList)
+ try
{
- try
- {
- await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false);
- }
- catch (Exception disposeEx)
- {
- await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false);
- }
+ await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false);
+ }
+ catch (Exception disposeEx)
+ {
+#if NET
+ TUnitActivitySource.RecordException(disposalActivity, disposeEx);
+#endif
+ await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false);
}
}
+ }
- try
- {
- await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false);
- }
- catch (Exception disposeEx)
- {
- await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false);
- }
+ try
+ {
+ await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false);
+ }
+ catch (Exception disposeEx)
+ {
#if NET
- }
- finally
- {
- TUnitActivitySource.StopActivity(disposalActivity);
- }
+ TUnitActivitySource.RecordException(disposalActivity, disposeEx);
#endif
+ await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false);
+ }
+#if NET
}
+ finally
+ {
+ TUnitActivitySource.StopActivity(disposalActivity);
+ }
+#endif
}
}
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index aa98cef708..da45407880 100644
--- a/TUnit.Engine/TestInitializer.cs
+++ b/TUnit.Engine/TestInitializer.cs
@@ -34,7 +34,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C
{
// Data source initialization now runs inside the test case span, so any spans it
// creates (container startup, auth calls, connection pools, etc.) will appear nested
- // inside the test's trace timeline via the "data source initialization" child activity
+ // inside the test's trace timeline via the "test object initialization" child activity
// created by TestExecutor.
await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken);
}
From f4e70bb31f6899e2c0df5088367bf5a8c0623755 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 22:40:40 +0000
Subject: [PATCH 04/28] Move init/disposal spans outside test case, add type
names, restore TestStart
- Initialization span now parented under session activity (not test case) so slow
infrastructure setup doesn't inflate the test's reported duration
- Restore TestStart to just before test body execution
- Span names include the class type: "initialize {TypeName}", "dispose {TypeName}"
- Restore Activity.Current swapping in TestInitializer.cs for session-level parenting
- Error recording preserved on both init and disposal spans
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/26cf2c58-e50d-44f7-b5d4-34f5bdc54f52
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Services/TestExecution/TestCoordinator.cs | 3 +-
TUnit.Engine/TestExecutor.cs | 43 ++++-------------
TUnit.Engine/TestInitializer.cs | 48 +++++++++++++++++--
3 files changed, 55 insertions(+), 39 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index a953a24b01..07803ee940 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -320,8 +320,9 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest
Activity? disposalActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
+ var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
disposalActivity = TUnitActivitySource.StartActivity(
- "test instance disposal",
+ $"dispose {typeName}",
ActivityKind.Internal,
test.Context.ClassContext.Activity?.Context ?? default,
[new("tunit.test.id", test.Context.Id)]);
diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs
index e3d0cfdcae..9e62882d1a 100644
--- a/TUnit.Engine/TestExecutor.cs
+++ b/TUnit.Engine/TestExecutor.cs
@@ -116,6 +116,12 @@ 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.
+ // The init span is parented under the session activity (not the test case span)
+ // so that slow infrastructure setup doesn't inflate the test's reported duration.
+ await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
+
#if NET
if (TUnitActivitySource.Source.HasListeners())
{
@@ -136,40 +142,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
}
#endif
- // Set test start time before initialization so the HTML report duration
- // includes data source initialization time. This means "test start" reflects
- // the beginning of the full test lifecycle (init + hooks + body), not just
- // the test body execution.
- executableTest.Context.TestStart = DateTimeOffset.UtcNow;
-
- // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks
- // This ensures resources like Docker containers are not started until needed
-#if NET
- Activity? initActivity = null;
- if (TUnitActivitySource.Source.HasListeners())
- {
- initActivity = TUnitActivitySource.StartActivity(
- "test object initialization",
- ActivityKind.Internal,
- executableTest.Context.Activity?.Context ?? default);
- }
- try
- {
- await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- TUnitActivitySource.RecordException(initActivity, ex);
- throw;
- }
- finally
- {
- TUnitActivitySource.StopActivity(initActivity);
- }
-#else
- await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
-#endif
-
executableTest.Context.RestoreExecutionContext();
// Early stage test start receivers run before instance-level hooks
@@ -350,6 +322,9 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl
return;
}
+ // Set the test start time when we actually begin executing the test
+ executableTest.Context.TestStart = DateTimeOffset.UtcNow;
+
// Set the cancellation token on the context so source-generated tests can access it
executableTest.Context.CancellationToken = cancellationToken;
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index da45407880..9bd60ab1fb 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,10 +35,47 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat
public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
- // Data source initialization now runs inside the test case span, so any spans it
- // creates (container startup, auth calls, connection pools, etc.) will appear nested
- // inside the test's trace timeline via the "test object initialization" child activity
- // created by TestExecutor.
+ // Object 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;
+ }
+
+ var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
+ Activity? initActivity = null;
+ if (TUnitActivitySource.Source.HasListeners())
+ {
+ initActivity = TUnitActivitySource.StartActivity(
+ $"initialize {typeName}",
+ ActivityKind.Internal,
+ sessionActivity?.Context ?? default,
+ [
+ new("tunit.test.id", test.Context.Id),
+ new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName)
+ ]);
+ }
+ try
+ {
+ await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ TUnitActivitySource.RecordException(initActivity, ex);
+ throw;
+ }
+ finally
+ {
+ TUnitActivitySource.StopActivity(initActivity);
+ Activity.Current = previousActivity;
+ }
+#else
await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken);
+#endif
}
}
From f905adf2b8e0586dd377c6cc97aa31dd3726807d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 23:04:05 +0000
Subject: [PATCH 05/28] Address latest review: fix dead code, add missing tags,
set Activity.Current, use FullName
- Remove dead RecordException for DisposeTestInstance (swallows all exceptions)
- Add tunit.test.class tag to dispose span for consistent trace filtering
- Set Activity.Current = disposalActivity during disposal for child span parenting
- Use FullName ?? Name for span names to handle nested/generic types
- Restore Activity.Current after disposal completes
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/172f0096-6317-4169-8b89-b8bc122de149
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Services/TestExecution/TestCoordinator.cs | 29 ++++++++++---------
TUnit.Engine/TestInitializer.cs | 2 +-
2 files changed, 17 insertions(+), 14 deletions(-)
diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
index 07803ee940..1ff324632b 100644
--- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
+++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
@@ -318,15 +318,25 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest
{
#if NET
Activity? disposalActivity = null;
+ var previousActivity = Activity.Current;
if (TUnitActivitySource.Source.HasListeners())
{
- var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
+ var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name;
disposalActivity = TUnitActivitySource.StartActivity(
$"dispose {typeName}",
ActivityKind.Internal,
test.Context.ClassContext.Activity?.Context ?? default,
- [new("tunit.test.id", test.Context.Id)]);
+ [
+ new("tunit.test.id", test.Context.Id),
+ new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName)
+ ]);
}
+
+ if (disposalActivity is not null)
+ {
+ Activity.Current = disposalActivity;
+ }
+
try
{
#endif
@@ -351,22 +361,15 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest
}
}
- try
- {
- await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false);
- }
- catch (Exception disposeEx)
- {
-#if NET
- TUnitActivitySource.RecordException(disposalActivity, disposeEx);
-#endif
- await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false);
- }
+ // Note: DisposeTestInstance swallows all exceptions internally (bare catch {}),
+ // so no error recording is needed here — exceptions never propagate.
+ await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false);
#if NET
}
finally
{
TUnitActivitySource.StopActivity(disposalActivity);
+ Activity.Current = previousActivity;
}
#endif
}
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index 9bd60ab1fb..a10445b904 100644
--- a/TUnit.Engine/TestInitializer.cs
+++ b/TUnit.Engine/TestInitializer.cs
@@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C
Activity.Current = sessionActivity;
}
- var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
+ var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name;
Activity? initActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
From fa7d05b59f131c68b43186f0b14a631316a180a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 06:57:20 +0000
Subject: [PATCH 06/28] Add initialization and disposal spans to HTML report
execution timeline
Include 'initialize {TypeName}' and 'dispose {TypeName}' spans in the
global Execution Timeline alongside session, assembly, and suite spans.
Extracted isGlobalTimelineSpan() helper for readability.
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/472d120e-6d02-435d-bc01-4fbeb030c0ff
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index 887df5d4a2..30ca6ccb5a 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1476,9 +1476,13 @@ function renderSuiteTrace(className) {
return '
' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
';
}
-// Global timeline: session + assembly + suite spans
+// Global timeline: session + assembly + suite + initialize/dispose spans
+function isGlobalTimelineSpan(s) {
+ var t = s.spanType;
+ return t === 'test session' || t === 'test assembly' || t === 'test suite' || (t && (t.startsWith('initialize ') || t.startsWith('dispose ')));
+}
function renderGlobalTimeline() {
- const topSpans = spans.filter(s => s.spanType === 'test session' || s.spanType === 'test assembly' || s.spanType === 'test suite');
+ const topSpans = spans.filter(isGlobalTimelineSpan);
if (!topSpans.length) return '';
return '' + tlArrow + 'Execution Timeline
' + renderSpanRows(topSpans, 'global') + '
';
}
From d9662364f35cbab82ea02ecd8d7bc90c9aaca0d5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 2 Apr 2026 08:00:12 +0100
Subject: [PATCH 07/28] Remove full name from class type in TestInitializer
---
TUnit.Engine/TestInitializer.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index a10445b904..9bd60ab1fb 100644
--- a/TUnit.Engine/TestInitializer.cs
+++ b/TUnit.Engine/TestInitializer.cs
@@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C
Activity.Current = sessionActivity;
}
- var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name;
+ var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
Activity? initActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
From 6f8e89b313861683d2f807008e480b8fd402576f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 08:01:04 +0000
Subject: [PATCH 08/28] Restore FullName ?? Name in TestInitializer.cs to fix
regression
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ad55c3d7-39fe-46bd-b02e-b217cf7ec4c8
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Engine/TestInitializer.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs
index 9bd60ab1fb..a10445b904 100644
--- a/TUnit.Engine/TestInitializer.cs
+++ b/TUnit.Engine/TestInitializer.cs
@@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C
Activity.Current = sessionActivity;
}
- var typeName = test.Context.Metadata.TestDetails.ClassType.Name;
+ var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name;
Activity? initActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
From 3c3097b2d730eb30cb1997b09d385de9e6d3962b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 08:51:20 +0000
Subject: [PATCH 09/28] Implement scope-aware init/dispose tracing based on
SharedType
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add TraceScopeRegistry to map data source objects to their SharedType
- Register scope in ClassDataSources.Get based on SharedType
- Move test case activity start before initialization so per-test
init spans can be children of the test case
- Create per-object init spans with scope-aware parent selection:
- PerTestSession → session activity
- PerAssembly → assembly activity
- PerClass → class activity
- None/Keyed/default → test case activity
- Add tunit.trace.scope tag to init/dispose spans
- Update HTML report isGlobalTimelineSpan() to show only shared
init spans in global timeline (not per-test ones)
- Simplify TestInitializer (tracing now in ObjectLifecycleService)
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ccdbc811-2561-405a-87e0-7152014e4e69
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../Attributes/TestData/ClassDataSources.cs | 19 ++-
TUnit.Core/TraceScopeRegistry.cs | 56 +++++++++
.../Reporters/Html/HtmlReportGenerator.cs | 10 +-
.../Services/ObjectLifecycleService.cs | 114 +++++++++++++++++-
.../Services/TestExecution/TestCoordinator.cs | 3 +-
TUnit.Engine/TestExecutor.cs | 15 ++-
TUnit.Engine/TestInitializer.cs | 48 +-------
7 files changed, 205 insertions(+), 60 deletions(-)
create mode 100644 TUnit.Core/TraceScopeRegistry.cs
diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs
index b90b8a3cd4..83d86e7b69 100644
--- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs
+++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs
@@ -42,7 +42,7 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys)
public T Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>(SharedType sharedType, Type testClassType, string key, DataGeneratorMetadata dataGeneratorMetadata)
{
- return sharedType switch
+ var instance = sharedType switch
{
SharedType.None => Create(),
SharedType.PerTestSession => (T) TestDataContainer.GetGlobalInstance(typeof(T), _ => Create(typeof(T)))!,
@@ -51,11 +51,17 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys)
SharedType.PerAssembly => (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), _ => Create(typeof(T)))!,
_ => throw new ArgumentOutOfRangeException()
};
+
+#if NET
+ TraceScopeRegistry.Register(instance!, sharedType);
+#endif
+
+ return instance;
}
public object? Get(SharedType sharedType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] Type type, Type testClassType, string? key, DataGeneratorMetadata dataGeneratorMetadata)
{
- return sharedType switch
+ var instance = sharedType switch
{
SharedType.None => Create(type),
SharedType.PerTestSession => TestDataContainer.GetGlobalInstance(type, _ => Create(type)),
@@ -64,6 +70,15 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys)
SharedType.PerAssembly => TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, _ => Create(type)),
_ => throw new ArgumentOutOfRangeException()
};
+
+#if NET
+ if (instance is not null)
+ {
+ TraceScopeRegistry.Register(instance, sharedType);
+ }
+#endif
+
+ return instance;
}
private static object CreateWithKey([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type type, string key)
diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs
new file mode 100644
index 0000000000..5d9ff2b9c7
--- /dev/null
+++ b/TUnit.Core/TraceScopeRegistry.cs
@@ -0,0 +1,56 @@
+#if NET
+using System.Collections.Concurrent;
+using TUnit.Core.Helpers;
+
+namespace TUnit.Core;
+
+///
+/// Thread-safe registry that maps data source objects to their trace scope.
+/// Used by the engine to parent initialization and disposal OpenTelemetry spans
+/// under the correct activity (session, assembly, class, or test).
+///
+///
+/// Objects are registered in based on their
+/// . The engine reads the scope during initialization
+/// to determine the parent activity for each object's trace span.
+/// Uses reference equality to distinguish distinct instances that may compare equal.
+///
+internal static class TraceScopeRegistry
+{
+ private static readonly ConcurrentDictionary