Skip to content

Improve assertions error messages with structured format#7444

Open
Evangelink wants to merge 28 commits intomainfrom
dev/amauryleve/rework-assert
Open

Improve assertions error messages with structured format#7444
Evangelink wants to merge 28 commits intomainfrom
dev/amauryleve/rework-assert

Conversation

@Evangelink
Copy link
Member

@Evangelink Evangelink commented Feb 20, 2026

Fix #7421

Copilot AI review requested due to automatic review settings February 20, 2026 21:33
Copy link
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

This pull request modernizes assertion error messages by introducing a structured, multi-line format that improves readability and developer experience. The changes replace old-style concatenated messages with formatted parameter displays using raw string literals.

Changes:

  • Introduced structured error message formatting with parameter names, expressions, and values on separate lines
  • Added helper methods for value formatting, expression truncation, and redundancy detection
  • Updated all assertion methods to use the new formatting approach
  • Removed obsolete resource strings and added new simplified ones
  • Updated all test expectations to match the new message format

Reviewed changes

Copilot reviewed 41 out of 41 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
Assert.cs Core formatting infrastructure: FormatValue, FormatParameter, truncation logic
Assert.ThrowsException.cs Updated to new structured format for exception type mismatches
Assert.StartsWith/EndsWith/Matches.cs String assertion methods using new format
Assert.IsTrue/IsFalse.cs Boolean assertion methods with condition parameter display
Assert.IsNull/IsNotNull.cs Null checking assertions with value display
Assert.IsInstanceOfType/IsExactInstanceOfType.cs Type checking with structured type comparison
Assert.IComparable.cs Comparison assertions (greater/less than, positive/negative)
Assert.Count/Contains.cs Collection assertions with expression parameters
Assert.AreSame.cs Reference equality with hash code display for disambiguation
FrameworkMessages.resx Resource string simplification and new message keys
xlf files Localization files marked with untranslated entries
Test files Updated expectations for all assertion error messages

@Evangelink Evangelink force-pushed the dev/amauryleve/rework-assert branch 2 times, most recently from a55c762 to 49018c6 Compare February 22, 2026 10:31
Copilot AI review requested due to automatic review settings February 22, 2026 10:31
Copy link
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

Copilot reviewed 44 out of 44 changed files in this pull request and generated 7 comments.

@Evangelink Evangelink force-pushed the dev/amauryleve/rework-assert branch from 49018c6 to 63e8257 Compare February 22, 2026 10:38
Copilot AI review requested due to automatic review settings February 22, 2026 12:56
Copy link
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

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

@nohwnd
Copy link
Member

nohwnd commented Feb 23, 2026

looks great

Copy link
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

Copilot reviewed 46 out of 46 changed files in this pull request and generated 4 comments.

string actualFormatted = FormatValue(actual);
bool sameToString = expected is not null && actual is not null && expectedFormatted == actualFormatted;
string expectedValue = sameToString
? expectedFormatted + $" (HashCode={RuntimeHelpers.GetHashCode(expected!)})"
Copy link
Member

Choose a reason for hiding this comment

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

This might be confusing to users as-is. Instead of HashCode=, maybe we should be explicit this is RuntimeHelpers.GetHashCode. Otherwise, users might think this is equivalent to x.GetHashCode() (the difference is only if the type overrides the default GetHashCode)

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe just Hash=XXX?

{
string finalMessage = string.Format(CultureInfo.CurrentCulture, FrameworkMessages.NullParameterToAssert, parameterName);
ThrowAssertFailed(assertionName, finalMessage);
foreach (object? item in collection)
Copy link
Member

Choose a reason for hiding this comment

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

Multiple enumerations are extremely risky IMO.
The original assert API that failed has likely already started enumerating the collection.

Copy link
Member

Choose a reason for hiding this comment

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

There is no guarantee that enumerating the given IEnumerable will be leading to the same original result as the first enumeration that happened earlier.

action.Should().Throw<Exception>().WithMessage("""
Assert.AreSame failed.
Expected references to be the same.
expected (new object()): <System.Object> (HashCode=*)
Copy link
Member

Choose a reason for hiding this comment

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

Should this look like the following instead?

expected 'new object()': <System.Object> (HashCode=*)

Copy link
Member

Choose a reason for hiding this comment

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

Yeah probably (even though then it looks like string...), personally I always trip over the expressions in the assertion messages lately, it is hard to know what is going on there when your code that produces the value is longer than few characters.

or when your expression is actual, more examples on the general approach would be nice that explore what will happen for common usecases.

Copy link
Member

Choose a reason for hiding this comment

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

Commented in few other places, where this seems problematic.

.WithMessage("""
Assert.AreEqual failed.
Expected values to be equal.
expected (new object()): <System.Object> (System.Object)
Copy link
Member

Choose a reason for hiding this comment

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

Looking through the code to see places where I would be confused.

This is imho very hard to interpret. The additional info about expressions and types should aid in understanding what is going on. And I would avoid putting anything before the actual values, instead I would prefer aligning the values on the same level, and add additional information after if needed, something like:

Assert.AreEqual failed.
Expected values to be equal.
    expected: System.Object [System.Object]
    actual:    1 [System.Int32]
 at Assert.AreEqual(new object(), 1).

This seems okay, and IMHO I have all the info needed. The values, the types, and what was called.

Copy link
Member

Choose a reason for hiding this comment

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

What does the < > do in the output? Is that telling me that this was unprintable value or something?

.WithMessage("""
Assert.AreNotEqual failed. User-provided message. DummyClassTrackingToStringCalls, DummyClassTrackingToStringCalls, Hello, DummyIFormattable.ToString()*
Expected a difference greater than <0.2>.
notExpected: 1.0m
Copy link
Member

Choose a reason for hiding this comment

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

This is existing code, but I am not 100% on board with this naming, makes me do additional mental math on what you meant here. The message does great job at telling me what is the expectation, and it already uses language appropriate for the Not*Assertion.

negative assertion:
Expected a difference greater than <0.2>.

positive assertion:
Expected a difference no greater than <0>.

Copy link
Member

Choose a reason for hiding this comment

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

the <> also seem extra, do they serve some purpose somewhere?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't get the first comment, what's the problem and what's your suggestion?

Copy link
Member

Choose a reason for hiding this comment

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

Expected a difference greater than <0.2>. notExpected: 1.0m actual: 1.0m
Expected a difference no greater than <0>. expected: 1.0m actual: 0.9m

1.0m is the value I provided, that drives the decision if the assertion will fail or not, but it says "notExpected/unexpected".

Copy link
Member

@nohwnd nohwnd left a comment

Choose a reason for hiding this comment

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

You might find it helpful to look at how pester does the formatting, this PR has lot of problems (this is subjective) that are solved there:
https://github.com/pester/Pester/blob/d5f7c87a5fd2409f77eb73bd891e7f15a3eb6a80/src/Format2.ps1#L4

-------------^
Assert.AreEqual failed.
String lengths are both 4 but differ at index 2.
expected ("aa\ta"): "aa?a"
Copy link
Member

Choose a reason for hiding this comment

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

One more example of the expression not really helping to explain what is going on

Assert.AreEqual failed.
String lengths are both 201 but differ at index 100.
expected: "...aaaaaaaaaaaaaaaaaabcccccccccccccccc..."
actual: "...aaaaaaaaaaaaaaaaaadcccccccccccccccc..."
Copy link
Member

Choose a reason for hiding this comment

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

values are aligned, this is how it should be everywhere.

action.Should().Throw<Exception>().WithMessage("""
Assert.AreSame failed.
Expected references to be the same.
expected (new object()): <System.Object> (HashCode=*)
Copy link
Member

Choose a reason for hiding this comment

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

Commented in few other places, where this seems problematic.

@nohwnd
Copy link
Member

nohwnd commented Mar 10, 2026

That said this is great effort 👍 I love more consistency for the assertion messages!

Copilot AI review requested due to automatic review settings March 11, 2026 15:15
Copy link
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

Copilot reviewed 46 out of 46 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (2)

src/TestFramework/TestFramework/Assertions/Assert.IsInstanceOfType.cs:343

  • When expectedType is null (one of the documented failure cases), the failure message currently only prints the value line and omits any indication that expectedType was null. This makes the assertion failure hard to diagnose. Consider either validating expectedType with CheckParameterNotNull (like other asserts do) or including an explicit "expectedType: (null)" / reason line in the structured output.
        string message = string.IsNullOrEmpty(userMessage) ? string.Empty : userMessage!;
        if (expectedType is not null && value is not null)
        {
            message += Environment.NewLine + FrameworkMessages.IsInstanceOfTypeFailNew;
        }

        message += Environment.NewLine + FormatParameter(nameof(value), valueExpression, value);
        if (expectedType is not null && value is not null)
        {
            message += Environment.NewLine + $"  expectedType: {expectedType}"
                     + Environment.NewLine + $"  actualType: {value.GetType()}";
        }

src/TestFramework/TestFramework/Assertions/Assert.IsExactInstanceOfType.cs:336

  • When expectedType is null, IsExactInstanceOfType fails but the structured message doesn't indicate that expectedType was null (it only prints the value line). Consider validating expectedType with CheckParameterNotNull or adding an explicit "expectedType: (null)" / reason line so callers can understand why the assert failed.
    private static void ThrowAssertIsExactInstanceOfTypeFailed(object? value, Type? expectedType, string? userMessage, string valueExpression)
    {
        string message = string.IsNullOrEmpty(userMessage) ? string.Empty : userMessage!;
        if (expectedType is not null && value is not null)
        {
            message += Environment.NewLine + FrameworkMessages.IsExactInstanceOfTypeFailNew;
        }

        message += Environment.NewLine + FormatParameter(nameof(value), valueExpression, value);
        if (expectedType is not null && value is not null)
        {
            message += Environment.NewLine + $"  expectedType: {expectedType}"
                     + Environment.NewLine + $"  actualType: {value.GetType()}";
        }

Comment on lines +413 to 420
if (wrongType is not null)
{
message += Environment.NewLine + FrameworkMessages.IsNotInstanceOfTypeFailNew;
}

message += Environment.NewLine + FormatParameter(nameof(value), valueExpression, value);
if (wrongType is not null)
{
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

When wrongType is null, IsNotInstanceOfType fails but the structured message doesn't mention that wrongType was null (it only prints the value line). Since wrongType being null is a key diagnostic, consider validating wrongType with CheckParameterNotNull or including an explicit "wrongType: (null)" / reason line in the output.

This issue also appears on line 332 of the same file.

Suggested change
if (wrongType is not null)
{
message += Environment.NewLine + FrameworkMessages.IsNotInstanceOfTypeFailNew;
}
message += Environment.NewLine + FormatParameter(nameof(value), valueExpression, value);
if (wrongType is not null)
{
message += Environment.NewLine + FrameworkMessages.IsNotInstanceOfTypeFailNew;
message += Environment.NewLine + FormatParameter(nameof(value), valueExpression, value);
if (wrongType is null)
{
message += Environment.NewLine + " wrongType: (null)";
}
else
{

Copilot uses AI. Check for mistakes.
value!.GetType().ToString());
message += Environment.NewLine + $" wrongType: {wrongType}"
+ Environment.NewLine + $" actualType: {value!.GetType()}";
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

When wrongType is null, IsNotExactInstanceOfType fails but the structured message omits any mention that wrongType was null. Since wrongType being null is a primary cause, consider validating wrongType with CheckParameterNotNull or including a "wrongType: (null)" / reason line in the message.

This issue also appears on line 323 of the same file.

Suggested change
}
}
else
{
message += Environment.NewLine + " wrongType: (null)"
+ Environment.NewLine + " reason: The parameter 'wrongType' must not be null.";
}

Copilot uses AI. Check for mistakes.
<trans-unit id="AreEqualFailNew">
<source>Expected values to be equal.</source>
<target state="new">Expected values to be equal.</target>
<note />
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

Per repo localization guidelines, *.xlf files shouldn't be edited manually. These XLF changes should be produced by updating the .resx and then building to regenerate the XLFs. Please revert any hand edits and regenerate the XLF file(s) from the build pipeline to avoid translation merge issues.

Suggested change
<note />
<note></note>

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +60
// the format produces a trailing space before the newline ("failed. \\r\\n").
// Remove it to avoid trailing whitespace on the first line.
if (message is not null && message.StartsWith(Environment.NewLine, StringComparison.Ordinal))
{
formattedMessage = $"{assertionName} failed.{message}";
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

ThrowAssertFailed special-cases messages that start with a newline by constructing the prefix with a hardcoded English string ("{assertionName} failed."). This bypasses the localized FrameworkMessages.AssertionFailed format and will regress non-English assertion prefixes whenever structured messages start with a newline. Consider keeping the localization by deriving the prefix from FrameworkMessages.AssertionFailed (e.g., formatting with an empty message and trimming trailing whitespace) rather than hardcoding "failed.".

Suggested change
// the format produces a trailing space before the newline ("failed. \\r\\n").
// Remove it to avoid trailing whitespace on the first line.
if (message is not null && message.StartsWith(Environment.NewLine, StringComparison.Ordinal))
{
formattedMessage = $"{assertionName} failed.{message}";
// the format produces a trailing space before the newline (for example, "failed. \\r\\n").
// Remove it to avoid trailing whitespace on the first line while preserving localization.
if (message is not null && message.StartsWith(Environment.NewLine, StringComparison.Ordinal))
{
string prefix = string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AssertionFailed, assertionName, string.Empty);
formattedMessage = prefix.TrimEnd() + message;

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +158
Type type = typeof(T);
if (type == typeof(object))
{
return userMessage;
// If the static type is object, use the runtime type
type = value.GetType();
}

string callerArgMessagePart = string.Format(CultureInfo.InvariantCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, parameterName1, callerArgExpression1, parameterName2, callerArgExpression2);
return string.IsNullOrEmpty(userMessage)
? callerArgMessagePart
: $"{callerArgMessagePart} {userMessage}";
if (type.IsPrimitive || value is decimal or DateTime or DateTimeOffset
or TimeSpan or Guid or Enum)
{
return EscapeNewlines(Truncate(value.ToString() ?? string.Empty, maxLength));
}

MethodInfo? toStringMethod = type.GetMethod(nameof(ToString), BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
if (toStringMethod is not null
&& toStringMethod.DeclaringType != typeof(object)
&& toStringMethod.DeclaringType != typeof(ValueType))
{
try
{
return EscapeNewlines(Truncate(value.ToString() ?? string.Empty, maxLength));
}
catch (Exception)
{
// Fall through to type name display if ToString throws
}
}

// No useful ToString - just return the type name
string typeName = type.FullName ?? type.Name;
return $"<{typeName}>";
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

FormatValue uses typeof(T) (and only switches to the runtime type when T==object). For values typed as an interface/base class where the runtime type overrides ToString(), this can incorrectly fall back to displaying the static type name (e.g., "") instead of the runtime ToString() output. Consider basing the ToString/Type checks on value.GetType() for non-null values (while still keeping the existing primitive/collection handling).

Copilot uses AI. Check for mistakes.
<trans-unit id="AreEqualFailNew">
<source>Expected values to be equal.</source>
<target state="new">Expected values to be equal.</target>
<note />
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

Per repo localization guidelines, *.xlf files shouldn't be edited manually. These XLF changes should be produced by updating the .resx and then building to regenerate the XLFs. Please revert any hand edits and regenerate the XLF file(s) from the build pipeline to avoid translation merge issues.

Suggested change
<note />
<note></note>

Copilot uses AI. Check for mistakes.
Assert.AreEqual("aaaa", "aaab") failed.
String lengths are both 4 but differ at index 3.
  expected: "aaaa"
  actual:   "aaab"
  --------------^
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.

Improve AreSame error messages in case of null values

4 participants