Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
119afd5
Add structured assertion message infrastructure (RFC 012)
Evangelink May 12, 2026
3bbc0e9
Apply structured assertion messages to IsTrue, IsFalse, IsNull, IsNot…
Evangelink May 12, 2026
1307208
feat: structured assertion messages for equality assertions (AreEqual…
Evangelink May 12, 2026
e587810
Merge remote-tracking branch 'origin/main' into dev/amauryleve/struct…
Evangelink May 14, 2026
e90c90e
test: convert structured-message assertions to raw strings + full equ…
Evangelink May 14, 2026
e03ae4d
fix: restore localized resx summaries and revert unrelated merge changes
Evangelink May 14, 2026
d9973f0
fix: address reviewer feedback - drop misleading case-sensitive claim…
Evangelink May 14, 2026
bd9d334
Merge remote-tracking branch 'origin/main' into dev/amauryleve/struct…
Evangelink May 14, 2026
62c39f5
Address expert reviewer feedback (H1, M2, L3, N1)
Evangelink May 14, 2026
1b7bc4a
Address expert reviewer iteration 2 (N1 IsNullOrEmpty, N2 nullable Ty…
Evangelink May 14, 2026
ae0742c
Address expert reviewer iteration 3 (L1 rename test, L2 add AreNotEqu…
Evangelink May 14, 2026
46a0016
Fix SA1311/IDE1006: rename s_lineBreakChars to LineBreakChars
Evangelink May 14, 2026
af81537
Address PR review: use IsNullOrWhiteSpace in FormatBinaryCallSiteExpr…
Evangelink May 14, 2026
0cd4ef2
Merge origin/main and resolve ScopeTests conflicts
Copilot May 14, 2026
842f318
Merge branch 'main' into dev/amauryleve/structured-messages-equality
Evangelink May 14, 2026
6ead0eb
Address PR review: drop `case-sensitive'' from RFC string section hea…
Nargiza1986 May 15, 2026
ee56476
Merge origin/main and resolve TestFramework conflicts
Copilot May 16, 2026
3b1c88f
Restore AreEqual summary xlf entries after merge conflict resolution
Copilot May 16, 2026
1950cf2
Merge origin/main and resolve conflicts
Copilot May 16, 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
12 changes: 6 additions & 6 deletions docs/RFCs/012-Structured-Assertion-Messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Examples:
| Assertion | Line 1 |
| --------- | ------ |
| `AreEqual` (int) | `Assertion failed. Expected values to be equal.` |
| `AreEqual` (string) | `Assertion failed. Expected strings to be equal (case-sensitive).` |
| `AreEqual` (string) | `Assertion failed. Expected strings to be equal.` |
| `IsTrue` | `Assertion failed. Expected condition to be true.` |
| `IsNull` | `Assertion failed. Expected value to be null.` |
| `IsInstanceOfType` | `Assertion failed. Expected value to be of type String (or derived).` |
Expand Down Expand Up @@ -270,7 +270,7 @@ Assert.AreEqual(expectedCount, actualCount)
### Assert.AreEqual (strings, with user message)

```text
Assertion failed. Expected strings to be equal (case-sensitive).
Assertion failed. Expected strings to be equal.
Strings have same length (11) but differ at 1 location(s). First difference at index 7.
The greeting should include the user's full name

Expand Down Expand Up @@ -317,7 +317,7 @@ Assert.ThrowsExactly<ArgumentException>(() => Validate(input))
### Assert.AreEqual (large strings)

```text
Assertion failed. Expected strings to be equal (case-sensitive).
Assertion failed. Expected strings to be equal.
Strings have different lengths (expected: 50000, actual: 49997) and differ at 1 location(s). First difference at index 1042.

expected:
Expand Down Expand Up @@ -382,7 +382,7 @@ Assert.AreEqual(
Output — the multiline expression is replaced with `<expected>` in the call-site:

```text
Assertion failed. Expected strings to be equal (case-sensitive).
Assertion failed. Expected strings to be equal.
Strings differ at 1 location(s). First difference at index 22.

expected: "{\n \"name\": \"Alice\",\n \"age\": 30\n}"
Expand Down Expand Up @@ -429,7 +429,7 @@ expected: 42
actual: 37
```

Note: When the generic `AreEqual<T>` overload is called with `T = string` (without `ignoreCase`/`culture` parameters), the message **auto-detects the string type** and uses the string-specific format (`"Expected strings to be equal (case-sensitive)."`) with full string diff diagnostics. The generic overload defaults to case-sensitive ordinal comparison, which is exactly what the string-specific format conveys. Developers writing `Assert.AreEqual("expected", actual)` get string diagnostics without needing to know about the string-specific overload.
Note: When the generic `AreEqual<T>` overload is called with `T = string` (without `ignoreCase`/`culture` parameters), the message **auto-detects the string type** and uses the string-specific format (`"Expected strings to be equal."`) with full string diff diagnostics. The generic overload defaults to case-sensitive ordinal comparison, which is exactly what the string-specific format conveys. Developers writing `Assert.AreEqual("expected", actual)` get string diagnostics without needing to know about the string-specific overload.
Comment thread
Evangelink marked this conversation as resolved.
Outdated

#### Assert.AreEqual (with delta)

Expand All @@ -446,7 +446,7 @@ Note: The `delta` overload exists for `float`, `double`, `decimal`, and `long`.
#### Assert.AreEqual (string, case-sensitive)
Comment thread
Evangelink marked this conversation as resolved.
Outdated

```text
Assertion failed. Expected strings to be equal (case-sensitive).
Assertion failed. Expected strings to be equal.
Strings have same length (11) but differ at 1 location(s). First difference at index 7.
Comment thread
Evangelink marked this conversation as resolved.

expected: "hello world"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio
{
if (_builder is not null)
{
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "expected", expectedExpression, "actual", actualExpression) + " ");
ReportAssertAreEqualFailed(_expected, _actual, _builder.ToString());
ReportAssertAreEqualFailed(_expected, _actual, _builder.ToString(), expectedExpression, actualExpression);
}
}

Expand Down Expand Up @@ -115,8 +114,7 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres
{
if (_builder is not null)
{
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "notExpected", notExpectedExpression, "actual", actualExpression) + " ");
ReportAssertAreNotEqualFailed(_notExpected, _actual, _builder.ToString());
ReportAssertAreNotEqualFailed(_notExpected, _actual, _builder.ToString(), notExpectedExpression, actualExpression);
}
}

Expand Down Expand Up @@ -265,15 +263,16 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio
public readonly struct AssertNonGenericAreNotEqualInterpolatedStringHandler
{
private readonly StringBuilder? _builder;
private readonly Action<string>? _failAction;
private readonly Action<string, string, string>? _failAction;

public AssertNonGenericAreNotEqualInterpolatedStringHandler(int literalLength, int formattedCount, float notExpected, float actual, float delta, out bool shouldAppend)
{
shouldAppend = AreNotEqualFailing(notExpected, actual, delta);
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
_failAction = userMessage => ReportAssertAreNotEqualFailed(notExpected, actual, delta, userMessage);
_failAction = (userMessage, notExpectedExpr, actualExpr) =>
ReportAssertAreNotEqualFailed(notExpected, actual, delta, BuildUserMessageForNotExpectedExpressionAndActualExpression(userMessage, notExpectedExpr, actualExpr));
}
}

Expand All @@ -283,7 +282,8 @@ public AssertNonGenericAreNotEqualInterpolatedStringHandler(int literalLength, i
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
_failAction = userMessage => ReportAssertAreNotEqualFailed(notExpected, actual, delta, userMessage);
_failAction = (userMessage, notExpectedExpr, actualExpr) =>
ReportAssertAreNotEqualFailed(notExpected, actual, delta, BuildUserMessageForNotExpectedExpressionAndActualExpression(userMessage, notExpectedExpr, actualExpr));
}
}

Expand All @@ -293,7 +293,8 @@ public AssertNonGenericAreNotEqualInterpolatedStringHandler(int literalLength, i
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
_failAction = userMessage => ReportAssertAreNotEqualFailed(notExpected, actual, delta, userMessage);
_failAction = (userMessage, notExpectedExpr, actualExpr) =>
ReportAssertAreNotEqualFailed(notExpected, actual, delta, BuildUserMessageForNotExpectedExpressionAndActualExpression(userMessage, notExpectedExpr, actualExpr));
}
}

Expand All @@ -303,7 +304,8 @@ public AssertNonGenericAreNotEqualInterpolatedStringHandler(int literalLength, i
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
_failAction = userMessage => ReportAssertAreNotEqualFailed(notExpected, actual, delta, userMessage);
_failAction = (userMessage, notExpectedExpr, actualExpr) =>
ReportAssertAreNotEqualFailed(notExpected, actual, delta, BuildUserMessageForNotExpectedExpressionAndActualExpression(userMessage, notExpectedExpr, actualExpr));
}
}

Expand All @@ -319,18 +321,12 @@ public AssertNonGenericAreNotEqualInterpolatedStringHandler(int literalLength, i
if (shouldAppend)
{
_builder = new StringBuilder(literalLength + formattedCount);
_failAction = userMessage => ReportAssertAreNotEqualFailed(notExpected, actual, userMessage);
_failAction = (userMessage, notExpectedExpr, actualExpr) => ReportAssertAreNotEqualFailed(notExpected, actual, userMessage, notExpectedExpr, actualExpr);
}
}

internal void ComputeAssertion(string notExpectedExpression, string actualExpression)
{
if (_failAction is not null)
{
_builder!.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "notExpected", notExpectedExpression, "actual", actualExpression) + " ");
_failAction.Invoke(_builder!.ToString());
}
}
=> _failAction?.Invoke(_builder!.ToString(), notExpectedExpression, actualExpression);

public void AppendLiteral(string value) => _builder!.Append(value);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,7 @@ public static void AreNotEqual(string? notExpected, string? actual, bool ignoreC
return;
}

string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
ReportAssertAreNotEqualFailed(notExpected, actual, userMessage);
ReportAssertAreNotEqualFailed(notExpected, actual, message, notExpectedExpression, actualExpression);
}

#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
Expand Down
101 changes: 69 additions & 32 deletions src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -131,8 +131,7 @@ public static void AreEqual<T>(T? expected, T? actual, IEqualityComparer<T> comp
return;
}

string userMessage = BuildUserMessageForExpectedExpressionAndActualExpression(message, expectedExpression, actualExpression);
ReportAssertAreEqualFailed(expected, actual, userMessage);
ReportAssertAreEqualFailed(expected, actual, message, expectedExpression, actualExpression);
}

private static bool AreEqualFailing<T>(T? expected, T? actual, IEqualityComparer<T>? comparer)
Expand Down Expand Up @@ -237,26 +236,58 @@ private static string FormatStringDifferenceMessage(string expected, string actu
}

[DoesNotReturn]
private static void ReportAssertAreEqualFailed(object? expected, object? actual, string userMessage)
private static void ReportAssertAreEqualFailed(object? expected, object? actual, string? message, string expectedExpression, string actualExpression)
{
string finalMessage = actual != null && expected != null && !actual.GetType().Equals(expected.GetType())
? string.Format(
CultureInfo.CurrentCulture,
FrameworkMessages.AreEqualDifferentTypesFailMsg,
userMessage,
ReplaceNulls(expected),
expected.GetType().FullName,
ReplaceNulls(actual),
actual.GetType().FullName)
: expected is string expectedString && actual is string actualString
? FormatStringComparisonMessage(expectedString, actualString, userMessage)
: string.Format(
CultureInfo.CurrentCulture,
FrameworkMessages.AreEqualFailMsg,
userMessage,
ReplaceNulls(expected),
ReplaceNulls(actual));
ReportAssertFailed("Assert.AreEqual", finalMessage);
string expectedRendered = AssertionValueRenderer.RenderValue(expected);
string actualRendered = AssertionValueRenderer.RenderValue(actual);

string summary;
EvidenceBlock evidence;
string? additionalSummaryLine = null;

if (actual is not null && expected is not null && !actual.GetType().Equals(expected.GetType()))
{
Type expectedType = expected.GetType();
Type actualType = actual.GetType();
summary = FrameworkMessages.AreEqualDifferentTypesFailedSummary;
evidence = EvidenceBlock.Create()
.AddLine("expected:", expectedRendered)
.AddLine("expected type:", expectedType.FullName ?? expectedType.Name)
.AddLine("actual:", actualRendered)
.AddLine("actual type:", actualType.FullName ?? actualType.Name);
}
else if (expected is string expectedString && actual is string actualString)
{
summary = FrameworkMessages.AreEqualStringsFailedSummary;
int diffIndex = FindFirstStringDifference(expectedString, actualString);
additionalSummaryLine = expectedString.Length == actualString.Length
? string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEqualStringDiffLengthBothMsg, expectedString.Length, diffIndex)
: string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AreEqualStringDiffLengthDifferentMsg, expectedString.Length, actualString.Length);

evidence = EvidenceBlock.Create()
.AddLine("expected:", expectedRendered)
.AddLine("actual:", actualRendered);
}
else
{
summary = FrameworkMessages.AreEqualFailedSummary;
evidence = EvidenceBlock.Create()
.AddLine("expected:", expectedRendered)
.AddLine("actual:", actualRendered);
}

StructuredAssertionMessage structured = new(summary);
if (additionalSummaryLine is not null)
{
structured.WithAdditionalSummaryLine(additionalSummaryLine);
}

structured.WithUserMessage(message);
structured.WithEvidence(evidence);
structured.WithExpectedAndActual(expectedRendered, actualRendered);
structured.WithCallSiteExpression(FormatBinaryCallSiteExpression("Assert.AreEqual", expectedExpression, "expected", actualExpression, "actual"));

ReportAssertFailed(structured);
}

/// <summary>
Expand Down Expand Up @@ -347,23 +378,29 @@ public static void AreNotEqual<T>(T? notExpected, T? actual, IEqualityComparer<T
return;
}

string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
ReportAssertAreNotEqualFailed(notExpected, actual, userMessage);
ReportAssertAreNotEqualFailed(notExpected, actual, message, notExpectedExpression, actualExpression);
}

private static bool AreNotEqualFailing<T>(T? notExpected, T? actual, IEqualityComparer<T>? comparer)
=> (comparer ?? EqualityComparer<T>.Default).Equals(notExpected!, actual!);

[DoesNotReturn]
private static void ReportAssertAreNotEqualFailed(object? notExpected, object? actual, string userMessage)
private static void ReportAssertAreNotEqualFailed(object? notExpected, object? actual, string? message, string notExpectedExpression, string actualExpression)
{
string finalMessage = string.Format(
CultureInfo.CurrentCulture,
FrameworkMessages.AreNotEqualFailMsg,
userMessage,
ReplaceNulls(notExpected),
ReplaceNulls(actual));
ReportAssertFailed("Assert.AreNotEqual", finalMessage);
string notExpectedRendered = AssertionValueRenderer.RenderValue(notExpected);
string actualRendered = AssertionValueRenderer.RenderValue(actual);

EvidenceBlock evidence = EvidenceBlock.Create()
.AddLine("notExpected:", notExpectedRendered)
.AddLine("actual:", actualRendered);

StructuredAssertionMessage structured = new(FrameworkMessages.AreNotEqualFailedSummary);
structured.WithUserMessage(message);
structured.WithEvidence(evidence);
structured.WithExpectedAndActual($"not {notExpectedRendered}", actualRendered);
Comment thread
Evangelink marked this conversation as resolved.
structured.WithCallSiteExpression(FormatBinaryCallSiteExpression("Assert.AreNotEqual", notExpectedExpression, "notExpected", actualExpression, "actual"));

ReportAssertFailed(structured);
}

#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
Expand Down
20 changes: 20 additions & 0 deletions src/TestFramework/TestFramework/Assertions/Assert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,26 @@ internal static string ReplaceNulls(object? input)
? null
: $"{methodName}({expression})";

private static readonly char[] LineBreakChars = ['\n', '\r'];

/// <summary>
/// Formats a call-site expression like <c>Assert.MethodName(expression1, expression2)</c>.
/// When either expression is empty the call-site is omitted. When an expression contains
/// newlines (multiline constant) it is replaced with a <c>&lt;paramName&gt;</c> placeholder.
/// </summary>
private static string? FormatBinaryCallSiteExpression(string methodName, string expression1, string paramName1, string expression2, string paramName2)
{
if (string.IsNullOrEmpty(expression1) || string.IsNullOrEmpty(expression2))
Comment thread
Evangelink marked this conversation as resolved.
Outdated
{
return null;
}

string arg1 = expression1.IndexOfAny(LineBreakChars) >= 0 ? $"<{paramName1}>" : expression1;
string arg2 = expression2.IndexOfAny(LineBreakChars) >= 0 ? $"<{paramName2}>" : expression2;

return $"{methodName}({arg1}, {arg2})";
}

private static int CompareInternal(string? expected, string? actual, bool ignoreCase, CultureInfo culture)
#pragma warning disable CA1309 // Use ordinal string comparison
=> string.Compare(expected, actual, ignoreCase, culture);
Expand Down
12 changes: 12 additions & 0 deletions src/TestFramework/TestFramework/Resources/FrameworkMessages.resx
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,16 @@ Actual: {2}</value>
<data name="IsNotNullFailedSummary" xml:space="preserve">
<value>Expected value to not be null.</value>
</data>
<data name="AreEqualFailedSummary" xml:space="preserve">
<value>Expected values to be equal.</value>
</data>
<data name="AreEqualDifferentTypesFailedSummary" xml:space="preserve">
<value>Expected values to be equal, but they are of different types.</value>
</data>
<data name="AreEqualStringsFailedSummary" xml:space="preserve">
<value>Expected strings to be equal.</value>
</data>
<data name="AreNotEqualFailedSummary" xml:space="preserve">
<value>Expected values to not be equal.</value>
</data>
</root>
Loading
Loading