Skip to content
Open
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
8014b3c
Add assertion formatting helpers and localized resource strings
Evangelink Feb 22, 2026
b2127af
Improve AreEqual/AreNotEqual assertion error messages
Evangelink Feb 22, 2026
9ac4849
Improve AreSame/AreNotSame assertion error messages
Evangelink Feb 22, 2026
375f0b5
Improve IsTrue/IsFalse assertion error messages
Evangelink Feb 22, 2026
bd66356
Improve IsNull/IsNotNull assertion error messages
Evangelink Feb 22, 2026
7e7f07e
Improve IsInstanceOfType/IsNotInstanceOfType assertion error messages
Evangelink Feb 22, 2026
bebe2ef
Improve IsExactInstanceOfType/IsNotExactInstanceOfType assertion erro…
Evangelink Feb 22, 2026
d35c33b
Improve IComparable assertion error messages
Evangelink Feb 22, 2026
23758d2
Improve HasCount/IsEmpty/IsNotEmpty assertion error messages
Evangelink Feb 22, 2026
3705c74
Improve ThrowsException assertion error messages
Evangelink Feb 22, 2026
23cd627
Improve Contains/DoesNotContain/IsInRange assertion error messages
Evangelink Feb 22, 2026
dd88189
Improve StartsWith/DoesNotStartWith assertion error messages
Evangelink Feb 22, 2026
0c10f63
Improve EndsWith/DoesNotEndWith assertion error messages
Evangelink Feb 22, 2026
14a6ec3
Improve MatchesRegex/DoesNotMatchRegex assertion error messages
Evangelink Feb 22, 2026
63e8257
Minor test cleanup for Inconclusive assertion
Evangelink Feb 22, 2026
f559387
Address Copilot feedback
Evangelink Feb 22, 2026
dfe9891
Fix acceptance tests
Evangelink Feb 22, 2026
4422c82
Merge branch 'main' into dev/amauryleve/rework-assert
Evangelink Mar 11, 2026
f725f60
Fix diff caret position
Evangelink Mar 11, 2026
9c96c9b
Use X more chars instead of total length
Evangelink Mar 11, 2026
632c3c1
Use item instead of element
Evangelink Mar 11, 2026
fa263f2
Use more items
Evangelink Mar 11, 2026
2822182
Updates
Evangelink Mar 11, 2026
39b59eb
Simplify user message handling
Evangelink Mar 11, 2026
60d17a9
Update hash text
Evangelink Mar 11, 2026
44c6ca1
Improve
Evangelink Mar 11, 2026
2610e3a
Move expressions to first line
Evangelink Mar 12, 2026
a402e6d
Avoid using wildcard in expected message
Evangelink Mar 12, 2026
8eae1b2
Merge main into dev/amauryleve/rework-assert
Evangelink Mar 19, 2026
2028446
Remove redundant item count from fully-displayed collections
Evangelink Mar 19, 2026
52d43b7
Display null instead of (null) for null values in assertions
Evangelink Mar 19, 2026
6222540
Use consistent angle bracket format for type display in assertions
Evangelink Mar 19, 2026
6b1cf89
Remove angle brackets around delta values in equality assertion messages
Evangelink Mar 19, 2026
08fb47e
Fix trailing newlines in test files
Evangelink Mar 19, 2026
7f4294e
Materialize non-ICollection enumerables at assertion boundary to prev…
Evangelink Mar 19, 2026
26f5c02
WIP: In-progress assertion changes before refactor
Evangelink Mar 19, 2026
99b4922
Refactor assertion error messages with structured format
Evangelink Mar 19, 2026
edc1aa1
Add C# numeric type suffixes to FormatValue display
Evangelink Mar 19, 2026
6aad990
Improve assertion messages: drop parens, specific counts, collection …
Evangelink Mar 19, 2026
1332f35
Drop parentheses from null type display: (null) -> null
Evangelink Mar 19, 2026
c6efa3e
Improve InstanceOfType messages: embed type in sentence, combine valu…
Evangelink Mar 19, 2026
d8e4572
Add equality hint to AreSame and show hash in AreNotSame
Evangelink Mar 20, 2026
7065af5
Move user message before explanation, drop 'User message:' prefix
Evangelink Mar 20, 2026
25e7447
Replace wildcard patterns with precise expected messages in IsInRange…
Evangelink Mar 20, 2026
370e8ac
Unify assertion failure messages with structured format
Evangelink Mar 20, 2026
fa32b9a
Merge branch 'main' into dev/amauryleve/rework-assert
Evangelink Mar 20, 2026
a7c777a
Address code review findings
Evangelink Mar 20, 2026
e797b5d
Fix broken AreSame test methods with missing closing braces and metho…
Evangelink Mar 23, 2026
7972e96
Fix all Build.cmd errors
Evangelink Mar 23, 2026
2034d49
Fix integration tests for new assertion message format
Evangelink Mar 23, 2026
8da5534
Address unresolved Copilot review comments
Evangelink Mar 23, 2026
ae8e633
Address new Copilot review comments
Evangelink Mar 23, 2026
6e578b3
Fix 17 failing unit tests
Evangelink Mar 23, 2026
8a34f9e
Add RFC 011: Structured Assertion Failure Messages design document
Evangelink Mar 23, 2026
d17a284
Fix markdown lint issues in RFC 011
Evangelink Mar 23, 2026
938bc20
RFC 011: Add ToString handling and size limits documentation
Evangelink Mar 23, 2026
9eedbd7
Address 9 review comments
Evangelink Mar 23, 2026
4c6fe77
Fix test expectations for new Assert.Fail message format
Evangelink Mar 24, 2026
d81b16a
More rework
Evangelink Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions docs/RFCs/011-Structured-Assertion-Messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
# RFC 011 - Structured Assertion Failure Messages

- [x] Approved in principle
- [ ] Under discussion
- [ ] Implementation
- [ ] Shipped

## Summary

This document describes the unified format for assertion failure messages across `Assert`, `StringAssert`, `CollectionAssert`, and `Assert.That`. All assertion failures now follow a consistent structured layout that separates the call site, user message, framework explanation, and diagnostic values into distinct, predictable sections.

## Motivation

Before this change, assertion failure messages used inconsistent formats across the framework:

- **Mixed tone**: Some messages used passive voice ("String does not contain..."), others active ("Expected a non-null value"), others factual ("Wrong exception type was thrown"), and others imperative ("Do not pass value types...").
- **User message embedding**: User-provided messages were sometimes embedded inside the framework message via `string.Format` positional placeholders (`{0}`), making them hard to visually separate from the diagnostic information.
- **No structured parameters**: Values like `expected`, `actual`, and `delta` were inlined into prose sentences with inconsistent formatting (angle brackets `<>`, single quotes, or no decoration).
- **No call site**: The old format started with `Assert.AreEqual failed.` — telling the user *what* failed but not *what was passed*.
- **`Assert.That`**: Used a different layout entirely (`Assert.That(...) failed.` / `Message: ...` / `Details:` / ` x = 5`).

Check failure on line 20 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Spaces inside code span elements

docs/RFCs/011-Structured-Assertion-Messages.md:20:115 MD038/no-space-in-code Spaces inside code span elements [Context: "` x = 5`"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md038.md
- **`CollectionAssert`/`StringAssert`**: Used legacy `string.Format` with positional placeholders for both user messages and values, making the output hard to parse visually.

These inconsistencies made it harder for users to quickly scan failure output and understand what went wrong.

## Design Goals

1. **Consistent structure**: Every assertion failure follows the same layout regardless of which assert class or method is used.
2. **User message first**: When the user provides a custom message, it appears before the framework explanation — it is the most important context.
3. **Expressions over names**: The call site shows the syntactic expressions the user wrote (via `CallerArgumentExpression`), not just parameter names.
4. **Aligned parameters**: Diagnostic values are indented and column-aligned for easy scanning.
5. **Localizable**: All user-facing framework messages go through `FrameworkMessages.resx` for localization support.
6. **Actionable**: Messages describe what was expected, not just what happened.

## Message Format

Every assertion failure message follows this structure:

```

Check failure on line 38 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:38 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
<CallSite>
[<UserMessage>]
<FrameworkMessage>
[ <param1>: <value1>]
[ <param2>: <value2>]
```

### Line 1: Call Site

The first line identifies which assertion failed and what expressions were passed:

```

Check failure on line 50 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:50 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
Assert.AreEqual(expectedVar, actualVar)
Assert.IsTrue(result.IsValid)
Assert.That(x > 10)
CollectionAssert.AreEqual
StringAssert.Contains
```

For `Assert.*` methods, expressions are captured via `[CallerArgumentExpression]` and truncated at 50 characters. For `CollectionAssert` and `StringAssert` (legacy APIs without expression capture), the method name alone is shown.

#### Omitted Parameters

When overloads accept additional parameters not captured by `CallerArgumentExpression` (such as `delta`, `ignoreCase`, `culture`), the call site uses a trailing `...` to signal that the displayed signature is abbreviated:

```

Check failure on line 64 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:64 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
Assert.AreEqual(1.0m, 1.1m, ...) // delta overload
Assert.AreEqual(expected, actual, ...) // culture overload
```

This avoids mixing runtime values with source expressions in the call site.

#### Lambda Stripping

For `Assert.That`, the `() => ` lambda wrapper is stripped from the call site since it is syntactic noise:

Check failure on line 73 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Spaces inside code span elements

docs/RFCs/011-Structured-Assertion-Messages.md:73:24 MD038/no-space-in-code Spaces inside code span elements [Context: "`() => `"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md038.md

```

Check failure on line 75 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:75 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
// Source code: Assert.That(() => x > 10)
// Call site: Assert.That(x > 10)
```

### Line 2 (Optional): User Message

If the user provided a custom message, it appears on its own line immediately after the call site, without any prefix:

```

Check failure on line 84 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:84 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
Assert.AreEqual(result, 42)
The calculation returned an unexpected value
Expected values to be equal.
expected: 42
actual: 37
```

This was a deliberate choice. Earlier iterations prefixed user messages with `Message:` or embedded them inline with the framework message. Both approaches made the user's intent harder to spot in multi-line output. Placing the user message on its own line — before the framework explanation — gives it the highest visual priority.

### Line 3: Framework Message

The framework's explanation of what was expected. All messages follow the tone **"Expected [subject] to [verb phrase]."**:

```

Check failure on line 98 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:98 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
Expected values to be equal.
Expected string to start with the specified prefix.
Expected collection to contain the specified item.
Expected the specified exception type to be thrown.
Expected condition to be true.
```

This tone was chosen after evaluating several alternatives:

| Style | Example | Verdict |
|-------|---------|---------|
| Passive: "String does not match..." | `String does not contain the expected substring.` | Rejected — describes outcome, not expectation |
| Factual: "Wrong exception type was thrown." | `No exception was thrown.` | Rejected — not actionable |
| Active nominal: "Expected a non-null value." | `Expected a positive value.` | Rejected — inconsistent structure with parameterized variants |
| **Active verbal: "Expected [X] to [Y]."** | `Expected value to be null.` | **Chosen** — consistent, actionable, parameterizable |

The verbal form scales naturally to parameterized messages like `Expected value {0} to be greater than {1}.` and negative forms like `Expected value to not be null.`

### Lines 4+: Aligned Parameters

Diagnostic values are shown as indented, colon-separated, column-aligned pairs:

```

Check failure on line 121 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:121 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
expected: 42
actual: 37
```

The alignment padding ensures all values start at the same column, making it easy to compare expected vs actual at a glance. When labels have different lengths, the shorter ones are padded:

```

Check failure on line 128 in docs/RFCs/011-Structured-Assertion-Messages.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified

docs/RFCs/011-Structured-Assertion-Messages.md:128 MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"] https://github.com/DavidAnson/markdownlint/blob/v0.36.1/doc/md040.md
expected prefix: "Hello"
value: "World"
```

Additional contextual parameters like `delta`, `ignore case`, and `culture` appear when relevant:

```
expected: "i"
actual: "I"
ignore case: False
culture: en-EN
```

Collection previews are shown inline with truncation:

```
collection: [1, 2, 3, ... 97 more]
```

## `Assert.That` Expression-Aware Messages

`Assert.That` accepts an `Expression<Func<bool>>` and uses the expression tree to generate context-specific failure messages instead of a generic "Expected condition to be true."

| Expression Type | Example | Message |
|----------------|---------|---------|
| `==` | `x == 5` | `Expected 3 to equal 5.` |
| `!=` | `s != "test"` | `Expected "test" to not equal "test".` |
| `>` | `x > 10` | `Expected 5 to be greater than 10.` |
| `>=` | `x >= 10` | `Expected 5 to be greater than or equal to 10.` |
| `<` | `year < 2000` | `Expected 2026 to be less than 2000.` |
| `<=` | `x <= 3` | `Expected 5 to be less than or equal to 3.` |
| `!flag` | `!flag` | `Expected flag to be false.` |
| Bool member | `user.IsActive` | `Expected user.IsActive to be true.` |
| `StartsWith` | `text.StartsWith(...)` | `Expected string to start with the specified prefix.` |
| `Contains` (string) | `text.Contains(...)` | `Expected string to contain the specified substring.` |
| `Contains` (collection) | `list.Contains(...)` | `Expected collection to contain the specified item.` |
| `All` | `nums.All(...)` | `Expected all elements to match the predicate.` |
| `Any` | `coll.Any(...)` | `Expected at least one item to match the predicate.` |
| `&&` / `\|\|` / fallback | compound | `Expected condition to be true.` |

For binary comparisons, both sides of the expression are evaluated at runtime and their values are displayed. For known methods (`StartsWith`, `Contains`, `All`, `Any`), the corresponding framework message is reused. String-specific methods are type-checked to avoid false matches on types that happen to have methods with the same name. Compound expressions (`&&`, `||`) fall back to the generic message since the specific failing sub-expression cannot be determined.

Variable details are extracted from the expression tree and displayed below the message:

```
Assert.That(x > 10)
Expected 5 to be greater than 10.
x = 5
```

## `CollectionAssert` and `StringAssert`

These legacy APIs follow the same structural pattern but without `CallerArgumentExpression` (since they predate it):

```
CollectionAssert.AreEqual
User-provided message
Element at index 1 do not match.
expected: 2
actual: 5
```

```
StringAssert.Contains
Expected string to contain the specified substring.
substring: "xyz"
value: "The quick brown fox..."
```

User messages are positioned using `AppendUserMessage` (before the framework message), and parameter values use `FormatAlignedParameters` for consistent alignment.

## Value Formatting

All values are formatted through a unified `FormatValue<T>` method that applies consistent rules:

| Type | Format | Example |
|------|--------|---------|
| `null` | `null` | `null` |
| `string` | Quoted, escaped, truncated at 256 chars | `"hello\r\nworld"` |
| `int` | Plain | `42` |
| `long` | With suffix | `42L` |
| `float` | With suffix | `1.5f` |
| `decimal` | With suffix | `0.1m` |
| `double` | Plain | `3.14` |
| Collections | Inline preview with truncation | `[1, 2, 3, ... 97 more]` |
| Types (no useful ToString) | Angle-bracketed full name | `<System.Object>` |
| Other (custom ToString) | Escaped, truncated | `MyCustomType{Id=5}` |

Numeric primitives are formatted using `CultureInfo.InvariantCulture` to ensure consistent output across locales. Collections are safe-enumerated with budget-based truncation to avoid hanging on infinite sequences.

## Implementation Details

### `StringPair` struct

To support `net462` (which lacks `System.ValueTuple`), the aligned parameter and call site methods use a simple `StringPair` struct instead of tuple syntax:

```csharp
internal readonly struct StringPair
{
public StringPair(string name, string value) { Name = name; Value = value; }
public string Name { get; }
public string Value { get; }
}
```

### Localization

All user-facing message strings are defined in `FrameworkMessages.resx` and generated via the standard xlf pipeline. This includes the `Assert.That` expression-aware messages, which use `string.Format` placeholders (`{0}`, `{1}`) for runtime values.

### Collection Safety

Collection parameters are materialized at the assertion boundary (via `as ICollection<T> ?? [.. collection]`) to prevent multiple enumeration. The `FormatCollectionPreview` method uses budget-based truncation and catches enumeration exceptions gracefully, falling back to a `...` suffix rather than failing the assertion formatting.

## Examples

### `Assert.AreEqual` (generic)

```
Assert.AreEqual(expected, actual)
Expected values to be equal.
expected: 42
actual: 37
```

### `Assert.AreEqual` (delta overload)

```
Assert.AreEqual(1.0m, 1.1m, ...)
Expected difference to be no greater than 0.001.
expected: 1.0m
actual: 1.1m
delta: 0.001m
```

### `Assert.AreEqual` (string with culture)

```
Assert.AreEqual(expected, actual, ...)
Case differs.
expected: "i"
actual: "I"
ignore case: False
culture: en-EN
```

### `Assert.IsNull`

```
Assert.IsNull(result)
Expected value to be null.
value: 42
```

### `Assert.Throws`

```
Assert.Throws(action)
Expected the specified exception type to be thrown.
action: () => service.Process()
expected exception type: <System.InvalidOperationException>
actual exception type: <System.ArgumentException>
```

### `Assert.That` (comparison)

```
Assert.That(x > 10)
x should be greater than 10
Expected 5 to be greater than 10.
x = 5
```

### `CollectionAssert.AreEqual`

```
CollectionAssert.AreEqual
Element at index 1 do not match.
expected: 2
actual: 5
```

### `StringAssert.StartsWith`

```
StringAssert.StartsWith
Expected string to start with the specified prefix.
expected prefix: "Hello"
value: "World says goodbye"
```
Loading
Loading