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

// GetProperties() order isn't contractually guaranteed across runtimes, so we just
// verify the focused-diff path was hit and the differing values surfaced — the only
// differing member is FirstName, so its values must appear regardless of which
// member name the formatter prints.
await Assert.That(exception!.Message).Contains("differs at member");
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");
}
}
43 changes: 42 additions & 1 deletion TUnit.Assertions/Conditions/EqualsAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}", exception));
}

// Deep comparison with ignored types
// Deep comparison with ignored types.
// Intentionally skips the TryFormatObjectDiff path below: StructuralDiffHelper has no
// knowledge of the ignored-type set, so any "differs at member X" hint it produced
// could falsely flag a member the user explicitly opted out of comparing. DeepEquals
// already produces a member-aware failure message that respects _ignoredTypes.
if (_ignoredTypes.Count > 0)
{
// Use reference-based tracking to detect cycles
Expand Down Expand Up @@ -126,9 +130,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
Loading
Loading