From beedbce300395fe3b6292bc7c21743aec836523e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:00:23 +0000 Subject: [PATCH 1/3] Fix ITestSkippedEventReceiver not firing for [Skip]-attributed tests RegisterReceivers was only called inside PrepareTest, which runs after skip checks. When a test was skipped via [Skip("...")] attribute, the EventReceiverRegistry global flags were never set, causing the InvokeTestSkippedEventReceiversAsync fast-path to short-circuit. Move RegisterReceivers call to ExecuteTestInternalAsync before any skip checks so attribute-based event receivers are registered early enough for OnTestSkipped to fire. Fixes #5252 --- TUnit.Engine/Services/TestExecution/TestCoordinator.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 29e444588b..cc79d87fb7 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -61,6 +61,10 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca _contextRestorer.RestoreContext(test); + // Register event receivers early so that skip event receivers work + // even when the test is skipped before full initialization. + _eventReceiverOrchestrator.RegisterReceivers(test.Context, cancellationToken); + // Check if test was already marked as skipped during registration // (e.g., by a derived SkipAttribute evaluated in OnTestRegistered). // This must be checked before any instance creation or retry/timeout logic. From dd67ea5540797e84fcc504d3615598aa436e69a2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:35:06 +0000 Subject: [PATCH 2/3] Add exactly-once invocation guard tests for skip event receivers Fix SkippedEventReceiverTests verification: After(Test) hooks don't run for [Skip]-attributed tests, so the old After(Test) assertion was dead code. Replace with a [NotInParallel] verification test that checks Events after the skipped test completes. Both static skip and runtime skip tests now assert that OnTestSkipped and OnTestEnd are each called exactly once. --- .../LastTestEventReceiverTests.cs | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/TUnit.TestProject/LastTestEventReceiverTests.cs b/TUnit.TestProject/LastTestEventReceiverTests.cs index 2bd365ff8b..58ad292a1b 100644 --- a/TUnit.TestProject/LastTestEventReceiverTests.cs +++ b/TUnit.TestProject/LastTestEventReceiverTests.cs @@ -109,19 +109,15 @@ public ValueTask OnLastTestInAssembly(AssemblyHookContext context, TestContext t } } -// Test for skipped event receivers +// Test for skipped event receivers using [Skip] attribute. +// After(Test) hooks don't run for statically skipped tests, so we use a +// separate verification test that runs after the skipped test completes. +[NotInParallel(nameof(SkippedEventReceiverTests))] 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() @@ -129,18 +125,21 @@ public async Task SkippedTestWithCustomReason() await Task.Delay(10); } - [After(Test)] - public async Task VerifySkipEventFired(TestContext context) + [Test] + public async Task Verify_SkipEventReceiver_Fired_Exactly_Once() { - // Give some time for async event receivers to complete - await Task.Delay(100); + // Events were populated by the SkipEventReceiverAttribute on the skipped test. + // No Before(Test) clearing here — we need to observe the cumulative state. + await Assert.That(Events).Contains("TestSkipped"); + await Assert.That(Events).Contains("TestEnd"); + await Assert.That(CapturedSkipReason).IsEqualTo("Testing skip event with custom reason"); - if (context. Metadata.DisplayName.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"); - } + // Guard against double invocation + var skipCount = Events.Count(e => e == "TestSkipped"); + await Assert.That(skipCount).IsEqualTo(1); + + var endCount = Events.Count(e => e == "TestEnd"); + await Assert.That(endCount).IsEqualTo(1); } } @@ -200,7 +199,10 @@ public async Task VerifyRuntimeSkipEventFired(TestContext context) await Assert.That(Events).Contains("TestSkipped"); await Assert.That(Events).Contains("TestEnd"); - // Verify TestEnd is called exactly once (not twice) + // Verify events are called exactly once (not twice) + var testSkippedCount = Events.Count(e => e == "TestSkipped"); + await Assert.That(testSkippedCount).IsEqualTo(1); + var testEndCount = Events.Count(e => e == "TestEnd"); await Assert.That(testEndCount).IsEqualTo(1); } From 4bb5329f3d8e5a2898bceb8d36e014fdb685048e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:55:24 +0000 Subject: [PATCH 3/3] Use DependsOn with ProceedOnFailure for skip event receiver test Replace [NotInParallel] with [DependsOn(ProceedOnFailure = true)] to guarantee the verification test runs after the skipped test, not just prevent parallel execution. This avoids potential ordering-dependent flakes. --- TUnit.TestProject/LastTestEventReceiverTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.TestProject/LastTestEventReceiverTests.cs b/TUnit.TestProject/LastTestEventReceiverTests.cs index 58ad292a1b..eeb62608f3 100644 --- a/TUnit.TestProject/LastTestEventReceiverTests.cs +++ b/TUnit.TestProject/LastTestEventReceiverTests.cs @@ -111,8 +111,7 @@ public ValueTask OnLastTestInAssembly(AssemblyHookContext context, TestContext t // Test for skipped event receivers using [Skip] attribute. // After(Test) hooks don't run for statically skipped tests, so we use a -// separate verification test that runs after the skipped test completes. -[NotInParallel(nameof(SkippedEventReceiverTests))] +// DependsOn verification test that runs after the skipped test completes. public class SkippedEventReceiverTests { public static readonly List Events = []; @@ -126,6 +125,7 @@ public async Task SkippedTestWithCustomReason() } [Test] + [DependsOn(nameof(SkippedTestWithCustomReason), ProceedOnFailure = true)] public async Task Verify_SkipEventReceiver_Fired_Exactly_Once() { // Events were populated by the SkipEventReceiverAttribute on the skipped test.