Skip to content

Assert.Throws<T>() / Assert.ThrowsExactly<T>() returns null when assertion fails inside AssertScope, violating non-nullable return contract #9069

@wi5nuu

Description

@wi5nuu

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:

  • Assert.Throws<T>()

  • Assert.ThrowsExactly<T>()

  • Assert.ThrowsAsync<T>()

  • Assert.ThrowsExactlyAsync<T>()

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

  1. Create a new MSTest 3.x project targeting .NET 8 or later.

  2. Add the following test:

[TestMethod]
public void Throws_ReturnsNull_WhenAssertionFailsInsideScope()
{
    using var scope = new AssertScope();
InvalidOperationException ex =
    Assert.Throws&lt;InvalidOperationException&gt;(() =&gt; { });

string msg = ex.Message; // NullReferenceException

}

  1. 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:

  1. The call to Assert.Throws<T>(), and

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs/triageNeeds triage by a maintainer.

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions