Description
When any Assert.Throws<T>() or Assert.ThrowsExactly<T>() overload is called inside an AssertScope and the assertion fails (either because no exception is thrown or because the wrong exception type is thrown), the method returns null instead of throwing.
This violates the non-nullable TException return contract and can lead to a NullReferenceException in user code that accesses the returned exception before the enclosing scope is disposed.
The root cause is that ReportAssertFailed() detects an active AssertScope and records the failure through scope.AddError() rather than throwing immediately. Execution then continues, and the caller reaches a return null! statement that exists only to satisfy the compiler's requirement for a return value.
As a result, the assertion failure is deferred until scope disposal, while a null value becomes observable to user code.
This affects all overloads across:
including both interpolated-string-handler and explicit-message variants.
Version Used
Latest main branch (commit current at the time of analysis).
The issue affects all versions that include the AssertScope feature (introduced in MSTest 3.x).
Steps To Reproduce
Create a new MSTest 3.x project targeting .NET 8 or later.
Add the following test:
[TestMethod]
public void Throws_ReturnsNull_WhenAssertionFailsInsideScope()
{
using var scope = new AssertScope();
InvalidOperationException ex =
Assert.Throws<InvalidOperationException>(() => { });
string msg = ex.Message; // NullReferenceException
}
Run the test.
Observed Result
A NullReferenceException is thrown when accessing ex.Message.
The same behavior occurs with all related overloads, including:
Assert.ThrowsExactly<InvalidOperationException>(() => { });
Assert.Throws<InvalidOperationException>(
() => { },
"message");
Assert.ThrowsExactly<InvalidOperationException>(
() => { },
ex => "message");
await Assert.ThrowsAsync<InvalidOperationException>(
() => Task.CompletedTask);
await Assert.ThrowsExactlyAsync<InvalidOperationException>(
() => Task.CompletedTask);
Expected Behavior
The assertion API should never expose a null value to user code.
Possible valid behaviors include:
Throwing immediately (while still allowing AssertScope to capture the failure).
Returning a safe non-null sentinel value.
Using another mechanism that preserves the non-nullable return contract.
Regardless of implementation, a failed assertion should not result in a user-observable null return value.
Actual Behavior
When an assertion fails inside an AssertScope, Assert.Throws<T>() returns null.
The returned value is observable by user code between:
The call to Assert.Throws<T>(), and
The eventual disposal of the enclosing AssertScope.
Any access to the returned exception instance can therefore throw a NullReferenceException, masking the original assertion failure and producing a misleading test result.
Additional Context
Affected file
src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs
Affected return null! locations
| Line |
Method |
| 54 |
AssertNonStrictThrowsInterpolatedStringHandler.ComputeAssertion |
| 125 |
AssertThrowsExactlyInterpolatedStringHandler.ComputeAssertion |
| 352 |
ThrowsException (sync, string message) |
| 374 |
ThrowsException (sync, Func messageBuilder) |
| 512 |
ThrowsExceptionAsync (async, string message) |
| 534 |
ThrowsExceptionAsync (async, Func messageBuilder) |
Root Cause Execution Path
Assert.ThrowsException.cs:46
→ ReportThrowsFailed()
→ Assert.ThrowsException.cs:628
→ ReportAssertFailed(structuredMessage)
→ Assert.cs:177
(AssertScope.Current is { } scope)
→ Assert.cs:188
scope.AddError(ex)
→ Assert.cs:189
return;
→ Assert.ThrowsException.cs:54
return null!;
Because ReportAssertFailed() returns normally when an AssertScope is active, execution reaches the fallback return null! path and exposes a null value to user code.
Description
When any
Assert.Throws<T>()orAssert.ThrowsExactly<T>()overload is called inside anAssertScopeand the assertion fails (either because no exception is thrown or because the wrong exception type is thrown), the method returnsnullinstead of throwing.This violates the non-nullable
TExceptionreturn contract and can lead to aNullReferenceExceptionin user code that accesses the returned exception before the enclosing scope is disposed.The root cause is that
ReportAssertFailed()detects an activeAssertScopeand records the failure throughscope.AddError()rather than throwing immediately. Execution then continues, and the caller reaches areturn null!statement that exists only to satisfy the compiler's requirement for a return value.As a result, the assertion failure is deferred until scope disposal, while a
nullvalue becomes observable to user code.This affects all overloads across:
Assert.Throws<T>()Assert.ThrowsExactly<T>()Assert.ThrowsAsync<T>()Assert.ThrowsExactlyAsync<T>()including both interpolated-string-handler and explicit-message variants.
Version Used
Latest
mainbranch (commit current at the time of analysis).The issue affects all versions that include the
AssertScopefeature (introduced in MSTest 3.x).Steps To Reproduce
Create a new MSTest 3.x project targeting .NET 8 or later.
Add the following test:
Run the test.
Observed Result
A
NullReferenceExceptionis thrown when accessingex.Message.The same behavior occurs with all related overloads, including:
Expected Behavior
The assertion API should never expose a
nullvalue to user code.Possible valid behaviors include:
Throwing immediately (while still allowing
AssertScopeto capture the failure).Returning a safe non-null sentinel value.
Using another mechanism that preserves the non-nullable return contract.
Regardless of implementation, a failed assertion should not result in a user-observable
nullreturn value.Actual Behavior
When an assertion fails inside an
AssertScope,Assert.Throws<T>()returnsnull.The returned value is observable by user code between:
The call to
Assert.Throws<T>(), andThe eventual disposal of the enclosing
AssertScope.Any access to the returned exception instance can therefore throw a
NullReferenceException, masking the original assertion failure and producing a misleading test result.Additional Context
Affected file
Affected
return null!locationsRoot Cause Execution Path
Because
ReportAssertFailed()returns normally when anAssertScopeis active, execution reaches the fallbackreturn null!path and exposes anullvalue to user code.