Skip to content

Structured assertion messages for exception assertions#8210

Merged
Evangelink merged 9 commits into
mainfrom
dev/amauryleve/structured-messages-exceptions
May 14, 2026
Merged

Structured assertion messages for exception assertions#8210
Evangelink merged 9 commits into
mainfrom
dev/amauryleve/structured-messages-exceptions

Conversation

@Evangelink
Copy link
Copy Markdown
Member

Summary

Extends the structured-message infrastructure introduced in RFC 012 to the exception assertions:

  • Assert.Throws<T> / Assert.ThrowsExactly<T>
  • Assert.ThrowsAsync<T> / Assert.ThrowsExactlyAsync<T>
  • The corresponding InterpolatedStringHandler overloads (AssertNonStrictThrowsInterpolatedStringHandler<TException>, AssertThrowsExactlyInterpolatedStringHandler<TException>)

The failure path is consolidated in a new private helper ReportThrowsFailed<TException> that builds a StructuredAssertionMessage with:

  • An English summary line (e.g. Expected exception of type ArgumentException (or derived) but caught InvalidOperationException.).
  • An EvidenceBlock with expected type:, actual type:, and actual exception: rows.
  • The user message (when supplied).
  • A pasteable call-site line such as Assert.Throws<ArgumentException>(action) (omitted when the captured [CallerArgumentExpression] action expression contains a line break).

Notable details

  • Generic exception types render as MyException<Int32> (and MyNs.MyException<System.Int32> in the evidence block) instead of the CLR MyException``1 form, via a small recursive helper GetDisplayTypeName. Limits are documented in code: nested generic exception types fall back to a less-precise rendering (still strictly better than the pre-PR Type.FullName output) — a follow-up issue can extend the helper to walk DeclaringType if a real user reports the case.
  • Multi-line values in EvidenceBlock (e.g. multi-line exception messages) are now indented to the value column on continuation lines, with a fix to suppress the trailing indent when the value ends with a newline.
  • AssertScope mode is preserved: ReportThrowsFailed records the failure into the active scope and returns instead of throwing; the two ComputeAssertion methods on the interpolated handlers fall through to a return null! so the caller doesn't see an InvalidCastException.
  • Public API surface is unchanged — no entries added to PublicAPI.Unshipped.txt.

Tests

  • New scenario tests in AssertTests.ThrowsExceptionTests covering: scope-mode wrong-type interpolated overloads (regression for the InvalidCastException issue), multi-line exception message indentation, multi-line action-expression call-site omission, generic exception type rendering.
  • New focused tests in EvidenceBlockTests covering LF / CRLF / CR-only / multiple newlines / value ending with a newline / mixed single-and-multi-line values.

Review process

Five rounds of expert review applied. Each round's findings were addressed in a separate commit with the round number in the message. Two findings were declined with documented rationale:

  1. Replacing the new English summary/labels with FrameworkMessages.* localized strings — declined per RFC 012 (English summary/labels are intentional for grep-friendly searchability).
  2. Per-call char[] allocation from the ['\n', '\r'] collection literal in FormatCallSiteExpression — declined as cosmetic (failure path).

Build verification

The local build environment is currently broken (the .NET 11 preview SDK pinned in global.json requires MSBuild 18.0+, but the only "complete" Visual Studio install available is 2022 17.14 which ships MSBuild 17.14). All file-level edits passed compile-error checks via the language service. Relying on CI to validate the full build and test run.

Introduce the foundational types and helpers for structured multi-line
assertion failure messages as described in RFC 012:

- EvidenceLine: labeled line record struct for evidence blocks
- EvidenceBlock: collection of labeled lines with automatic alignment
- StructuredAssertionMessage: builder producing the new multi-line format
  (prefix + summary + user message + evidence block + call-site)
- AssertionValueRenderer: renders values per RFC 012 rules (null, quoted
  strings with escape sequences, booleans, collections as JSON arrays)
- AssertFailedException: add ExpectedText/ActualText public properties
- Assert: add ReportAssertFailed/ThrowAssertFailed overloads accepting
  StructuredAssertionMessage

No existing assertion methods are changed yet - this PR only introduces
the infrastructure that subsequent PRs will use to migrate each
assertion method to the new format.
Apply RFC 012 structured assertion message format to:
- Throws / ThrowsExactly (sync)
- ThrowsAsync / ThrowsExactlyAsync (async)
- All interpolated string handler overloads
- All Func<Exception?, string> messageBuilder overloads

Each method now produces structured output with summary, evidence block
(for wrong-type failures), user message, and call-site expression.

Refactor ThrowsExceptionState to use ThrowsFailureKind enum instead of
Action<string> lambdas. Extract ReportThrowsFailed helper method.
Remove unused BuildUserMessageForActionExpression.

Update all corresponding test expectations to match the new format.
# Conflicts:
#	src/TestFramework/TestFramework/Assertions/Assert.cs
#	src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs
#	src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs
#	src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs
#	test/UnitTests/TestFramework.UnitTests/Assertions/AssertFailedExceptionTests.cs
#	test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs
#	test/UnitTests/TestFramework.UnitTests/Assertions/StructuredAssertionMessageTests.cs
…-ending newline + EvidenceBlock multi-line tests
… calls

Note: the localization-regression finding was reviewed but not addressed:
the English-only summary text and evidence labels in the structured
exception failure messages are intentional per RFC 012 (search-friendly,
universal grep prefix) and match the documented spec literally. This
matches the design pattern set by the structured-message infrastructure
in main.
…mment cleanup

- Replace stale '// This will not hit, but need it for compiler' comments in 4
  sibling helpers (sync/async, message/messageBuilder overloads) with a comment
  that matches the iter-1 fix - the line IS reached in scope mode and intentionally
  returns null. Prevents future contributors from re-introducing the iter-1
  InvalidCastException regression by assuming [DoesNotReturn].

- Render generic exception types with a friendly name in the summary and call-site
  lines: 'MyException`1' becomes 'MyException<Int32>'. Without this the summary
  prose breaks visually and the call-site line is non-pasteable C#. Helper is
  shared for both short (summary, call-site) and full (evidence-block) names.

- Add regression test Throws_WhenExpectedTypeIsGeneric_RendersFriendlyTypeName.

Not addressed (NIT, intentional):
- '['\n', '\r']' collection-literal allocation in FormatCallSiteExpression sits on
  the failure path - cosmetic only.
Copilot AI review requested due to automatic review settings May 14, 2026 09:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the RFC 012 structured assertion message infrastructure to Assert.Throws* / Assert.Throws*Async (including interpolated-string-handler overloads), and updates evidence formatting to better handle multi-line values.

Changes:

  • Introduces a consolidated failure path for Throws* assertions that emits StructuredAssertionMessage with summary + evidence + optional call-site line.
  • Updates EvidenceBlock.Format() to align continuation lines for multi-line values.
  • Adds/updates unit tests validating the new formatted messages, including scope-mode regression coverage.
Show a summary per file
File Description
test/UnitTests/TestFramework.UnitTests/Assertions/EvidenceBlockTests.cs Adds tests for multi-line value indentation behavior in EvidenceBlock.Format().
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs Updates expected failure messages to the new structured format and adds new scenario tests.
src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs Implements continuation-line indentation when formatting evidence values that contain newlines.
src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs Reworks Throws* failure reporting to build structured assertion messages and preserves AssertScope behavior for interpolated handlers.
src/TestFramework/TestFramework/Assertions/Assert.cs Adds helper to format a pasteable call-site expression when the captured expression is single-line.

Copilot's findings

Comments suppressed due to low confidence (3)

test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs:214

  • This test hard-codes the BCL ArgumentOutOfRangeException.Message text (including the “(Parameter '...')” suffix). The project multi-targets net48/net8/net9 and this message format differs across TFMs (and can vary by culture), so the assertion is likely to be flaky. Prefer a deterministic exception/message in the test, relax the match (wildcards), or build the expected message from an exception instance for the current TFM.
                $"expected type:    System.ArgumentNullException{Environment.NewLine}" +
                $"actual type:      System.ArgumentOutOfRangeException{Environment.NewLine}" +
                $"actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere')" +
                $"{Environment.NewLine}{Environment.NewLine}" +
                "Assert.ThrowsExactly<ArgumentNullException>(() => throw new ArgumentOutOfRangeException(\"MyParamNameHere\"))");

test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs:275

  • This test hard-codes the BCL ArgumentOutOfRangeException.Message text (including the “(Parameter '...')” suffix). The project multi-targets net48/net8/net9 and this message format differs across TFMs (and can vary by culture), so the assertion is likely to be flaky. Prefer a deterministic exception/message in the test, relax the match (wildcards), or build the expected message from an exception instance for the current TFM.
                $"expected type:    System.ArgumentNullException (or derived){Environment.NewLine}" +
                $"actual type:      System.ArgumentOutOfRangeException{Environment.NewLine}" +
                $"actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere')" +
                $"{Environment.NewLine}{Environment.NewLine}" +
                "Assert.ThrowsAsync<ArgumentNullException>(() => Task.FromException(new ArgumentOutOfRangeException(\"MyParamNameHere\")))");

test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.ThrowsExceptionTests.cs:336

  • This test hard-codes the BCL ArgumentOutOfRangeException.Message text (including the “(Parameter '...')” suffix). The project multi-targets net48/net8/net9 and this message format differs across TFMs (and can vary by culture), so the assertion is likely to be flaky. Prefer a deterministic exception/message in the test, relax the match (wildcards), or build the expected message from an exception instance for the current TFM.
                $"expected type:    System.ArgumentNullException{Environment.NewLine}" +
                $"actual type:      System.ArgumentOutOfRangeException{Environment.NewLine}" +
                $"actual exception: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'MyParamNameHere')" +
                $"{Environment.NewLine}{Environment.NewLine}" +
                "Assert.ThrowsExactlyAsync<ArgumentNullException>(() => Task.FromException(new ArgumentOutOfRangeException(\"MyParamNameHere\")))");
  • Files reviewed: 5/5 changed files
  • Comments generated: 2

Comment thread src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs Outdated
Copy link
Copy Markdown
Member Author

@Evangelink Evangelink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The PR has evolved significantly from the diff shown in the description — the actual branch HEAD is a well-structured, production-quality implementation. The core infrastructure (fluent StructuredAssertionMessage builder, EvidenceBlock.AppendValue character-scan instead of Split+TrimEnd, [StackTraceHidden] on ReportThrowsFailed, [CallerMemberName] to capture the public API method name) is clean and correct.

Dimension-by-dimension assessment

# Dimension Verdict
1 Algorithmic Correctness ISSUEInnerException chain dropped; NoExceptionThrown path has no evidence block
2 Threading & Concurrency LGTM — ConfigureAwait(false) present; no shared mutable state
3 Security / IPC LGTM
4 Public API / Binary Compat LGTM — no new public surface; PublicAPI.Unshipped.txt unchanged; ThrowsExceptionState is private readonly struct
5 Performance LGTM — failure path only; GetDisplayTypeName avoids LINQ in the generic case via StringBuilder loop
6 Cross-TFM LGTM — no Regex in final code; collection literals compile to arrays on all targets
7 Resource / IDisposable LGTM
8 Defensive Coding LGTM — action ?? throw, messageBuilder ?? throw guards in place
9 Localization LGTM — RFC 012 deliberate English strings (previously declined finding)
10 Test Isolation LGTM — tests are stateless
11 Assertion Quality LGTM — TestFramework.ForTestingMSTest + AwesomeAssertions used correctly; no banned Assert.* calls in new test code
12 Flakiness LGTM
13 Test Completeness ISSUE — no plain (non-messageBuilder) exact-format test for the NoExceptionThrown path
14 Data-Driven Coverage LGTM — LF/CRLF/CR/mixed covered in EvidenceBlockTests
15 Code Structure LGTM — factory methods on ThrowsExceptionState, guard clauses, switch expressions used well
16 Naming & Conventions NIT — one ThrowsExactly(Func<object?>, Func<...>) overload has extra indentation on =>
17 Documentation LGTM — XML doc complete; [StackTraceHidden] on ReportThrowsFailed correctly documented
18–19 Analyzer / IPC N/A
20 Build Infrastructure LGTM
21 Scope Discipline LGTM — tightly scoped to exception assertions

Inline findings

Severity File Finding
MODERATE Assert.ThrowsException.cs (evidence AddLine) actual exception: uses {type}: {message} — silently drops InnerException chain; ex.ToString() would be richer and the multi-line indentation now handles it
MODERATE Assert.ThrowsException.cs (NoExceptionThrown branch) No evidence block emitted when action doesn't throw; expected type: absent from structured data; missing exact-format test for plain overload
NIT Assert.ThrowsException.cs (ThrowsExactly overload) Extra indentation level on expression body

No blocking issues found. The implementation is correct, the test coverage is thorough, and the InnerException concern is a design trade-off rather than a bug.

Generated by Expert Code Review (on open) for issue #8210 · ● 16.6M

…s + exact comparisons

- EvidenceBlock.AppendValue: skip continuation indent when next char is
  another newline so consecutive line breaks don't produce whitespace-only
  lines (addresses copilot review on EvidenceBlock.cs:85). Adds regression
  test Format_ValueWithConsecutiveNewlines_DoesNotEmitWhitespaceOnlyLines.

- Convert all PR-touched test expected messages from Environment.NewLine
  string concatenation to C# raw string literals so the expected output
  is laid out visually as it will appear on screen, easier to review and
  diff (per maintainer request).

- Replace AwesomeAssertions WithMessage(...) wildcard match with
  .Which.Message.Should().Be(...) for exact comparison so we don't miss
  out on differences (per maintainer request).

- Cross-TFM ArgumentOutOfRangeException.Message handling for the four
  WithMessageBuilder_FailsBecauseTypeMismatch tests using BeOneOf(...)
  with both forms (.NET Core/5+ '(Parameter ''x'')' and .NET Framework
  'Parameter name: x' on its own line) - addresses copilot review on
  AssertTests.ThrowsExceptionTests.cs:153 (and 210, 271, 332).

Build: 0 warnings, 0 errors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants