Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
153 changes: 153 additions & 0 deletions TUnit.Assertions.Tests/FocusedDiffMessageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using TUnit.Assertions.Enums;

namespace TUnit.Assertions.Tests;

/// <summary>
/// Tests for issue #5732 — failure messages for IsEqualTo / IsEquivalentTo should include
/// a focused "differs at member X: expected Y but found Z" hint instead of dumping the
/// whole serialized object graph.
/// </summary>
public class FocusedDiffMessageTests
{
public record EmployeeInfo(string FirstName, string LastName, int Age);

public class Employee
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public int Age { get; set; }

// Force reference equality so IsEqualTo (without overridden Equals) hits the
// structural diff fallback path rather than the records' value-equality path.
}

public class EmployeeWithNestedAddress
{
public string? Name { get; set; }
public Address? Address { get; set; }
}

public class Address
{
public string? Street { get; set; }
public string? City { get; set; }
}

[Test]
public async Task IsEqualTo_OnRecord_Failure_Includes_Differing_Property()
{
var actual = new EmployeeInfo("ictoria", "Apanii", 30);
var expected = new EmployeeInfo("Victoria", "Apanii", 30);

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEqualTo(expected));

await Assert.That(exception!.Message).Contains("differs at member FirstName");
await Assert.That(exception.Message).Contains("expected \"Victoria\"");
await Assert.That(exception.Message).Contains("found \"ictoria\"");
}

[Test]
public async Task IsEqualTo_OnReferenceType_Failure_Includes_Differing_Property()
{
var actual = new Employee { FirstName = "Tom", LastName = "X", Age = 1 };
var expected = new Employee { FirstName = "Tom", LastName = "Y", Age = 1 };

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEqualTo(expected));

await Assert.That(exception!.Message).Contains("differs at member LastName");
await Assert.That(exception.Message).Contains("expected \"Y\"");
await Assert.That(exception.Message).Contains("found \"X\"");
}

[Test]
public async Task IsEqualTo_OnReferenceType_Failure_Includes_Nested_Path()
{
var actual = new EmployeeWithNestedAddress
{
Name = "Bob",
Address = new Address { Street = "1 Main", City = "Foo" }
};
var expected = new EmployeeWithNestedAddress
{
Name = "Bob",
Address = new Address { Street = "1 Main", City = "Bar" }
};

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEqualTo(expected));

await Assert.That(exception!.Message).Contains("differs at member Address.City");
}

[Test]
public async Task IsEqualTo_PrimitiveString_Message_Unchanged()
{
// Primitives/strings should keep the simple "received X" path — verifies we don't
// regress the primitive case.
var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That("hello").IsEqualTo("world"));

await Assert.That(exception!.Message).DoesNotContain("differs at member");
}

[Test]
public async Task IsEquivalentTo_Collection_MatchingOrder_Failure_Includes_Property_Diff()
{
var actual = new[]
{
new EmployeeInfo("Victoria", "Apanii", 30),
new EmployeeInfo("Bob", "X", 25),
};
var expected = new[]
{
new EmployeeInfo("Victoria", "Apanii", 30),
new EmployeeInfo("Bob", "Y", 25),
};

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEquivalentTo(expected, CollectionOrdering.Matching));

await Assert.That(exception!.Message).Contains("differs at member LastName");
await Assert.That(exception.Message).Contains("expected \"Y\"");
await Assert.That(exception.Message).Contains("found \"X\"");
}

[Test]
public async Task IsEquivalentTo_Collection_AnyOrder_Failure_Includes_Closest_Match_Diff()
{
var actual = new[]
{
new EmployeeInfo("Victoria", "Apanii", 30),
new EmployeeInfo("Bob", "X", 25),
};
var expected = new[]
{
new EmployeeInfo("Victoria", "Apanii", 30),
// The closest match in actual is Bob/X — the diff should call out LastName.
new EmployeeInfo("Bob", "Y", 25),
};

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEquivalentTo(expected, CollectionOrdering.Any));

await Assert.That(exception!.Message).Contains("closest match");
await Assert.That(exception.Message).Contains("differs at member LastName");
}

[Test]
public async Task IsEquivalentTo_PrimitiveCollection_Message_Unchanged()
{
// Primitive collections shouldn't get a "closest match" hint — there is no
// member path to surface.
int[] actual = [1, 2, 3];
int[] expected = [1, 2, 4];

var exception = await Assert.ThrowsAsync<TUnitAssertionException>(
async () => await Assert.That(actual).IsEquivalentTo(expected, CollectionOrdering.Matching));

await Assert.That(exception!.Message).DoesNotContain("differs at member");
await Assert.That(exception.Message).DoesNotContain("closest match");
}
}
37 changes: 37 additions & 0 deletions TUnit.Assertions/Conditions/EqualsAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,46 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
return Task.FromResult(AssertionResult.Failed($"received {actualPreview}"));
}

// For non-primitive reference objects, surface a focused diff pointing to the first
// differing member instead of dumping the entire serialized object. Falls through to the
// generic "received {value}" message if no structural diff is available.
if (_comparer is null && TryFormatObjectDiff(value, _expected, out var diffMessage))
{
return Task.FromResult(AssertionResult.Failed(diffMessage));
}

return Task.FromResult(AssertionResult.Failed($"received {value}"));
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Structural diff is best-effort; gracefully degrades when reflection is unavailable")]
private static bool TryFormatObjectDiff(object? actual, object? expected, out string message)
{
message = string.Empty;

if (actual is null || expected is null)
{
return false;
}

var type = actual.GetType();
// Primitives, strings, and well-known immutable types already produce useful messages
// via the standard "received {value}" path — no need for structural inspection.
if (TypeHelper.IsPrimitiveOrWellKnownType(type))
{
return false;
}

var diff = StructuralDiffHelper.FindFirstDifference(actual, expected);
var formatted = StructuralDiffHelper.FormatDiff(diff);
if (formatted is null)
{
return false;
}

message = $"received {actual} ({formatted})";
return true;
}

private const int CollectionPreviewMax = 10;

private static bool IsFormattableCollection(object? value)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Assertions.Enums;

namespace TUnit.Assertions.Conditions.Helpers;
Expand Down Expand Up @@ -70,14 +71,40 @@ private static CheckResult CheckOrderedEquivalence<TItem>(

if (!areEqual)
{
var diff = DescribeItemDifference(expectedItem, actualItem);
return CheckResult.Failure(
$"collection item at index {i} does not match: expected {expectedItem}, but was {actualItem}");
$"collection item at index {i} does not match: expected {expectedItem}, but was {actualItem}{diff}");
}
}

return CheckResult.Success();
}

/// <summary>
/// Adds a focused structural diff to the failure message when both items are non-null
/// reference objects with reflectable members. Returns an empty string for primitives or
/// well-known types — those already render as "expected X but was Y" via the caller's
/// message, so a structural diff would be redundant.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Structural diff is best-effort; gracefully degrades when reflection is unavailable")]
private static string DescribeItemDifference<TItem>(TItem expected, TItem actual)
{
if (expected is null || actual is null)
{
return string.Empty;
}

var type = expected.GetType();
if (TypeHelper.IsPrimitiveOrWellKnownType(type))
{
return string.Empty;
}

var diff = StructuralDiffHelper.FindFirstDifference(actual, expected);
var formatted = StructuralDiffHelper.FormatDiff(diff);
return formatted is null ? string.Empty : $" ({formatted})";
}

private static CheckResult CheckUnorderedEquivalence<TItem>(
List<TItem> actualList,
List<TItem> expectedList,
Expand Down Expand Up @@ -126,8 +153,9 @@ private static CheckResult CheckUnorderedEquivalenceLinear<TItem>(

if (foundIndex == -1)
{
var diff = DescribeClosestDiff(expectedItem, actualList);
return CheckResult.Failure(
$"collection does not contain expected item: {expectedItem}");
$"collection does not contain expected item: {expectedItem}{diff}");
}

remainingActual.RemoveAt(foundIndex);
Expand All @@ -136,6 +164,68 @@ private static CheckResult CheckUnorderedEquivalenceLinear<TItem>(
return CheckResult.Success();
}

/// <summary>
/// Finds the candidate in <paramref name="candidates"/> with the highest "similarity score"
/// to <paramref name="expected"/> and returns a parenthesized hint pointing to the diff for
/// that candidate. Similarity counts top-level members that match exactly — this picks the
/// candidate that "almost matches" rather than an unrelated item. Returns empty when no
/// useful hint can be produced (primitives, no reflectable members, or no candidate of the
/// matching type).
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Structural diff is best-effort; gracefully degrades when reflection is unavailable")]
private static string DescribeClosestDiff<TItem>(TItem expected, IReadOnlyList<TItem> candidates)
{
if (expected is null || candidates.Count == 0)
{
return string.Empty;
}

var expectedType = expected.GetType();
if (TypeHelper.IsPrimitiveOrWellKnownType(expectedType))
{
return string.Empty;
}

StructuralDiffHelper.DiffResult bestDiff = default;
var bestScore = -1;
TItem? bestCandidate = default;

foreach (var candidate in candidates)
{
if (candidate is null || candidate.GetType() != expectedType)
{
continue;
}

var diff = StructuralDiffHelper.FindFirstDifference(candidate, expected);
if (!diff.HasDiff)
{
continue;
}

var score = StructuralDiffHelper.CountMatchingTopLevelMembers(candidate, expected);
if (score > bestScore)
{
bestScore = score;
bestDiff = diff;
bestCandidate = candidate;
}
}

if (bestScore < 0)
{
return string.Empty;
}

var formatted = StructuralDiffHelper.FormatDiff(bestDiff);
if (formatted is null)
{
return string.Empty;
}

return $" (closest match {bestCandidate} {formatted})";
}

private static CheckResult CheckUnorderedEquivalenceDictionary<TItem>(
List<TItem> actualList,
List<TItem> expectedList,
Expand Down Expand Up @@ -183,8 +273,9 @@ private static CheckResult CheckUnorderedEquivalenceDictionary<TItem>(
{
if (!actualCounts.TryGetValue(expectedItem, out var count) || count == 0)
{
var diff = DescribeClosestDiff(expectedItem, actualList);
return CheckResult.Failure(
$"collection does not contain expected item: {expectedItem}");
$"collection does not contain expected item: {expectedItem}{diff}");
}
actualCounts[expectedItem] = count - 1;
}
Expand Down
Loading
Loading