Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix BeforeEvery/AfterEvery hooks for Class and Assembly not being exe…
…cuted

The HookExecutor was missing invocation of BeforeEvery(Class), AfterEvery(Class),
BeforeEvery(Assembly), and AfterEvery(Assembly) hooks. While the Test-level hooks
had the correct pattern of executing both BeforeEvery and Before hooks, the same
pattern was missing for Class and Assembly levels.

Updated ExecuteBeforeClassHooksAsync, ExecuteAfterClassHooksAsync,
ExecuteBeforeAssemblyHooksAsync, and ExecuteAfterAssemblyHooksAsync to also
collect and execute the corresponding BeforeEvery/AfterEvery hooks, following the
same pattern already used for Test-level hooks.

Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/89836764-b81e-472f-9f00-c6e7e61c02ca
  • Loading branch information
Copilot and thomhurst committed Mar 24, 2026
commit 61582a2a688e2e44842be956c5797715d7732afe
230 changes: 160 additions & 70 deletions TUnit.Engine/Services/HookExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,66 +157,111 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel
}
#endif

var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false);
// Execute BeforeEvery(Assembly) hooks first (global hooks run before specific hooks)
var beforeEveryAssemblyHooks = await _hookCollectionService.CollectBeforeEveryAssemblyHooksAsync().ConfigureAwait(false);

if (hooks.Count == 0)
if (beforeEveryAssemblyHooks.Count > 0)
{
return;
foreach (var hook in beforeEveryAssemblyHooks)
{
try
{
assemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (ex is SkipTestException)
{
throw;
}

if (ex.InnerException is SkipTestException skipEx)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}

throw new BeforeAssemblyException($"BeforeEveryAssembly hook failed: {ex.Message}", ex);
}
}
}

foreach (var hook in hooks)
// Execute Before(Assembly) hooks after BeforeEvery hooks
var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false);

if (hooks.Count > 0)
{
try
foreach (var hook in hooks)
{
assemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (ex is SkipTestException)
try
{
throw;
assemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false);
}

if (ex.InnerException is SkipTestException skipEx)
catch (Exception ex)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}
if (ex is SkipTestException)
{
throw;
}

throw new BeforeAssemblyException($"BeforeAssembly hook failed: {ex.Message}", ex);
if (ex.InnerException is SkipTestException skipEx)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}

throw new BeforeAssemblyException($"BeforeAssembly hook failed: {ex.Message}", ex);
}
}
}
}

public async ValueTask<List<Exception>> ExecuteAfterAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken)
{
var afterAssemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly);
var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly).ConfigureAwait(false);

if (hooks.Count == 0)
{
#if NET
FinishAssemblyActivity(assembly, hasErrors: false);
#endif
return [];
}

// Defer exception list allocation until actually needed
List<Exception>? exceptions = null;

foreach (var hook in hooks)
// Execute After(Assembly) hooks first (specific hooks run before global hooks for cleanup)
var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly).ConfigureAwait(false);

if (hooks.Count > 0)
{
try
foreach (var hook in hooks)
{
afterAssemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false);
try
{
afterAssemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Collect hook exceptions instead of throwing immediately
// This allows all hooks to run even if some fail
exceptions ??= [];
exceptions.Add(new AfterAssemblyException($"AfterAssembly hook failed: {ex.Message}", ex));
}
}
catch (Exception ex)
}

// Execute AfterEvery(Assembly) hooks after After hooks (global hooks run last for cleanup)
var afterEveryAssemblyHooks = await _hookCollectionService.CollectAfterEveryAssemblyHooksAsync().ConfigureAwait(false);

if (afterEveryAssemblyHooks.Count > 0)
{
foreach (var hook in afterEveryAssemblyHooks)
{
// Collect hook exceptions instead of throwing immediately
// This allows all hooks to run even if some fail
exceptions ??= [];
exceptions.Add(new AfterAssemblyException($"AfterAssembly hook failed: {ex.Message}", ex));
try
{
afterAssemblyContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
exceptions ??= [];
exceptions.Add(new AfterAssemblyException($"AfterEveryAssembly hook failed: {ex.Message}", ex));
}
}
}

Expand Down Expand Up @@ -271,33 +316,61 @@ public async ValueTask ExecuteBeforeClassHooksAsync(
}
#endif

var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false);
// Execute BeforeEvery(Class) hooks first (global hooks run before specific hooks)
var beforeEveryClassHooks = await _hookCollectionService.CollectBeforeEveryClassHooksAsync().ConfigureAwait(false);

if (hooks.Count == 0)
if (beforeEveryClassHooks.Count > 0)
{
return;
foreach (var hook in beforeEveryClassHooks)
{
try
{
classContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (ex is SkipTestException)
{
throw;
}

if (ex.InnerException is SkipTestException skipEx)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}

throw new BeforeClassException($"BeforeEveryClass hook failed: {ex.Message}", ex);
}
}
}

foreach (var hook in hooks)
// Execute Before(Class) hooks after BeforeEvery hooks
var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false);

if (hooks.Count > 0)
{
try
foreach (var hook in hooks)
{
classContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (ex is SkipTestException)
try
{
throw;
classContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false);
}

if (ex.InnerException is SkipTestException skipEx)
catch (Exception ex)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}
if (ex is SkipTestException)
{
throw;
}

throw new BeforeClassException($"BeforeClass hook failed: {ex.Message}", ex);
if (ex.InnerException is SkipTestException skipEx)
{
ExceptionDispatchInfo.Capture(skipEx).Throw();
}

throw new BeforeClassException($"BeforeClass hook failed: {ex.Message}", ex);
}
}
}
}
Expand All @@ -307,32 +380,49 @@ public async ValueTask<List<Exception>> ExecuteAfterClassHooksAsync(
Type testClass, CancellationToken cancellationToken)
{
var afterClassContext = _contextProvider.GetOrCreateClassContext(testClass);
var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClass).ConfigureAwait(false);

if (hooks.Count == 0)
{
#if NET
FinishClassActivity(testClass, hasErrors: false);
#endif
return [];
}

// Defer exception list allocation until actually needed
List<Exception>? exceptions = null;

foreach (var hook in hooks)
// Execute After(Class) hooks first (specific hooks run before global hooks for cleanup)
var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClass).ConfigureAwait(false);

if (hooks.Count > 0)
{
try
foreach (var hook in hooks)
{
afterClassContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false);
try
{
afterClassContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Collect hook exceptions instead of throwing immediately
// This allows all hooks to run even if some fail
exceptions ??= [];
exceptions.Add(new AfterClassException($"AfterClass hook failed: {ex.Message}", ex));
}
}
catch (Exception ex)
}

// Execute AfterEvery(Class) hooks after After hooks (global hooks run last for cleanup)
var afterEveryClassHooks = await _hookCollectionService.CollectAfterEveryClassHooksAsync().ConfigureAwait(false);

if (afterEveryClassHooks.Count > 0)
{
foreach (var hook in afterEveryClassHooks)
{
// Collect hook exceptions instead of throwing immediately
// This allows all hooks to run even if some fail
exceptions ??= [];
exceptions.Add(new AfterClassException($"AfterClass hook failed: {ex.Message}", ex));
try
{
afterClassContext.RestoreExecutionContext();
await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
exceptions ??= [];
exceptions.Add(new AfterClassException($"AfterEveryClass hook failed: {ex.Message}", ex));
}
}
}

Expand Down
Loading