Skip to content

Commit cb202d7

Browse files
committed
feat(execution): Improve exception handling in ExecuteAsync method to capture and rethrow hook exceptions
1 parent 1f2b192 commit cb202d7

File tree

1 file changed

+26
-29
lines changed

1 file changed

+26
-29
lines changed

TUnit.Engine/TestExecutor.cs

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Diagnostics.CodeAnalysis;
22
using System.Reflection;
3+
using System.Runtime.ExceptionServices;
34
using TUnit.Core;
45
using TUnit.Core.Exceptions;
56
using TUnit.Core.Interfaces;
@@ -57,6 +58,9 @@ public async Task ExecuteAsync(AbstractExecutableTest executableTest, Cancellati
5758
var testClass = executableTest.Metadata.TestClassType;
5859
var testAssembly = testClass.Assembly;
5960

61+
Exception? capturedException = null;
62+
Exception? hookException = null;
63+
6064
try
6165
{
6266
await EnsureTestSessionHooksExecutedAsync().ConfigureAwait(false);
@@ -72,7 +76,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(
7276
await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(testAssembly, assembly => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, CancellationToken.None))
7377
.ConfigureAwait(false);
7478

75-
// Event receivers for first test in assembly
7679
await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
7780
executableTest.Context,
7881
executableTest.Context.ClassContext.AssemblyContext,
@@ -83,7 +86,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
8386
await _beforeHookTaskCache.GetOrCreateBeforeClassTask(testClass, _ => _hookExecutor.ExecuteBeforeClassHooksAsync(testClass, CancellationToken.None))
8487
.ConfigureAwait(false);
8588

86-
// Event receivers for first test in class
8789
await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
8890
executableTest.Context,
8991
executableTest.Context.ClassContext,
@@ -97,7 +99,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
9799

98100
executableTest.Context.RestoreExecutionContext();
99101

100-
// Only wrap the actual test execution with timeout, not the hooks
101102
var testTimeout = executableTest.Context.Metadata.TestDetails.Timeout;
102103
var timeoutMessage = testTimeout.HasValue
103104
? $"Test '{executableTest.Context.Metadata.TestDetails.TestName}' execution timed out after {testTimeout.Value}"
@@ -111,51 +112,47 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
111112

112113
executableTest.SetResult(TestState.Passed);
113114
}
114-
catch (SkipTestException)
115+
catch (SkipTestException ex)
115116
{
116117
executableTest.SetResult(TestState.Skipped);
117-
throw;
118+
capturedException = ex;
118119
}
119120
catch (Exception ex)
120121
{
121122
executableTest.SetResult(TestState.Failed, ex);
122-
123-
// Run per-retry cleanup hooks before re-throwing
123+
capturedException = ex;
124+
}
125+
finally
126+
{
124127
try
125128
{
126-
// Run After(Test) hooks
127129
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);
128-
129130
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);
130131
}
131-
catch
132+
catch (Exception ex)
132133
{
133-
// Swallow any exceptions from hooks when we already have a test failure
134+
hookException = ex;
134135
}
136+
}
135137

136-
// Check if the result was overridden - if so, respect the override and don't re-throw
137-
if (executableTest.Context.Execution.Result?.IsOverridden == true)
138-
{
139-
// Result was overridden, respect the override state (Passed, Failed, Skipped, etc.)
140-
// Don't re-throw the exception - the override has final say
141-
}
142-
else
143-
{
144-
throw;
145-
}
138+
// If test passed but hooks failed, throw hook exception to fail the test
139+
if (capturedException == null && hookException != null)
140+
{
141+
ExceptionDispatchInfo.Capture(hookException).Throw();
146142
}
147-
finally
143+
144+
// If test failed or was skipped, handle the test exception
145+
if (capturedException != null)
148146
{
149-
// Per-retry cleanup - runs for success path only
150-
if (executableTest.State != TestState.Failed)
147+
if (capturedException is SkipTestException)
151148
{
152-
// Run After(Test) hooks
153-
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);
149+
ExceptionDispatchInfo.Capture(capturedException).Throw();
150+
}
154151

155-
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);
152+
if (executableTest.Context.Execution.Result?.IsOverridden != true)
153+
{
154+
ExceptionDispatchInfo.Capture(capturedException).Throw();
156155
}
157-
// Note: Test instance disposal and After(Class/Assembly/Session) hooks
158-
// are now handled in TestCoordinator after the retry loop completes
159156
}
160157
}
161158

0 commit comments

Comments
 (0)