Structured assertion messages for exception assertions#8210
Conversation
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
…e value alignment, naming
…-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.
There was a problem hiding this comment.
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 emitsStructuredAssertionMessagewith 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
Evangelink
left a comment
There was a problem hiding this comment.
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 | ISSUE — InnerException 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.
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>InterpolatedStringHandleroverloads (AssertNonStrictThrowsInterpolatedStringHandler<TException>,AssertThrowsExactlyInterpolatedStringHandler<TException>)The failure path is consolidated in a new private helper
ReportThrowsFailed<TException>that builds aStructuredAssertionMessagewith:Expected exception of type ArgumentException (or derived) but caught InvalidOperationException.).EvidenceBlockwithexpected type:,actual type:, andactual exception:rows.Assert.Throws<ArgumentException>(action)(omitted when the captured[CallerArgumentExpression]action expression contains a line break).Notable details
MyException<Int32>(andMyNs.MyException<System.Int32>in the evidence block) instead of the CLRMyException``1form, via a small recursive helperGetDisplayTypeName. Limits are documented in code: nested generic exception types fall back to a less-precise rendering (still strictly better than the pre-PRType.FullNameoutput) — a follow-up issue can extend the helper to walkDeclaringTypeif a real user reports the case.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.AssertScopemode is preserved:ReportThrowsFailedrecords the failure into the active scope and returns instead of throwing; the twoComputeAssertionmethods on the interpolated handlers fall through to areturn null!so the caller doesn't see anInvalidCastException.PublicAPI.Unshipped.txt.Tests
AssertTests.ThrowsExceptionTestscovering: scope-mode wrong-type interpolated overloads (regression for theInvalidCastExceptionissue), multi-line exception message indentation, multi-line action-expression call-site omission, generic exception type rendering.EvidenceBlockTestscovering 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:
FrameworkMessages.*localized strings — declined per RFC 012 (English summary/labels are intentional for grep-friendly searchability).char[]allocation from the['\n', '\r']collection literal inFormatCallSiteExpression— declined as cosmetic (failure path).Build verification
The local build environment is currently broken (the .NET 11 preview SDK pinned in
global.jsonrequires 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.