diff --git a/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs b/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs new file mode 100644 index 0000000000..164c7a45c9 --- /dev/null +++ b/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs @@ -0,0 +1,22 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; +using TUnit.Engine.Tests.Extensions; + +namespace TUnit.Engine.Tests; + +public class AfterEveryAssemblyHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/AfterEveryAssemblyTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0), + _ => FindFile(x => x.Name.Contains("AfterEveryAssemblyTests") && x.Extension == ".txt").AssertExists() + ]); + } +} diff --git a/TUnit.Engine.Tests/AfterEveryClassHookTests.cs b/TUnit.Engine.Tests/AfterEveryClassHookTests.cs new file mode 100644 index 0000000000..19d85d9d84 --- /dev/null +++ b/TUnit.Engine.Tests/AfterEveryClassHookTests.cs @@ -0,0 +1,22 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; +using TUnit.Engine.Tests.Extensions; + +namespace TUnit.Engine.Tests; + +public class AfterEveryClassHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/AfterEveryClassTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0), + _ => FindFile(x => x.Name.Contains("AfterEveryClassTests") && x.Extension == ".txt").AssertExists() + ]); + } +} diff --git a/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs b/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs new file mode 100644 index 0000000000..6e29017c55 --- /dev/null +++ b/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class BeforeEveryAssemblyHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/BeforeEveryAssemblyTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs b/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs new file mode 100644 index 0000000000..be2ae791c6 --- /dev/null +++ b/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class BeforeEveryClassHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/BeforeEveryClassTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 47911cbe38..e2c3589063 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -157,33 +157,61 @@ 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); + } } } } @@ -191,32 +219,49 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel public async ValueTask> 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? 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)); + } } } @@ -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); + } } } } @@ -307,32 +380,49 @@ public async ValueTask> 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? 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)); + } } } diff --git a/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs b/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs new file mode 100644 index 0000000000..985eb25ea6 --- /dev/null +++ b/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.AfterTests; + +public class AfterEveryAssemblyHooks +{ + [AfterEvery(Assembly)] + public static async Task AfterEveryAssembly(AssemblyHookContext context) + { + foreach (var test in context.AllTests) + { + if (test.Metadata.TestDetails.TestName == nameof(AfterEveryAssemblyTests.EnsureAfterEveryAssemblyRuns)) + { + await File.WriteAllTextAsync($"AfterEveryAssemblyTests{Guid.NewGuid():N}.txt", "AfterEvery(Assembly) executed"); + } + } + } +} + +public class AfterEveryAssemblyTests +{ + [Test] + public async Task EnsureAfterEveryAssemblyRuns() + { + await Task.CompletedTask; + } +} diff --git a/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs b/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs new file mode 100644 index 0000000000..8791f9161f --- /dev/null +++ b/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.AfterTests; + +public class AfterEveryClassHooks +{ + [AfterEvery(Class)] + public static async Task AfterEveryClass(ClassHookContext context) + { + foreach (var test in context.Tests) + { + if (test.Metadata.TestDetails.TestName == nameof(AfterEveryClassTests.EnsureAfterEveryClassRuns)) + { + await File.WriteAllTextAsync($"AfterEveryClassTests{Guid.NewGuid():N}.txt", "AfterEvery(Class) executed"); + } + } + } +} + +public class AfterEveryClassTests +{ + [Test] + public async Task EnsureAfterEveryClassRuns() + { + await Task.CompletedTask; + } +} diff --git a/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs b/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs new file mode 100644 index 0000000000..cd41f54933 --- /dev/null +++ b/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.BeforeTests; + +public class BeforeEveryAssemblyHooks +{ + [BeforeEvery(Assembly)] + public static void BeforeEveryAssembly(AssemblyHookContext context) + { + foreach (var test in context.AllTests) + { + if (test.Metadata.TestDetails.TestName == nameof(BeforeEveryAssemblyTests.EnsureBeforeEveryAssemblyHit)) + { + test.StateBag.Items["BeforeEveryAssemblyHit"] = true; + } + } + } +} + +public class BeforeEveryAssemblyTests +{ + [Test] + public async Task EnsureBeforeEveryAssemblyHit() + { + await Assert.That(TestContext.Current?.StateBag.Items["BeforeEveryAssemblyHit"]).IsEquatableOrEqualTo(true); + } +} diff --git a/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs b/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs new file mode 100644 index 0000000000..7429d7e299 --- /dev/null +++ b/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs @@ -0,0 +1,22 @@ +namespace TUnit.TestProject.BeforeTests; + +public class BeforeEveryClassHooks +{ + [BeforeEvery(Class)] + public static void BeforeEveryClass(ClassHookContext context) + { + foreach (var test in context.Tests) + { + test.StateBag.Items["BeforeEveryClassHit"] = true; + } + } +} + +public class BeforeEveryClassTests +{ + [Test] + public async Task EnsureBeforeEveryClassHit() + { + await Assert.That(TestContext.Current?.StateBag.Items["BeforeEveryClassHit"]).IsEquatableOrEqualTo(true); + } +}