From 9ebfd0383e7aef8063748d8deffddc72a392794e Mon Sep 17 00:00:00 2001 From: Rajiv Sinha Date: Thu, 23 Oct 2025 21:35:26 +0200 Subject: [PATCH] Initial version of documentation of ExecutionHook attribute --- .../nunit/extending-nunit/Execution-Hooks.md | 104 ++++++++++++++++++ .../Framework-Extensibility.md | 5 +- docs/articles/nunit/extending-nunit/toc.yml | 2 + .../Snippets.NUnit/ExecutionHookExamples.cs | 78 +++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/articles/nunit/extending-nunit/Execution-Hooks.md create mode 100644 docs/snippets/Snippets.NUnit/ExecutionHookExamples.cs diff --git a/docs/articles/nunit/extending-nunit/Execution-Hooks.md b/docs/articles/nunit/extending-nunit/Execution-Hooks.md new file mode 100644 index 000000000..3b1217f41 --- /dev/null +++ b/docs/articles/nunit/extending-nunit/Execution-Hooks.md @@ -0,0 +1,104 @@ +# Execution Hooks + +Execution Hooks provide structured, ordered, exception-aware extension points around each core test lifecycle phase. They complement (and can wrap) [Action Attributes](Action-Attributes.md) while staying focused on execution. + +Key differences to Action Attributes: + +- Execution Hooks focus on the immediate test invocation phases (before/after setup, test, teardown and test action callbacks). +- Only overridden hook methods are registered, keeping runtime overhead low. +- After-hooks run in reverse order (stack behavior) to naturally unwind resources paired with the corresponding before-hooks. + +## When to use Execution Hooks + +Execution Hooks should be used when there is a need to: + +- Time, log, trace or audit test phases precisely. +- Inject state around each SetUp/TearDown. +- Pair resource acquisition/release symmetrically (BeforeX/AfterX). +- React to exceptions thrown by setup, test or teardown methods. +- Integrate with or augment existing Action Attributes behavior at a finer granularity. + +## Getting started + +Derive from `ExecutionHookAttribute` and override only the methods that are relevant: + +| Method | Triggered immediately | Applies To | +|--------|-----------------------|------------| +| `BeforeEverySetUpHook` | Before each `[SetUp]` or `[OneTimeSetUp]` method | All fixture & base fixture setup methods | +| `AfterEverySetUpHook` | After each `[SetUp]` or `[OneTimeSetUp]` method | All fixture & base fixture setup methods | +| `BeforeTestHook` | Before the test method | The test method | +| `AfterTestHook` | After the test method | The test method | +| `BeforeEveryTearDownHook` | Before each `[TearDown]` or `[OneTimeTearDown]` method | All fixture & base fixture teardown methods | +| `AfterEveryTearDownHook` | After each `[TearDown]` or `[OneTimeTearDown]` method | All fixture & base fixture teardown methods | +| `BeforeTestActionBeforeTestHook` | Before an `ITestAction.BeforeTest(ITest)` executes | Each applicable Action Attribute | +| `BeforeTestActionAfterTestHook` | After an `ITestAction.BeforeTest(ITest)` executes | Each applicable Action Attribute | +| `AfterTestActionBeforeTestHook` | Before an `ITestAction.AfterTest(ITest)` executes | Each applicable Action Attribute | +| `AfterTestActionAfterTestHook` | After an `ITestAction.AfterTest(ITest)` executes | Each applicable Action Attribute | + +This derived attribute can be applied at the method, class, or assembly level. + +Each hook receives a `HookData` instance: + +- `Context`: A `TestContext` snapshot (current test, properties, etc.). +- `HookedMethod`: The `MethodInfoAdapter` of the method currently executing (e.g., the specific `[SetUp]`, test, or `[TearDown]`). +- `Exception`: Non-null only for after-hooks when the hooked method threw. + +Use these fields for logging, conditional logic, or adaptive cleanup. + +## Example: Measure Time for Setup + +[!code-csharp[ExecutionHookAttributeExample](~/snippets/Snippets.NUnit/ExecutionHookExamples.cs#TimingHookAttribute)] + +Usage: + +[!code-csharp[ExecutionHookAttributeExample](~/snippets/Snippets.NUnit/ExecutionHookExamples.cs#Usage)] + +## Example: Logging All Phases + +[!code-csharp[ExecutionHookAttributeExample](~/snippets/Snippets.NUnit/ExecutionHookExamples.cs#LoggingAllPhases)] + +## Exception Handling + +In general: + +- If an Execution Hook throws an exception, NUnit treats it in the same way as if the hooked method had thrown it. +For example, an exception from a before/after setup hook is handled the same way as an exception from the setup method itself. +- Independent if a before hook method or the hooked method itself is throwing an exception it is always guaranteed that the after hook method is called and contains the exception details within the `HookData`. + +Behavior illustrated by hooking a test method: + +- If a `BeforeTestHook` throws, the test method body is skipped, but its `AfterTestHook` still runs (with `HookData.Exception` set) allowing cleanup/logging. +- If an `AfterTestHook` throws, NUnit still proceeds with TearDown phases (remaining hooks and teardown methods run). +- Failures inside a test body still trigger all `AfterTestHook` executions. +- Setup/TearDown exceptions are reported by the corresponding after-hook with `HookData.Exception` populated. + +## Ordering Semantics + +If multiple attributes are applied: + +- Before-hooks (`BeforeEverySetUpHook`, `BeforeTestHook`, etc.) execute in the order, attributes were applied (declaration order on the method/class/assembly). +- After-hooks execute in reverse order, enabling natural stacking: + - Attribute A before, Attribute B before - Attribute B after, then Attribute A after. +- When multiple attributes appear at different scopes (assembly, class, method), hooks from broader scopes run before narrower scopes for "before" phases, and after narrower scopes for "after" phases (as shown in sequence tests). + +## Scope: Method vs Class vs Assembly + +Because the `AttributeUsage` targets can be chosen on the derived attribute, control over where hooks can be applied is provided: + +- Method: affects only that test method. +- Class: affects all tests within the fixture and inherited base fixture methods. +- Assembly: affects every test in the assembly. + +Hooks from broader scopes wrap those from narrower scopes. For a single test method with an assembly-level and a method-level TimingHook: + +1. Assembly `BeforeTestHook` runs first. +2. Method `BeforeTestHook` runs. +3. Test executes. +4. Method `AfterTestHook` runs. +5. Assembly `AfterTestHook` runs. + +## See Also + +- [Action Attributes](~/articles/nunit/extending-nunit/Action-Attributes.md) +- [Custom Attributes](~/articles/nunit/extending-nunit/Custom-Attributes.md) +- [Framework Extensibility](~/articles/nunit/extending-nunit/Framework-Extensibility.md) diff --git a/docs/articles/nunit/extending-nunit/Framework-Extensibility.md b/docs/articles/nunit/extending-nunit/Framework-Extensibility.md index 04f97958e..d62a65d75 100644 --- a/docs/articles/nunit/extending-nunit/Framework-Extensibility.md +++ b/docs/articles/nunit/extending-nunit/Framework-Extensibility.md @@ -4,7 +4,7 @@ The NUnit Framework is the part of NUnit that is referenced by user tests. It co Attributes, Constraints and Asserts as well as the code that discovers and executes tests. Most extensions to exactly how tests are recognized and how they execute are Framework extensions. -In this documentation, we refer to three different types of Framework extension: +In this documentation, we refer to four different types of Framework extension: [Custom Attributes](Custom-Attributes.md) allow creation of new types of tests and suites, new sources of data and modification of the environment in which a test runs as well as its final result. @@ -18,6 +18,9 @@ to many tests. [Custom Constraints](Custom-Constraints.md) allow the user to define new constraints for use in tests along with the associated fluent syntax that allows them to be used with `Assert.That`. +[Execution Hooks](Execution-Hooks.md) are a NUnit extensibility feature that lets custom code run +at precise moments in the lifecycle of every test method. + ## Links to Blog Posts ### On Custom constraints diff --git a/docs/articles/nunit/extending-nunit/toc.yml b/docs/articles/nunit/extending-nunit/toc.yml index 06947af14..6efe9d947 100644 --- a/docs/articles/nunit/extending-nunit/toc.yml +++ b/docs/articles/nunit/extending-nunit/toc.yml @@ -26,4 +26,6 @@ href: ITestBuilder-Interface.md - name: Custom Constraints href: Custom-Constraints.md + - name: Execution Hooks + href: Execution-Hooks.md diff --git a/docs/snippets/Snippets.NUnit/ExecutionHookExamples.cs b/docs/snippets/Snippets.NUnit/ExecutionHookExamples.cs new file mode 100644 index 000000000..41b903d15 --- /dev/null +++ b/docs/snippets/Snippets.NUnit/ExecutionHookExamples.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; +using NUnit.Framework.Internal.ExecutionHooks; + +namespace Snippets.NUnit +{ + public class ExecutionHookExamples + { + #region TimingHookAttribute + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public sealed class TimeMeasurementHookAttribute : ExecutionHookAttribute + { + private readonly Dictionary _starts = new(); + + public override void BeforeEverySetUpHook(HookData hookData) + { + _starts[hookData.Context.Test.FullName] = DateTime.UtcNow; + } + + public override void AfterEverySetUpHook(HookData hookData) + { + var key = hookData.Context.Test.FullName; + if (_starts.TryGetValue(key, out var start)) + { + var elapsed = DateTime.UtcNow - start; + TestContext.WriteLine($"[Timing] " + + $"{hookData.Context.Test.MethodName} " + + $"took {elapsed.TotalMilliseconds:F1} ms"); + } + } + } + #endregion + + #region Usage + [TestFixture] + [TimeMeasurementHook] + public class SampleTests + { + [SetUp] + public void HeavySetUp() { /* ... */ } + + [Test] + public void FastTest() { /* ... */ } + } + #endregion + + #region LoggingAllPhases + [AttributeUsage(AttributeTargets.Method)] + public sealed class LogAllHooksAttribute : ExecutionHookAttribute + { + private void Log(string phase, HookData data, bool withException = false) + { + var name = data.Context.Test.MethodName; + var exInfo = withException && data.Exception != null ? + $" (EX: {data.Exception.GetType().Name})" : string.Empty; + TestContext.WriteLine($"[{phase}] {name}{exInfo}"); + } + + public override void BeforeEverySetUpHook(HookData d) + => Log("BeforeEverySetUp", d); + + public override void AfterEverySetUpHook(HookData d) + => Log("AfterEverySetUp", d, withException: true); + + public override void BeforeTestHook(HookData d) + => Log("BeforeTest", d); + + public override void AfterTestHook(HookData d) + => Log("AfterTest", d, withException: true); + + public override void BeforeEveryTearDownHook(HookData d) + => Log("BeforeEveryTearDown", d); + + public override void AfterEveryTearDownHook(HookData d) + => Log("AfterEveryTearDown", d, withException: true); + } + #endregion + } +} \ No newline at end of file