Define the observable contract
An exception contract can be as specific as an ordinary returned-value contract. A caller may require an exact ArgumentOutOfRangeException for an invalid quantity, rather than accepting any failure that happens somewhere in the operation. The small ThrowsExactly predicate observes that distinction by running an Action and comparing the runtime exception type. It is a deliberately limited portable observer, not a replacement implementation of MSTest or xUnit. Its boolean tells this lesson whether an observation matched one type; a production test report would also preserve the actual exception, message context, and stack information.
Walk through the real program
RequirePositive throws ArgumentOutOfRangeException for zero or a negative integer and returns normally for a positive integer. The first observation supplies zero and prints expected:True. The second supplies one, so no exception occurs and the predicate returns false, producing missing:False. The third deliberately throws InvalidOperationException instead and produces wrong:False. Those latter false values are expected demonstrations of the observer's behavior, not evidence that the sample itself has an unexplained failing test suite. The program never treats an unrelated failure as an acceptable argument-validation result.
Why the design is testable
The try block must contain the operation whose failure is under examination. Performing that action before passing it to the observer would throw outside the observation boundary. An Action lambda delays the call until the observer invokes it. Type equality here is exact: a derived exception would not satisfy a base exception expectation merely because a catch for that base type could handle it. Real frameworks have documented assertion APIs with their own exact-versus-derived contracts, so consult the particular API rather than inferring semantics from a method name alone.
A boundary that matters
Catching Exception is acceptable here only as part of the explicitly bounded test observation, where a mismatched type becomes a mismatch. It is not advice to swallow every exception in domain code. Returning false alone would be weak diagnostic information for a large suite, so a real assertion should expose what actually occurred. Do not make tests pass by replacing a specific expectation with a broad catch-all. Also avoid asserting a localized framework-generated error message unless that exact text is genuinely part of the application's controlled public contract.
Practice: isolate one behavior
First call the observer with negative one and predict another true match for ArgumentOutOfRangeException. Then test a custom derived exception against a base-type exact expectation and predict false. Hint: compare GetType with the expected Type, not merely whether a catch clause can receive the object. Keep the deliberate derived-type experiment separate from RequirePositive's domain implementation so the difference between observer semantics and business validation remains clear. Restore the simple baseline before adding another kind of failure outcome.
Practice: challenge another assumption
For a second exercise, return a diagnostic record from the observer containing whether anything was thrown and the observed type name. Test the expected, missing, and wrong-type paths again. Hint: preserve evidence rather than translating every mismatch into an unexplained false. In a separate real MSTest or xUnit project, select its documented exact-exception assertion and reproduce the domain scenario. Do not copy framework namespaces into this portable console sample without installing and configuring the actual test project and runner that own those assertions.
Review the evidence
A useful exception check identifies the action boundary, the expected type, the no-throw case, and the wrong-throw case. The original three lines demonstrate all of those distinctions while remaining a small executable program. Confidence comes from requiring the correct failure for the stated reason, not from proving only that something went wrong. Keep test observations separate from application recovery policy, and retain enough unexpected-exception information that a programming defect cannot be disguised as ordinary input rejection. When rejection must be atomic, also inspect the relevant state to ensure the expected exception occurred before any forbidden mutation or external effect.
- Exact type matching distinguishes the intended failure from an unrelated exception.
- The supplied action runs inside the observer, not before it.
- This boolean observer is deliberately smaller than a real framework assertion.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
bool ThrowsExactly<T>(Action action) where T : Exception
{
try { action(); return false; }
catch (Exception error) { return error.GetType() == typeof(T); }
}
void RequirePositive(int value) { if (value <= 0) throw new ArgumentOutOfRangeException(nameof(value)); }
Console.WriteLine($"expected:{ThrowsExactly<ArgumentOutOfRangeException>(() => RequirePositive(0))}");
Console.WriteLine($"missing:{ThrowsExactly<ArgumentOutOfRangeException>(() => RequirePositive(1))}");
Console.WriteLine($"wrong:{ThrowsExactly<ArgumentOutOfRangeException>(() => throw new InvalidOperationException())}");
Expected output
expected:True missing:False wrong:False
A wrong exception must not be accepted merely because something failed.