diff --git a/TUnit.Engine/Scheduling/HookOrchestratingTestExecutorAdapter.cs b/TUnit.Engine/Scheduling/HookOrchestratingTestExecutorAdapter.cs index 8555d9f285..0e348bfb34 100644 --- a/TUnit.Engine/Scheduling/HookOrchestratingTestExecutorAdapter.cs +++ b/TUnit.Engine/Scheduling/HookOrchestratingTestExecutorAdapter.cs @@ -130,6 +130,7 @@ await _messageBus.PublishAsync( test.EndTime = DateTimeOffset.UtcNow; // Execute cleanup hooks + Exception? cleanupException = null; try { // Use a separate cancellation token for cleanup to ensure cleanup hooks execute @@ -139,8 +140,51 @@ await _messageBus.PublishAsync( } catch (Exception ex) { - // Log but don't throw - after hooks shouldn't prevent test completion reporting await _logger.LogErrorAsync($"Error in cleanup hooks for test {test.TestId}: {ex}"); + cleanupException = ex; + + // If test passed but after hooks failed, update the test result + if (test.State == TestState.Passed) + { + test.State = TestState.Failed; + test.Result = new TestResult + { + State = TestState.Failed, + Start = test.StartTime, + End = test.EndTime, + Duration = test.EndTime.GetValueOrDefault() - test.StartTime.GetValueOrDefault(), + Exception = ex, + ComputerName = Environment.MachineName + }; + + // Report the failure + await _messageBus.PublishAsync( + this, + new TestNodeUpdateMessage( + _sessionUid, + test.Context.ToTestNode().WithProperty(new FailedTestNodeStateProperty(ex)))); + } + // If test already failed and after hooks also failed, we need to report both failures + else if (test.State == TestState.Failed && test.Result?.Exception != null) + { + var aggregateException = new AggregateException("Test and after hooks both failed", test.Result.Exception, ex); + test.Result = new TestResult + { + State = TestState.Failed, + Start = test.Result.Start, + End = test.EndTime, + Duration = test.Result.Duration, + Exception = aggregateException, + ComputerName = test.Result.ComputerName + }; + + // Update the failure message + await _messageBus.PublishAsync( + this, + new TestNodeUpdateMessage( + _sessionUid, + test.Context.ToTestNode().WithProperty(new FailedTestNodeStateProperty(aggregateException)))); + } } } } diff --git a/TUnit.Engine/Services/HookOrchestrator.cs b/TUnit.Engine/Services/HookOrchestrator.cs index e644192c57..f82afde5dd 100644 --- a/TUnit.Engine/Services/HookOrchestrator.cs +++ b/TUnit.Engine/Services/HookOrchestrator.cs @@ -4,6 +4,7 @@ using TUnit.Core; using TUnit.Core.Data; using TUnit.Core.Services; +using TUnit.Engine.Exceptions; using TUnit.Engine.Framework; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; @@ -93,6 +94,7 @@ private Task GetOrCreateBeforeClassTask( public async Task ExecuteAfterTestSessionHooksAsync(CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterTestSessionHooksAsync(); + var exceptions = new List(); foreach (var hook in hooks) { @@ -104,9 +106,16 @@ private Task GetOrCreateBeforeClassTask( catch (Exception ex) { await _logger.LogErrorAsync($"AfterTestSession hook failed: {ex.Message}"); - // After hooks failures are logged but don't stop execution + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw exceptions.Count == 1 + ? new HookFailedException(exceptions[0]) + : new HookFailedException("Multiple AfterTestSession hooks failed", new AggregateException(exceptions)); + } #if NET return ExecutionContext.Capture(); @@ -143,6 +152,7 @@ private Task GetOrCreateBeforeClassTask( public async Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterTestDiscoveryHooksAsync(); + var exceptions = new List(); foreach (var hook in hooks) { @@ -154,8 +164,16 @@ private Task GetOrCreateBeforeClassTask( catch (Exception ex) { await _logger.LogErrorAsync($"AfterTestDiscovery hook failed: {ex.Message}"); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw exceptions.Count == 1 + ? new HookFailedException(exceptions[0]) + : new HookFailedException("Multiple AfterTestDiscovery hooks failed", new AggregateException(exceptions)); + } #if NET return ExecutionContext.Capture(); @@ -259,8 +277,8 @@ private async Task ExecuteBeforeAssemblyHooksAsync(Assembly as private async Task ExecuteAfterAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly); - var assemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly); + var exceptions = new List(); // Execute global AfterEveryAssembly hooks first var everyHooks = await _hookCollectionService.CollectAfterEveryAssemblyHooksAsync(); @@ -274,6 +292,7 @@ private async Task ExecuteAfterAssemblyHooksAsync(Assembly ass catch (Exception ex) { await _logger.LogErrorAsync($"AfterEveryAssembly hook failed for {assembly.GetName().Name}: {ex.Message}"); + exceptions.Add(ex); } } @@ -287,8 +306,16 @@ private async Task ExecuteAfterAssemblyHooksAsync(Assembly ass catch (Exception ex) { await _logger.LogErrorAsync($"AfterAssembly hook failed for {assembly.GetName().Name}: {ex.Message}"); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw exceptions.Count == 1 + ? new HookFailedException(exceptions[0]) + : new HookFailedException("Multiple AfterAssembly hooks failed", new AggregateException(exceptions)); + } return ExecutionContext.Capture()!; } @@ -339,8 +366,8 @@ private async Task ExecuteAfterClassHooksAsync( Type testClassType, CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClassType); - var classContext = _contextProvider.GetOrCreateClassContext(testClassType); + var exceptions = new List(); // Execute global AfterEveryClass hooks first var everyHooks = await _hookCollectionService.CollectAfterEveryClassHooksAsync(); @@ -354,6 +381,7 @@ private async Task ExecuteAfterClassHooksAsync( catch (Exception ex) { await _logger.LogErrorAsync($"AfterEveryClass hook failed for {testClassType.Name}: {ex.Message}"); + exceptions.Add(ex); } } @@ -367,8 +395,16 @@ private async Task ExecuteAfterClassHooksAsync( catch (Exception ex) { await _logger.LogErrorAsync($"AfterClass hook failed for {testClassType.Name}: {ex.Message}"); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw exceptions.Count == 1 + ? new HookFailedException(exceptions[0]) + : new HookFailedException("Multiple AfterClass hooks failed", new AggregateException(exceptions)); + } return ExecutionContext.Capture()!; } @@ -395,6 +431,7 @@ private async Task ExecuteBeforeEveryTestHooksAsync(Type testClassType, TestCont private async Task ExecuteAfterEveryTestHooksAsync(Type testClassType, TestContext testContext, CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterEveryTestHooksAsync(testClassType); + var exceptions = new List(); foreach (var hook in hooks) { @@ -406,7 +443,15 @@ private async Task ExecuteAfterEveryTestHooksAsync(Type testClassType, TestConte catch (Exception ex) { await _logger.LogErrorAsync($"AfterEveryTest hook failed: {ex.Message}"); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw exceptions.Count == 1 + ? new HookFailedException(exceptions[0]) + : new HookFailedException("Multiple AfterEveryTest hooks failed", new AggregateException(exceptions)); + } } } diff --git a/TUnit.Engine/Services/SingleTestExecutor.cs b/TUnit.Engine/Services/SingleTestExecutor.cs index ce342de3dc..0bf15cda55 100644 --- a/TUnit.Engine/Services/SingleTestExecutor.cs +++ b/TUnit.Engine/Services/SingleTestExecutor.cs @@ -5,6 +5,7 @@ using TUnit.Core.Logging; using TUnit.Core.ReferenceTracking; using TUnit.Core.Tracking; +using TUnit.Engine.Exceptions; using TUnit.Engine.Extensions; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; @@ -165,6 +166,7 @@ private async Task ExecuteTestWithHooksAsync(AbstractExecutableTest test, object var beforeTestHooks = await _hookCollectionService.CollectBeforeTestHooksAsync(testClassType); var afterTestHooks = await _hookCollectionService.CollectAfterTestHooksAsync(testClassType); + Exception? testException = null; try { await ExecuteBeforeTestHooksAsync(beforeTestHooks, test.Context, cancellationToken); @@ -179,13 +181,35 @@ private async Task ExecuteTestWithHooksAsync(AbstractExecutableTest test, object catch (Exception ex) { HandleTestFailure(test, ex); + testException = ex; + } + + try + { + await ExecuteAfterTestHooksAsync(afterTestHooks, test.Context, cancellationToken); + } + catch (Exception afterHookEx) + { + // If test already failed, aggregate the exceptions + if (testException != null) + { + throw new AggregateException("Test and after hook both failed", testException, afterHookEx); + } + + // Otherwise, fail the test due to after hook failure + HandleTestFailure(test, afterHookEx); throw; } finally { - await ExecuteAfterTestHooksAsync(afterTestHooks, test.Context, cancellationToken); await DecrementAndDisposeTrackedObjectsAsync(test); } + + // Re-throw original test exception if after hooks succeeded + if (testException != null) + { + throw testException; + } } @@ -212,6 +236,8 @@ private async Task ExecuteBeforeTestHooksAsync(IReadOnlyList> hooks, TestContext context, CancellationToken cancellationToken) { + var exceptions = new List(); + foreach (var hook in hooks) { try @@ -223,6 +249,19 @@ private async Task ExecuteAfterTestHooksAsync(IReadOnlyList 0) + { + if (exceptions.Count == 1) + { + throw new HookFailedException(exceptions[0]); + } + else + { + throw new HookFailedException("Multiple after test hooks failed", new AggregateException(exceptions)); } } } diff --git a/TUnit.TestProject/AfterTests/AfterTestExceptionTests.cs b/TUnit.TestProject/AfterTests/AfterTestExceptionTests.cs new file mode 100644 index 0000000000..0170144a74 --- /dev/null +++ b/TUnit.TestProject/AfterTests/AfterTestExceptionTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Threading.Tasks; +using TUnit.Core; + +namespace TUnit.TestProject.AfterTests; + +public class AfterTestExceptionTests +{ + private static bool _testExecuted; + private static bool _afterHookExecuted; + + [Before(Class)] + public static void ResetFlags() + { + _testExecuted = false; + _afterHookExecuted = false; + } + + [Test] + public async Task Test_Should_Fail_When_After_Hook_Throws() + { + _testExecuted = true; + await Task.CompletedTask; + // Test itself passes + } + + [After(Test)] + public async Task AfterHook_That_Throws_Exception() + { + _afterHookExecuted = true; + await Task.CompletedTask; + throw new InvalidOperationException("After test hook intentionally failed!"); + } + + [After(Class)] + public static void VerifyExecutions() + { + // Verify that both the test and after hook executed + if (!_testExecuted) + { + throw new Exception("Test was not executed!"); + } + + if (!_afterHookExecuted) + { + throw new Exception("After hook was not executed!"); + } + } +} + +public class MultipleAfterHookExceptionTests +{ + [Test] + public async Task Test_Should_Fail_When_Multiple_After_Hooks_Throw() + { + await Task.CompletedTask; + // Test itself passes + } + + [After(Test)] + public async Task FirstAfterHook_That_Throws() + { + await Task.CompletedTask; + throw new InvalidOperationException("First after hook failed!"); + } + + [After(Test)] + public async Task SecondAfterHook_That_Throws() + { + await Task.CompletedTask; + throw new InvalidOperationException("Second after hook failed!"); + } +} + +public class TestAndAfterHookBothFailTests +{ + [Test] + public async Task Test_And_After_Hook_Both_Fail() + { + await Task.CompletedTask; + throw new InvalidOperationException("Test intentionally failed!"); + } + + [After(Test)] + public async Task AfterHook_Also_Fails() + { + await Task.CompletedTask; + throw new InvalidOperationException("After hook also failed!"); + } +} + +public class PassingTestWithFailingAfterHook +{ + [Test] + public void Simple_Passing_Test_With_Failing_After_Hook() + { + // Test passes + Console.WriteLine("Test executed and passed"); + } + + [After(Test)] + public void After_Hook_That_Fails() + { + Console.WriteLine("After hook executing..."); + throw new Exception("After hook failure should fail the test!"); + } +} \ No newline at end of file