Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
41 changes: 41 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,47 @@ internal override void SetAsyncLocalContext()

internal object[]? CachedEligibleEventObjects { get; set; }

// Pre-computed typed event receivers (filtered, sorted, scoped-attribute filtered)
// These are computed lazily on first access and cached
#if NET
// Stage-specific caches for .NET 8+ (avoids runtime filtering by stage)
internal ITestStartEventReceiver[]? CachedTestStartReceiversEarly { get; set; }
internal ITestStartEventReceiver[]? CachedTestStartReceiversLate { get; set; }
internal ITestEndEventReceiver[]? CachedTestEndReceiversEarly { get; set; }
internal ITestEndEventReceiver[]? CachedTestEndReceiversLate { get; set; }
#else
// Single cache for older frameworks (no stage concept)
internal ITestStartEventReceiver[]? CachedTestStartReceivers { get; set; }
internal ITestEndEventReceiver[]? CachedTestEndReceivers { get; set; }
#endif
internal ITestSkippedEventReceiver[]? CachedTestSkippedReceivers { get; set; }
internal ITestDiscoveryEventReceiver[]? CachedTestDiscoveryReceivers { get; set; }
internal ITestRegisteredEventReceiver[]? CachedTestRegisteredReceivers { get; set; }

// Track the class instance used when building caches for invalidation on retry
internal object? CachedClassInstance { get; set; }

/// <summary>
/// Invalidates all cached event receiver data. Called when class instance changes (e.g., on retry).
/// </summary>
internal void InvalidateEventReceiverCaches()
{
CachedEligibleEventObjects = null;
#if NET
CachedTestStartReceiversEarly = null;
CachedTestStartReceiversLate = null;
CachedTestEndReceiversEarly = null;
CachedTestEndReceiversLate = null;
#else
CachedTestStartReceivers = null;
CachedTestEndReceivers = null;
#endif
CachedTestSkippedReceivers = null;
CachedTestDiscoveryReceivers = null;
CachedTestRegisteredReceivers = null;
CachedClassInstance = null;
}


internal ConcurrentDictionary<string, object?> ObjectBag => _testBuilderContext.StateBag;

Expand Down
5 changes: 2 additions & 3 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1029,9 +1029,8 @@ private async Task InvokeTestRegisteredEventReceiversAsync(TestContext context)
// First, invoke the global test argument registration service to register shared instances
await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context);

var eventObjects = context.GetEligibleEventObjects();

foreach (var receiver in eventObjects.OfType<ITestRegisteredEventReceiver>())
// Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered)
foreach (var receiver in context.GetTestRegisteredReceivers())
{
await receiver.OnTestRegistered(registeredContext);
}
Expand Down
225 changes: 217 additions & 8 deletions TUnit.Engine/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,165 @@
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Interfaces;
using TUnit.Engine.Utilities;

namespace TUnit.Engine.Extensions;

internal static class TestContextExtensions
{
public static IEnumerable<object> GetEligibleEventObjects(this TestContext testContext)
/// <summary>
/// Ensures all event receiver caches are populated. Iterates through eligible objects once
/// and categorizes them by type in a single pass.
/// </summary>
/// <remarks>
/// Class instances change in these scenarios:
/// - Test retries: A new instance is created for each retry attempt
/// - Keyed test instances: Different data combinations may use different instances
/// When this happens, eligible event objects may include the new instance (if it implements
/// event receiver interfaces), so all caches must be invalidated and rebuilt.
/// </remarks>
private static void EnsureEventReceiversCached(TestContext testContext)
{
// Return cached result if available
if (testContext.CachedEligibleEventObjects != null)
var currentClassInstance = testContext.Metadata.TestDetails.ClassInstance;

// Check if caches are valid (populated and class instance hasn't changed)
#if NET
if (testContext.CachedTestStartReceiversEarly != null &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return;
}
#else
if (testContext.CachedTestStartReceivers != null &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return testContext.CachedEligibleEventObjects;
return;
}
#endif

// Build result directly with single allocation
var result = BuildEligibleEventObjects(testContext);
testContext.CachedEligibleEventObjects = result;
return result;
// Invalidate stale caches if class instance changed
if (testContext.CachedClassInstance != null &&
!ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
testContext.InvalidateEventReceiverCaches();
}

// Build caches - get eligible objects first
var eligibleObjects = BuildEligibleEventObjects(testContext);
testContext.CachedEligibleEventObjects = eligibleObjects;

// Single pass: categorize each object by interface type
#if NET
List<ITestStartEventReceiver>? startReceiversEarly = null;
List<ITestStartEventReceiver>? startReceiversLate = null;
List<ITestEndEventReceiver>? endReceiversEarly = null;
List<ITestEndEventReceiver>? endReceiversLate = null;
#else
List<ITestStartEventReceiver>? startReceivers = null;
List<ITestEndEventReceiver>? endReceivers = null;
#endif
List<ITestSkippedEventReceiver>? skippedReceivers = null;
List<ITestDiscoveryEventReceiver>? discoveryReceivers = null;
List<ITestRegisteredEventReceiver>? registeredReceivers = null;

foreach (var obj in eligibleObjects)
{
// Check each interface - an object can implement multiple
if (obj is ITestStartEventReceiver startReceiver)
{
#if NET
if (startReceiver.Stage == EventReceiverStage.Early)
{
startReceiversEarly ??= [];
startReceiversEarly.Add(startReceiver);
}
else
{
startReceiversLate ??= [];
startReceiversLate.Add(startReceiver);
}
#else
startReceivers ??= [];
startReceivers.Add(startReceiver);
#endif
}

if (obj is ITestEndEventReceiver endReceiver)
{
#if NET
if (endReceiver.Stage == EventReceiverStage.Early)
{
endReceiversEarly ??= [];
endReceiversEarly.Add(endReceiver);
}
else
{
endReceiversLate ??= [];
endReceiversLate.Add(endReceiver);
}
#else
endReceivers ??= [];
endReceivers.Add(endReceiver);
#endif
}

if (obj is ITestSkippedEventReceiver skippedReceiver)
{
skippedReceivers ??= [];
skippedReceivers.Add(skippedReceiver);
}

if (obj is ITestDiscoveryEventReceiver discoveryReceiver)
{
discoveryReceivers ??= [];
discoveryReceivers.Add(discoveryReceiver);
}

if (obj is ITestRegisteredEventReceiver registeredReceiver)
{
registeredReceivers ??= [];
registeredReceivers.Add(registeredReceiver);
}
}

// Sort and apply scoped filtering, then cache
#if NET
testContext.CachedTestStartReceiversEarly = SortAndFilter(startReceiversEarly);
testContext.CachedTestStartReceiversLate = SortAndFilter(startReceiversLate);
testContext.CachedTestEndReceiversEarly = SortAndFilter(endReceiversEarly);
testContext.CachedTestEndReceiversLate = SortAndFilter(endReceiversLate);
#else
testContext.CachedTestStartReceivers = SortAndFilter(startReceivers);
testContext.CachedTestEndReceivers = SortAndFilter(endReceivers);
#endif
testContext.CachedTestSkippedReceivers = SortAndFilter(skippedReceivers);
testContext.CachedTestDiscoveryReceivers = SortAndFilter(discoveryReceivers);
testContext.CachedTestRegisteredReceivers = SortAndFilter(registeredReceivers);

// Update cached class instance last
testContext.CachedClassInstance = currentClassInstance;
}

private static T[] SortAndFilter<T>(List<T>? receivers) where T : class, IEventReceiver
{
if (receivers == null || receivers.Count == 0)
{
return [];
}

// Sort by Order
receivers.Sort((a, b) => a.Order.CompareTo(b.Order));

// Apply scoped attribute filtering and return as array
var filtered = ScopedAttributeFilter.FilterScopedAttributes(receivers);
return filtered.ToArray();
}

public static IEnumerable<object> GetEligibleEventObjects(this TestContext testContext)
{
// Use EnsureEventReceiversCached which builds eligible objects as part of cache initialization
EnsureEventReceiversCached(testContext);
return testContext.CachedEligibleEventObjects!;
}

private static object[] BuildEligibleEventObjects(TestContext testContext)
Expand Down Expand Up @@ -119,4 +263,69 @@ private static int CountNonNullValues(IDictionary<string, object?> props)
}
return count;
}

/// <summary>
/// Gets pre-computed test start receivers (filtered, sorted, scoped-attribute filtered).
/// </summary>
#if NET
public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext, EventReceiverStage stage)
{
EnsureEventReceiversCached(testContext);
return stage == EventReceiverStage.Early
? testContext.CachedTestStartReceiversEarly!
: testContext.CachedTestStartReceiversLate!;
}
#else
public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
return testContext.CachedTestStartReceivers!;
}
#endif

/// <summary>
/// Gets pre-computed test end receivers (filtered, sorted, scoped-attribute filtered).
/// </summary>
#if NET
public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext, EventReceiverStage stage)
{
EnsureEventReceiversCached(testContext);
return stage == EventReceiverStage.Early
? testContext.CachedTestEndReceiversEarly!
: testContext.CachedTestEndReceiversLate!;
}
#else
public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
return testContext.CachedTestEndReceivers!;
}
#endif

/// <summary>
/// Gets pre-computed test skipped receivers (filtered, sorted, scoped-attribute filtered).
/// </summary>
public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
return testContext.CachedTestSkippedReceivers!;
}

/// <summary>
/// Gets pre-computed test discovery receivers (filtered, sorted, scoped-attribute filtered).
/// </summary>
public static ITestDiscoveryEventReceiver[] GetTestDiscoveryReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
return testContext.CachedTestDiscoveryReceivers!;
}

/// <summary>
/// Gets pre-computed test registered receivers (filtered, sorted, scoped-attribute filtered).
/// </summary>
public static ITestRegisteredEventReceiver[] GetTestRegisteredReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
return testContext.CachedTestRegisteredReceivers!;
}
}
Loading
Loading