Skip to content
35 changes: 33 additions & 2 deletions TUnit.Core/Models/TestDiscoveryContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,41 @@ public void AddTests(IEnumerable<TestContext> tests)
public required string? TestFilter { get; init; }

[field: AllowNull, MaybeNull]
public IEnumerable<AssemblyHookContext> Assemblies => field ??= TestClasses.Select(x => x.AssemblyContext).Distinct().ToArray();
public IEnumerable<AssemblyHookContext> Assemblies => field ??= BuildUniqueAssemblies();

[field: AllowNull, MaybeNull]
public IEnumerable<ClassHookContext> TestClasses => field ??= AllTests.Where(x => x.ClassContext != null).Select(x => x.ClassContext!).Distinct().ToArray();
public IEnumerable<ClassHookContext> TestClasses => field ??= BuildUniqueTestClasses();

private AssemblyHookContext[] BuildUniqueAssemblies()
{
// HashSet<T> iteration order is not contractually guaranteed; the parallel list
// preserves first-occurrence order so hook execution sequence stays deterministic.
var seen = new HashSet<AssemblyHookContext>();
var ordered = new List<AssemblyHookContext>();
foreach (var cls in TestClasses)
{
if (seen.Add(cls.AssemblyContext))
{
ordered.Add(cls.AssemblyContext);
}
}
return ordered.ToArray();
}

private ClassHookContext[] BuildUniqueTestClasses()
{
var seen = new HashSet<ClassHookContext>();
var ordered = new List<ClassHookContext>();
foreach (var test in AllTests)
{
var cls = test.ClassContext;
if (cls != null && seen.Add(cls))
{
ordered.Add(cls);
}
}
return ordered.ToArray();
}

public IReadOnlyList<TestContext> AllTests { get; private set; } = [];

Expand Down
11 changes: 11 additions & 0 deletions TUnit.Core/Models/TestSessionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ private void InvalidateCaches()

internal bool FirstTestStarted { get; set; }

private int _failureCount;

/// <summary>
/// True if any test in the session completed with Failed, Timeout, or Cancelled state.
/// Updated atomically by <see cref="MarkFailure"/>; avoids an O(N) AllTests traversal
/// in after-session hook paths.
/// </summary>
internal bool HasFailures => Volatile.Read(ref _failureCount) > 0;

internal void MarkFailure() => Interlocked.Increment(ref _failureCount);

internal readonly List<Artifact> Artifacts = [];

public void AddArtifact(Artifact artifact)
Expand Down
34 changes: 24 additions & 10 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ namespace TUnit.Core;
public partial class TestContext : Context,
ITestExecution, ITestParallelization, ITestOutput, ITestMetadata, ITestDependencies, ITestStateBag, ITestEvents, ITestIsolation
{
private static readonly ConcurrentDictionary<string, TestContext> _testContextsById = new();
private static readonly ConcurrentDictionary<Guid, TestContext> _testContextsByGuid = new();
private readonly TestBuilderContext _testBuilderContext;
private readonly Guid _idGuid;
private string? _idString;
private string? _cachedDisplayName;

public TestContext(string testName, IServiceProvider serviceProvider, ClassHookContext classContext, TestBuilderContext testBuilderContext, CancellationToken cancellationToken) : base(classContext)
Expand All @@ -42,17 +44,31 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC
ServiceProvider = serviceProvider;
ClassContext = classContext;

// Generate unique ID for this test instance
Id = Guid.NewGuid().ToString();
_idGuid = Guid.NewGuid();
IsolationUniqueId = Interlocked.Increment(ref _isolationIdCounter);

_testContextsById[Id] = this;
_testContextsByGuid[_idGuid] = this;
}

/// <summary>
/// Gets the unique identifier for this test instance.
/// The string form is materialized lazily on first access — most tests never need it
/// unless OTel is active or user code queries the context by Id.
/// </summary>
public string Id { get; }
public string Id
{
get
{
// Volatile read gives ARM/WASM acquire semantics without a volatile field.
if (Volatile.Read(ref _idString) is { } existing)
{
return existing;
}

var materialized = _idGuid.ToString();
return Interlocked.CompareExchange(ref _idString, materialized, null) ?? materialized;
}
}

/// <summary>
/// Gets access to test execution state, result management, cancellation, and retry information.
Expand Down Expand Up @@ -203,12 +219,10 @@ public ContextScope MakeCurrent()
/// </summary>
/// <param name="id">The unique identifier of the test context.</param>
/// <returns>The matching <see cref="TestContext"/>, or <c>null</c>.</returns>
public static TestContext? GetById(string id) => _testContextsById.GetValueOrDefault(id);
public static TestContext? GetById(string id) =>
Guid.TryParse(id, out var guid) ? _testContextsByGuid.GetValueOrDefault(guid) : null;

/// <summary>
/// Removes a test context from the static registry. Called when test execution completes.
/// </summary>
internal static void RemoveById(string id) => _testContextsById.TryRemove(id, out _);
internal void RemoveFromRegistry() => _testContextsByGuid.TryRemove(_idGuid, out _);

/// <summary>
/// Gets the dictionary of test parameters indexed by parameter name.
Expand Down
1 change: 1 addition & 0 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public async Task<bool> ScheduleAndExecuteAsync(
if (testsInCircularDependencies.Add(chainTest))
{
_testStateManager.MarkCircularDependencyFailed(chainTest, exception);
TestSessionContext.Current?.MarkFailure();
await _messageBus.Failed(chainTest.Context, exception, DateTimeOffset.UtcNow).ConfigureAwait(false);
}
}
Expand Down
29 changes: 17 additions & 12 deletions TUnit.Engine/Services/EventReceiverOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,17 @@ public async ValueTask InvokeHookRegistrationEventReceiversAsync(HookRegisteredC
// Pre-sort by Order before filtering so that FilterScopedAttributes (which uses TryAdd
// and keeps the first encountered item per ScopeType) retains the lowest-Order attribute.
// After filtering, sort the result in-place for final invocation order.
var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(
hookContext.HookMethod.Attributes
.OfType<IHookRegisteredEventReceiver>()
.OrderBy(static x => x.Order));
var sorted = new List<IHookRegisteredEventReceiver>();
foreach (var attr in hookContext.HookMethod.Attributes)
{
if (attr is IHookRegisteredEventReceiver receiver)
{
sorted.Add(receiver);
}
}
sorted.Sort(static (a, b) => a.Order.CompareTo(b.Order));

var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes<IHookRegisteredEventReceiver>(sorted);

Array.Sort(filteredReceivers, static (a, b) => a.Order.CompareTo(b.Order));

Expand Down Expand Up @@ -548,24 +555,22 @@ private async ValueTask InvokeLastTestInClassEventReceiversCore(
/// <summary>
/// Initialize test counts for first/last event receivers
/// </summary>
public void InitializeTestCounts(IEnumerable<TestContext> allTestContexts)
public void InitializeTestCounts(IReadOnlyList<AbstractExecutableTest> allTests)
{
var contexts = allTestContexts as IList<TestContext> ?? allTestContexts.ToList();
Interlocked.Exchange(ref _sessionTestCount, contexts.Count);
Interlocked.Exchange(ref _sessionTestCount, allTests.Count);

// Clear first-event tracking to ensure clean state for each test execution
_firstTestInAssemblyTasks = new ThreadSafeDictionary<Assembly, Task>();
_firstTestInClassTasks = new ThreadSafeDictionary<Type, Task>();
_firstTestInSessionTasks = new ThreadSafeDictionary<string, Task>();

foreach (var context in contexts)
for (var i = 0; i < allTests.Count; i++)
{
var assembly = context.ClassContext.AssemblyContext.Assembly;
var assemblyCounter = _assemblyTestCounts.GetOrAdd(assembly, static _ => new Counter());
var classContext = allTests[i].Context.ClassContext;
var assemblyCounter = _assemblyTestCounts.GetOrAdd(classContext.AssemblyContext.Assembly, static _ => new Counter());
assemblyCounter.Add(1);

var classType = context.ClassContext.ClassType;
var classCounter = _classTestCounts.GetOrAdd(classType, static _ => new Counter());
var classCounter = _classTestCounts.GetOrAdd(classContext.ClassType, static _ => new Counter());
classCounter.Add(1);
}
}
Expand Down
35 changes: 20 additions & 15 deletions TUnit.Engine/Services/HookDelegateBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,24 @@ private async Task<IReadOnlyList<NamedHookDelegate<TContext>>> BuildGlobalHooksA
await _logger.LogTraceAsync($"Built {hooks.Count} {hookTypeName} hook delegate(s)").ConfigureAwait(false);
}

return hooks
.OrderBy(static h => h.order)
.ThenBy(static h => h.registrationIndex)
.Select(static h => h.hook)
.ToList();
return SortAndProject(hooks);
}

private static List<NamedHookDelegate<TContext>> SortAndProject<TContext>(
List<(int order, int registrationIndex, NamedHookDelegate<TContext> hook)> hooks)
{
hooks.Sort(static (a, b) =>
{
var orderCompare = a.order.CompareTo(b.order);
return orderCompare != 0 ? orderCompare : a.registrationIndex.CompareTo(b.registrationIndex);
});

var result = new List<NamedHookDelegate<TContext>>(hooks.Count);
for (var i = 0; i < hooks.Count; i++)
{
result.Add(hooks[i].hook);
}
return result;
}

private Task<IReadOnlyList<NamedHookDelegate<TestContext>>> BuildGlobalBeforeEveryTestHooksAsync()
Expand Down Expand Up @@ -471,11 +484,7 @@ private async Task<IReadOnlyList<NamedHookDelegate<AssemblyHookContext>>> BuildB
allHooks.Add((hook.Order, hook.RegistrationIndex, namedHook));
}

return allHooks
.OrderBy(static h => h.order)
.ThenBy(static h => h.registrationIndex)
.Select(static h => h.hook)
.ToList();
return SortAndProject(allHooks);
}

public async ValueTask<IReadOnlyList<NamedHookDelegate<AssemblyHookContext>>> CollectAfterAssemblyHooksAsync(Assembly assembly)
Expand Down Expand Up @@ -505,11 +514,7 @@ private async Task<IReadOnlyList<NamedHookDelegate<AssemblyHookContext>>> BuildA
allHooks.Add((hook.Order, hook.RegistrationIndex, namedHook));
}

return allHooks
.OrderBy(static h => h.order)
.ThenBy(static h => h.registrationIndex)
.Select(static h => h.hook)
.ToList();
return SortAndProject(allHooks);
}

public ValueTask<IReadOnlyList<NamedHookDelegate<TestSessionContext>>> CollectBeforeTestSessionHooksAsync()
Expand Down
4 changes: 1 addition & 3 deletions TUnit.Engine/Services/HookExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ public async ValueTask<List<Exception>> ExecuteAfterTestSessionHooksAsync(Cancel
// stopped the activity after hooks, the exporter would already be
// gone and the root span would never be exported.
#if NET
var hasTestFailures = _contextProvider.TestSessionContext.AllTests
.Any(t => t.Result is { State: TestState.Failed or TestState.Timeout or TestState.Cancelled });
FinishSessionActivity(hasErrors: hasTestFailures);
FinishSessionActivity(hasErrors: _contextProvider.TestSessionContext.HasFailures);
#endif

var hooks = await _hookCollectionService.CollectAfterTestSessionHooksAsync().ConfigureAwait(false);
Expand Down
4 changes: 3 additions & 1 deletion TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
break;
case TestState.Timeout:
case TestState.Failed:
TestSessionContext.Current?.MarkFailure();
await _messageBus.Failed(test.Context, test.Context.Execution.Result?.Exception!, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
break;
case TestState.Skipped:
Expand All @@ -247,13 +248,14 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
await _messageBus.Skipped(test.Context, skipReason).ConfigureAwait(false);
break;
case TestState.Cancelled:
TestSessionContext.Current?.MarkFailure();
await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
break;
default:
throw new ArgumentOutOfRangeException();
}

TestContext.RemoveById(test.Context.Id);
test.Context.RemoveFromRegistry();
}
}

Expand Down
Loading
Loading