diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index f3a2a87ac7..d28177f42e 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -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; } + + /// + /// Invalidates all cached event receiver data. Called when class instance changes (e.g., on retry). + /// + 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 ObjectBag => _testBuilderContext.StateBag; diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 8921b35590..ffb901e5dc 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -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()) + // Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered) + foreach (var receiver in context.GetTestRegisteredReceivers()) { await receiver.OnTestRegistered(registeredContext); } diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index 3b64e65d7c..27c00913fa 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -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 GetEligibleEventObjects(this TestContext testContext) + /// + /// Ensures all event receiver caches are populated. Iterates through eligible objects once + /// and categorizes them by type in a single pass. + /// + /// + /// 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. + /// + 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? startReceiversEarly = null; + List? startReceiversLate = null; + List? endReceiversEarly = null; + List? endReceiversLate = null; +#else + List? startReceivers = null; + List? endReceivers = null; +#endif + List? skippedReceivers = null; + List? discoveryReceivers = null; + List? 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(List? 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 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) @@ -119,4 +263,69 @@ private static int CountNonNullValues(IDictionary props) } return count; } + + /// + /// Gets pre-computed test start receivers (filtered, sorted, scoped-attribute filtered). + /// +#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 + + /// + /// Gets pre-computed test end receivers (filtered, sorted, scoped-attribute filtered). + /// +#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 + + /// + /// Gets pre-computed test skipped receivers (filtered, sorted, scoped-attribute filtered). + /// + public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext) + { + EnsureEventReceiversCached(testContext); + return testContext.CachedTestSkippedReceivers!; + } + + /// + /// Gets pre-computed test discovery receivers (filtered, sorted, scoped-attribute filtered). + /// + public static ITestDiscoveryEventReceiver[] GetTestDiscoveryReceivers(this TestContext testContext) + { + EnsureEventReceiversCached(testContext); + return testContext.CachedTestDiscoveryReceivers!; + } + + /// + /// Gets pre-computed test registered receivers (filtered, sorted, scoped-attribute filtered). + /// + public static ITestRegisteredEventReceiver[] GetTestRegisteredReceivers(this TestContext testContext) + { + EnsureEventReceiversCached(testContext); + return testContext.CachedTestRegisteredReceivers!; + } } diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index a9777470a8..6b83f8456e 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -92,40 +92,38 @@ public async ValueTask InvokeTestStartEventReceiversAsync(TestContext context, C private async ValueTask InvokeTestStartEventReceiversCore(TestContext context, CancellationToken cancellationToken, EventReceiverStage? stage) { - // Manual filtering and sorting instead of LINQ to avoid allocations - var eligibleObjects = context.GetEligibleEventObjects(); - List? receivers = null; - - foreach (var obj in eligibleObjects) + // Use pre-computed receivers (already filtered by stage, sorted, and scoped-attribute filtered) +#if NET + if (stage.HasValue) { - if (obj is ITestStartEventReceiver receiver) + var receivers = context.GetTestStartReceivers(stage.Value); + foreach (var receiver in receivers) { -#if NET - // Filter by stage if specified (only on .NET 8.0+ where Stage property exists) - if (stage.HasValue && receiver.Stage != stage.Value) - { - continue; - } -#endif - receivers ??= []; - receivers.Add(receiver); + await receiver.OnTestStart(context); } } - - if (receivers == null) + else { - return; - } - - // Manual sort instead of OrderBy - receivers.Sort((a, b) => a.Order.CompareTo(b.Order)); - - var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(receivers); + // No stage specified - invoke both Early and Late receivers in order + var earlyReceivers = context.GetTestStartReceivers(EventReceiverStage.Early); + foreach (var receiver in earlyReceivers) + { + await receiver.OnTestStart(context); + } - foreach (var receiver in filteredReceivers) + var lateReceivers = context.GetTestStartReceivers(EventReceiverStage.Late); + foreach (var receiver in lateReceivers) + { + await receiver.OnTestStart(context); + } + } +#else + var receivers = context.GetTestStartReceivers(); + foreach (var receiver in receivers) { await receiver.OnTestStart(context); } +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -144,37 +142,61 @@ private async ValueTask> InvokeTestEndEventReceiversCore(TestCon // Defer exception list allocation until actually needed List? exceptions = null; - // Manual filtering and sorting instead of LINQ to avoid allocations - var eligibleObjects = context.GetEligibleEventObjects(); - List? receivers = null; - - foreach (var obj in eligibleObjects) + // Use pre-computed receivers (already filtered by stage, sorted, and scoped-attribute filtered) +#if NET + if (stage.HasValue) { - if (obj is ITestEndEventReceiver receiver) + var receivers = context.GetTestEndReceivers(stage.Value); + foreach (var receiver in receivers) { -#if NET - // Filter by stage if specified (only on .NET 8.0+ where Stage property exists) - if (stage.HasValue && receiver.Stage != stage.Value) + try { - continue; + await receiver.OnTestEnd(context); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in test end event receiver: {ex.Message}"); + exceptions ??= []; + exceptions.Add(ex); } -#endif - receivers ??= []; - receivers.Add(receiver); } } - - if (receivers == null) + else { - return []; - } - - // Manual sort instead of OrderBy - receivers.Sort((a, b) => a.Order.CompareTo(b.Order)); - - var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(receivers); + // No stage specified - invoke both Early and Late receivers in order + var earlyReceivers = context.GetTestEndReceivers(EventReceiverStage.Early); + foreach (var receiver in earlyReceivers) + { + try + { + await receiver.OnTestEnd(context); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in test end event receiver: {ex.Message}"); + exceptions ??= []; + exceptions.Add(ex); + } + } - foreach (var receiver in filteredReceivers) + var lateReceivers = context.GetTestEndReceivers(EventReceiverStage.Late); + foreach (var receiver in lateReceivers) + { + try + { + await receiver.OnTestEnd(context); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in test end event receiver: {ex.Message}"); + exceptions ??= []; + exceptions.Add(ex); + } + } + } +#else + var receivers = context.GetTestEndReceivers(); + foreach (var receiver in receivers) { try { @@ -187,6 +209,7 @@ private async ValueTask> InvokeTestEndEventReceiversCore(TestCon exceptions.Add(ex); } } +#endif return exceptions ?? []; } @@ -204,30 +227,15 @@ public async ValueTask InvokeTestSkippedEventReceiversAsync(TestContext context, private async ValueTask InvokeTestSkippedEventReceiversCore(TestContext context, CancellationToken cancellationToken) { - // Manual filtering and sorting instead of LINQ to avoid allocations - var eligibleObjects = context.GetEligibleEventObjects(); - List? receivers = null; - - foreach (var obj in eligibleObjects) - { - if (obj is ITestSkippedEventReceiver receiver) - { - receivers ??= []; - receivers.Add(receiver); - } - } + // Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered) + var receivers = context.GetTestSkippedReceivers(); - if (receivers == null) + if (receivers.Length == 0) { return; } - // Manual sort instead of OrderBy - receivers.Sort((a, b) => a.Order.CompareTo(b.Order)); - - var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(receivers); - - foreach (var receiver in filteredReceivers) + foreach (var receiver in receivers) { await receiver.OnTestSkipped(context); } @@ -235,13 +243,10 @@ private async ValueTask InvokeTestSkippedEventReceiversCore(TestContext context, public async ValueTask InvokeTestDiscoveryEventReceiversAsync(TestContext context, DiscoveredTestContext discoveredContext, CancellationToken cancellationToken) { - var eventReceivers = context.GetEligibleEventObjects() - .OfType(); + // Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered) + var receivers = context.GetTestDiscoveryReceivers(); - // Filter scoped attributes to ensure only the highest priority one of each type is invoked - var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(eventReceivers); - - foreach (var receiver in filteredReceivers.OrderBy(static r => r.Order)) + foreach (var receiver in receivers) { await receiver.OnTestDiscovered(discoveredContext); } diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 0c0285ba62..028fe29652 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -83,9 +83,8 @@ private async Task RegisterTest(AbstractExecutableTest test) return; } - var eventObjects = test.Context.GetEligibleEventObjects(); - - foreach (var receiver in eventObjects.OfType()) + // Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered) + foreach (var receiver in test.Context.GetTestRegisteredReceivers()) { try {