From c901839a5cbe92c5e6bd75023656ad961c050aae Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 20:54:58 +0100 Subject: [PATCH] fix(event-receivers): invoke last test and skipped test event receivers --- .../Framework/TUnitServiceProvider.cs | 3 +- .../Services/TestExecution/TestCoordinator.cs | 58 +++++- .../LastTestEventReceiverTests.cs | 165 ++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 TUnit.TestProject/LastTestEventReceiverTests.cs diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index b5b868b7258..e603a65bbdf 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -193,7 +193,8 @@ public TUnitServiceProvider(IExtension extension, TestExecutor, testInitializer, objectTracker, - Logger)); + Logger, + EventReceiverOrchestrator)); // Create the HookOrchestratingTestExecutorAdapter // Note: We'll need to update this to handle dynamic dependencies properly diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 4a79021c622..0d78977f708 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -22,6 +22,7 @@ internal sealed class TestCoordinator : ITestCoordinator private readonly TestInitializer _testInitializer; private readonly ObjectTracker _objectTracker; private readonly TUnitFrameworkLogger _logger; + private readonly EventReceiverOrchestrator _eventReceiverOrchestrator; public TestCoordinator( TestExecutionGuard executionGuard, @@ -31,7 +32,8 @@ public TestCoordinator( TestExecutor testExecutor, TestInitializer testInitializer, ObjectTracker objectTracker, - TUnitFrameworkLogger logger) + TUnitFrameworkLogger logger, + EventReceiverOrchestrator eventReceiverOrchestrator) { _executionGuard = executionGuard; _stateManager = stateManager; @@ -41,6 +43,7 @@ public TestCoordinator( _testInitializer = testInitializer; _objectTracker = objectTracker; _logger = logger; + _eventReceiverOrchestrator = eventReceiverOrchestrator; } #if NET6_0_OR_GREATER @@ -92,6 +95,13 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => !string.IsNullOrEmpty(test.Context.SkipReason)) { await _stateManager.MarkSkippedAsync(test, test.Context.SkipReason ?? "Test was skipped"); + + // Invoke skipped event receivers + await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken); + + // Invoke test end event receivers for skipped tests + await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken); + return; } @@ -137,6 +147,12 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => catch (SkipTestException ex) { await _stateManager.MarkSkippedAsync(test, ex.Message); + + // Invoke skipped event receivers + await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken); + + // Invoke test end event receivers for skipped tests + await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken); } catch (Exception ex) { @@ -161,6 +177,46 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => cleanupExceptions.AddRange(hookExceptions); } + // Invoke Last event receivers for class and assembly + try + { + await _eventReceiverOrchestrator.InvokeLastTestInClassEventReceiversAsync( + test.Context, + test.Context.ClassContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in class event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + + try + { + await _eventReceiverOrchestrator.InvokeLastTestInAssemblyEventReceiversAsync( + test.Context, + test.Context.ClassContext.AssemblyContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in assembly event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + + try + { + await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( + test.Context, + test.Context.ClassContext.AssemblyContext.TestSessionContext, + CancellationToken.None); + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error in last test in session event receiver for {test.TestId}: {ex}"); + cleanupExceptions.Add(ex); + } + // If any cleanup exceptions occurred, mark the test as failed if (cleanupExceptions.Count > 0) { diff --git a/TUnit.TestProject/LastTestEventReceiverTests.cs b/TUnit.TestProject/LastTestEventReceiverTests.cs new file mode 100644 index 00000000000..b239fb8a5d5 --- /dev/null +++ b/TUnit.TestProject/LastTestEventReceiverTests.cs @@ -0,0 +1,165 @@ +using TUnit.Core.Interfaces; + +namespace TUnit.TestProject; + +public class LastTestEventReceiverTests +{ + public static readonly List Events = []; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + } + + [Test] + [LastTestEventReceiver] + public async Task Test1() + { + await Task.Delay(10); + } + + [Test] + [LastTestEventReceiver] + public async Task Test2() + { + await Task.Delay(10); + } + + [Test] + [LastTestEventReceiver] + public async Task Test3() + { + await Task.Delay(10); + } + + [After(Test)] + public async Task VerifyLastTestEventFired(TestContext context) + { + // Give some time for async event receivers to complete + await Task.Delay(100); + + var displayName = context.GetDisplayName(); + + // After the last test (Test3), we should have the last test event recorded + if (displayName.Contains("Test3")) + { + await Assert.That(Events).Contains("LastTestInClass"); + } + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class LastTestEventReceiverAttribute : Attribute, + ITestStartEventReceiver, + ITestEndEventReceiver, + ILastTestInClassEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestStart(TestContext context) + { + LastTestEventReceiverTests.Events.Add($"TestStart: {context.GetDisplayName()}"); + return default; + } + + public ValueTask OnTestEnd(TestContext context) + { + LastTestEventReceiverTests.Events.Add($"TestEnd: {context.GetDisplayName()}"); + return default; + } + + public ValueTask OnLastTestInClass(ClassHookContext context, TestContext testContext) + { + LastTestEventReceiverTests.Events.Add("LastTestInClass"); + return default; + } +} + +// Separate test class to test assembly-level last test event +public class LastTestInAssemblyEventReceiverTests +{ + public static readonly List Events = []; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + } + + [Test] + [LastTestInAssemblyEventReceiver] + public async Task AssemblyTest() + { + await Task.Delay(10); + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class LastTestInAssemblyEventReceiverAttribute : Attribute, + ILastTestInAssemblyEventReceiver +{ + public int Order => 0; + + public ValueTask OnLastTestInAssembly(AssemblyHookContext context, TestContext testContext) + { + LastTestInAssemblyEventReceiverTests.Events.Add("LastTestInAssembly"); + return default; + } +} + +// Test for skipped event receivers +public class SkippedEventReceiverTests +{ + public static readonly List Events = []; + public static string? CapturedSkipReason = null; + + [Before(Test)] + public void ClearEvents() + { + Events.Clear(); + CapturedSkipReason = null; + } + + [Test, Skip("Testing skip event with custom reason")] + [SkipEventReceiverAttribute] + public async Task SkippedTestWithCustomReason() + { + await Task.Delay(10); + } + + [After(Test)] + public async Task VerifySkipEventFired(TestContext context) + { + // Give some time for async event receivers to complete + await Task.Delay(100); + + if (context.GetDisplayName().Contains("SkippedTestWithCustomReason")) + { + await Assert.That(Events).Contains("TestSkipped"); + await Assert.That(Events).Contains("TestEnd"); + await Assert.That(CapturedSkipReason).IsEqualTo("Testing skip event with custom reason"); + } + } +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public class SkipEventReceiverAttribute : Attribute, + ITestSkippedEventReceiver, + ITestEndEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestSkipped(TestContext context) + { + SkippedEventReceiverTests.Events.Add("TestSkipped"); + SkippedEventReceiverTests.CapturedSkipReason = context.SkipReason; + return default; + } + + public ValueTask OnTestEnd(TestContext context) + { + SkippedEventReceiverTests.Events.Add("TestEnd"); + return default; + } +}