From 3d19f3cef838e36aab227e01a869c0ca166036cc Mon Sep 17 00:00:00 2001 From: Sean Killeen Date: Thu, 22 Oct 2026 18:49:25 -0400 Subject: [PATCH] Correct and complete the UnhandledExceptionHandling attribute documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `UnhandledExceptionHandling` page existed but had two inaccuracies and a gap, checked against `NUnitTestAssemblyRunner`, `UnhandledExceptionHandlingAttribute` and the framework's own tests: * **Precedence was wrong.** The page said settings at different levels are "combined - each level's configuration is additive". `GetUnhandledExceptionHandlingFor` actually walks method → fixture → assembly and uses the first level with a match; within a level, an entry naming the exception type beats an all-exceptions entry. Replaced with a "Precedence When Applied at Several Levels" section and an example of ignoring everything except one type at the same level. * **Platform behaviour was missing.** On .NET Core / .NET 5+, an unhandled exception on another thread terminates the process regardless of the attribute (the framework tests are limited to `NETFRAMEWORK` for this reason), and unobserved `Task` exceptions are raised on the finalizer thread where NUnit cannot attribute them to a test. Added a "Platform Support" section and rewrote the "Common Scenarios" that used `Task.Run` as if the attribute would catch them. * Added the constructor table, a "When to Use It" section (the issue asked when it is appropriate), fixed the See Also links, and added the missing row on the attributes index page. * Updated the two snippet examples that spawned tasks to use threads that do not throw, with comments explaining why they cannot throw on modern .NET. Snippet tests pass (6 passed, 1 skipped on .NET as before). Fixes #1151 Co-Authored-By: Claude Fable 5.1 --- .../nunit/writing-tests/attributes.md | 1 + .../attributes/unhandledexceptionhandling.md | 122 ++++++++++-------- ...ndledExceptionHandlingAttributeExamples.cs | 34 ++--- 3 files changed, 82 insertions(+), 75 deletions(-) diff --git a/docs/articles/nunit/writing-tests/attributes.md b/docs/articles/nunit/writing-tests/attributes.md index 4f3ce79b5..ca59fa3b7 100644 --- a/docs/articles/nunit/writing-tests/attributes.md +++ b/docs/articles/nunit/writing-tests/attributes.md @@ -56,5 +56,6 @@ This table lists all the attributes supported by NUnit. | [TestOf Attribute](xref:attribute-testof) | Indicates the name or Type of the class being tested. | | [Theory Attribute](xref:attribute-theory) | Marks a test method as a Theory, a special kind of test in NUnit. | | [Timeout Attribute](xref:attribute-timeout) | Provides a timeout value in milliseconds for test cases. | +| [UnhandledExceptionHandling Attribute](xref:attribute-unhandledexceptionhandling) | Controls whether unhandled exceptions on other threads fail the test. | | [Values Attribute](xref:attribute-values) | Provides a set of inline values for a parameter of a test method. | | [ValueSource Attribute](xref:attribute-valuesource) | Provides a source of values for a parameter of a test method. | diff --git a/docs/articles/nunit/writing-tests/attributes/unhandledexceptionhandling.md b/docs/articles/nunit/writing-tests/attributes/unhandledexceptionhandling.md index 3a87f7d5d..a419dbd84 100644 --- a/docs/articles/nunit/writing-tests/attributes/unhandledexceptionhandling.md +++ b/docs/articles/nunit/writing-tests/attributes/unhandledexceptionhandling.md @@ -18,6 +18,21 @@ attribute allows you to change that behavior. > This attribute only affects exceptions raised on threads other than the main test thread. Exceptions on the main test > thread will always cause the test to fail, regardless of this attribute's setting. +## Constructors + +```csharp +UnhandledExceptionHandlingAttribute(UnhandledExceptionHandling handling) +UnhandledExceptionHandlingAttribute(UnhandledExceptionHandling handling, params Type[] exceptions) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `handling` | `UnhandledExceptionHandling` | How NUnit treats a matching unhandled exception. | +| `exceptions` | `Type[]` | Exception types this entry applies to. When omitted, the entry applies to all exception types. | + +The attribute may be applied more than once to the same element, so that different exception types get different +handling. + ## Handling Modes The attribute accepts an `UnhandledExceptionHandling` enum value: @@ -34,6 +49,24 @@ The attribute accepts an `UnhandledExceptionHandling` enum value: |--------------|--------------------------|----------| | ✅ | ✅ | ✅ | +## Platform Support + +What this attribute can do depends on what the runtime allows. + +* **.NET Framework:** an unhandled exception on another thread is reported to NUnit, which attributes it to the test + that was running and either records an error or, with `Ignore`, lets the test complete normally. This is the platform + the attribute was designed for, and the only one where `Ignore` keeps the process alive. +* **.NET Core and .NET 5 and later:** an unhandled exception on a thread **terminates the test process**, whatever this + attribute says. NUnit still receives the exception first and records an error when the mode is `Error`, but `Ignore` + cannot prevent the crash. .NET 10 added an API that lets a handler mark such an exception as handled, but NUnit does + not use it yet. +* **Unobserved `Task` exceptions:** these do not terminate the process on modern .NET. They are reported on the finalizer + thread, where NUnit usually cannot tell which test started the task, so they are logged and do not fail any test, + regardless of this attribute. + +In practice the attribute matters for exceptions thrown on threads your test code creates itself, such as `new Thread`, +and for `ThreadAbortException` on .NET Framework. + ## Basic Usage ### Error Mode (Default) @@ -78,80 +111,60 @@ ignore `ThreadAbortException` specifically: > `Thread.Abort()` is obsolete in .NET 5+ and throws `PlatformNotSupportedException` on those platforms. > Consider migrating to `CancellationToken`-based cancellation patterns instead. -### Testing Fire-and-Forget Operations +### Background Threads Started by the Code Under Test (.NET Framework) -When testing code that intentionally spawns background work that might fail independently: +When the system under test starts its own threads and a failure on one of them is a known, accepted condition: ```csharp -[Test] -[UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore)] -public void TestFireAndForgetLogging() +[TestFixture] +[UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore, typeof(ObjectDisposedException))] +public class LegacySystemTests { - // The system under test fires off logging that we don't want to wait for - var sut = new ServiceWithBackgroundLogging(); - var result = sut.DoWork(); - - Assert.That(result, Is.EqualTo(expected)); - // Background logging exceptions won't fail this test + // The legacy component's cleanup thread sometimes throws ObjectDisposedException + // after the test has finished with it. On .NET Framework that no longer fails the test. } ``` -### Testing Cancellation Scenarios - -When `OperationCanceledException` on background threads is expected and should be ignored: +On .NET Core and later the same exception would terminate the process, so the only real fix there is to stop the +exception from escaping the thread, for example by catching it in the thread procedure. -```csharp -[Test] -[UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore, typeof(OperationCanceledException))] -public async Task TestCancellationInBackgroundWork() -{ - using var cts = new CancellationTokenSource(); - - // Fire-and-forget background work that observes the cancellation token - _ = Task.Run(() => LongRunningOperationAsync(cts.Token)); +## Precedence When Applied at Several Levels - // Trigger cancellation while the test continues - cts.Cancel(); - - // Perform other assertions that do not await the background work - await Task.Delay(50); - Assert.That(SomeResult(), Is.EqualTo(expected)); - - // Any OperationCanceledException thrown on the background task - // will be ignored by NUnit due to the UnhandledExceptionHandling setting -} -``` +The attribute can be applied at the assembly, fixture and method level. When an unhandled exception arrives, NUnit +looks for a matching entry starting at the **test method**, then the **fixture**, then the **assembly**, and uses the +first level that has one: -### Legacy Code Integration +1. At a given level, an entry that names the exception type wins over an entry that covers all exception types. +2. If a level has no entry for that exception, NUnit moves up to the next level. +3. If no level matches, the default `Error` applies. -When integrating with legacy code that has known background thread issues: +Settings are **not** merged across levels. A method-level `[UnhandledExceptionHandling(Ignore)]` covers every exception +type for that test, so a fixture-level entry for a specific type is never consulted for it. To ignore everything except +one type at the same level, give that type its own `Error` entry: ```csharp -[TestFixture] +[Test] [UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore)] -public class LegacySystemTests +[UnhandledExceptionHandling(UnhandledExceptionHandling.Error, typeof(InvalidOperationException))] +public void IgnoresEverythingExceptInvalidOperation() { - // Legacy system has known issues with background thread cleanup - // that we accept for now + // ... } ``` -## Inheritance and Scope - -The attribute can be applied at multiple levels: +## When to Use It -| Level | Scope | -| ------- | ------- | -| Assembly | Affects all tests in the assembly | -| Class | Affects all tests in the fixture | -| Method | Affects only the specific test | +> [!WARNING] +> Using `Ignore` mode can hide real bugs in your code. Use it sparingly and only when you understand the implications. -When multiple levels specify the attribute, they are combined - each level's configuration is additive. +The attribute is appropriate when: -## Best Practices +* You are on .NET Framework and test code that uses `Thread.Abort()`, so `ThreadAbortException` is expected. +* The code under test runs a background thread whose failure is already known, cannot be fixed in the test, and is + unrelated to what the test verifies. Name the exception type rather than ignoring everything. -> [!WARNING] -> Using `Ignore` mode can hide real bugs in your code. Use it sparingly and only when you understand the implications. +It is **not** a substitute for awaiting tasks or joining threads before the test ends. If the test can observe the +background work, do that instead, so a failure is reported where it happened. 1. **Prefer `Error` mode** - The default behavior ensures you're aware of all exceptions 2. **Be specific with exception types** - When using `Ignore`, specify only the exception types you expect @@ -160,5 +173,6 @@ When multiple levels specify the attribute, they are combined - each level's con ## See Also -* [Assert.Throws](xref:classic-assert-throws) -* [Assert.ThrowsAsync](xref:classic-assert-throws-async) +* [CancelAfter Attribute](xref:attribute-cancelafter) +* [Timeout Attribute](xref:attribute-timeout) +* [Property Attribute](xref:attribute-property) diff --git a/docs/snippets/Snippets.NUnit/Attributes/UnhandledExceptionHandlingAttributeExamples.cs b/docs/snippets/Snippets.NUnit/Attributes/UnhandledExceptionHandlingAttributeExamples.cs index c1c7eff05..ff26ebb21 100644 --- a/docs/snippets/Snippets.NUnit/Attributes/UnhandledExceptionHandlingAttributeExamples.cs +++ b/docs/snippets/Snippets.NUnit/Attributes/UnhandledExceptionHandlingAttributeExamples.cs @@ -27,37 +27,29 @@ public void TestWithErrorHandling() [UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore)] public void TestIgnoringBackgroundExceptions() { - // Background exceptions are ignored - use with caution! - // This test will pass even if background threads throw - using var cts = new CancellationTokenSource(); - var task = Task.Run(() => - { - // Simulate work - Thread.Sleep(10); - }, cts.Token); + // An exception escaping this thread is not recorded against the test. + // On .NET Framework the test then passes; on .NET Core and later the + // runtime still terminates the process, so the thread procedure below + // must not actually throw. + var thread = new Thread(() => Thread.Sleep(10)); + thread.Start(); + thread.Join(); - task.Wait(); Assert.Pass(); } #endregion #region UnhandledExceptionSpecificTypes [Test] - [UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore, typeof(OperationCanceledException))] + [UnhandledExceptionHandling(UnhandledExceptionHandling.Ignore, typeof(ObjectDisposedException))] public void TestIgnoringSpecificExceptions() { - // Only OperationCanceledException is ignored on background threads - // Other exception types will still cause the test to fail - - _ = Task.Run(() => - { - // This will throw on a background thread - Thread.Sleep(10); - throw new OperationCanceledException(); - }); + // Only ObjectDisposedException escaping a background thread is ignored. + // Any other exception type is still recorded as a test error. + var thread = new Thread(() => Thread.Sleep(10)); + thread.Start(); + thread.Join(); - // The unhandled OperationCanceledException on the background thread - // will be ignored by the attribute, so this test still passes Assert.Pass(); } #endregion