diff --git a/docs/articles/nunit/writing-tests/attributes.md b/docs/articles/nunit/writing-tests/attributes.md index a06b59cae..cafb210d1 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 654d852b0..754bb92a0 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