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
131 changes: 131 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using TUnit.Assertions.Exceptions;

namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Regression tests for GitHub issue #5613 finding #2: array IsEqualTo uses reference
/// equality, and failure messages rendered both sides as "System.UInt32[]" because
/// arrays don't override ToString. The message must:
/// - show element contents when references (and contents) differ, and
/// - disclose reference-equality semantics (especially when contents match), so users
/// see why a "visually-equal" assertion failed and are pointed at IsEquivalentTo.
/// </summary>
public class Issue5613ArrayFormatTests
{
[Test]
public async Task Array_IsEqualTo_With_Different_Contents_Renders_Both_Sides()
{
uint[] actual = [2u, 5u, 7u];
uint[] expected = [2u, 5u, 8u];
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).DoesNotContain("System.UInt32[]");
await Assert.That(exception.Message).Contains("[2, 5, 8]");
await Assert.That(exception.Message).Contains("[2, 5, 7]");
}

[Test]
public async Task Array_IsEqualTo_With_Equal_Contents_Different_References_Explains_Reference_Semantics()
{
uint[] actual = [2u, 5u, 7u];
uint[] expected = [2u, 5u, 7u];
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).DoesNotContain("System.UInt32[]");
await Assert.That(exception.Message).Contains("IsEquivalentTo");
}

[Test]
public async Task IntArray_IsEqualTo_Failure_Renders_Contents()
{
int[] actual = [1, 2, 3];
int[] expected = [4, 5, 6];
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).DoesNotContain("System.Int32[]");
await Assert.That(exception.Message).Contains("[1, 2, 3]");
await Assert.That(exception.Message).Contains("[4, 5, 6]");
}

[Test]
public async Task List_IsEqualTo_Failure_Renders_Contents()
{
var actual = new List<int> { 1, 2, 3 };
var expected = new List<int> { 4, 5, 6 };
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).Contains("[1, 2, 3]");
await Assert.That(exception.Message).Contains("[4, 5, 6]");
}

[Test]
public async Task NonReplayable_IEnumerable_IsEqualTo_Failure_Renders_Contents_Once()
{
// Regression: iterator-generated sequences (yield) are single-shot. Earlier revision
// enumerated value+expected twice (FormatValue then SequenceEquals) — the second
// pass saw an empty sequence and silently misreported "same contents, different ref".
static IEnumerable<int> Yield(params int[] values)
{
foreach (var v in values) yield return v;
}

IEnumerable<int> actual = Yield(1, 2, 3);
IEnumerable<int> expected = Yield(4, 5, 6);
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).Contains("[1, 2, 3]");
await Assert.That(exception.Message).Contains("[4, 5, 6]");
await Assert.That(exception.Message).DoesNotContain("IsEquivalentTo");
}

[Test]
public async Task LargeArray_IsEqualTo_Failure_Truncates_Contents()
{
var actual = Enumerable.Range(1, 15).ToArray();
var expected = Enumerable.Range(100, 15).ToArray();
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).DoesNotContain("System.Int32[]");
await Assert.That(exception.Message).Contains("more...");
}

[Test]
public async Task StringArray_IsEqualTo_Failure_Quotes_Items()
{
// Prevents ambiguity between null and the literal "null", and keeps whitespace visible.
string[] actual = ["hello", "world"];
string[] expected = ["foo", "bar"];
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).Contains("[\"hello\", \"world\"]");
await Assert.That(exception.Message).Contains("[\"foo\", \"bar\"]");
}

[Test]
public async Task NestedCollection_IsEqualTo_Failure_Recurses_Into_Items()
{
int[][] actual = [[1, 2], [3, 4]];
int[][] expected = [[5, 6], [7, 8]];
var action = async () => await Assert.That(actual).IsEqualTo(expected);

var exception = await Assert.That(action).Throws<AssertionException>();

await Assert.That(exception.Message).DoesNotContain("System.Int32[]");
await Assert.That(exception.Message).Contains("[[1, 2], [3, 4]]");
await Assert.That(exception.Message).Contains("[[5, 6], [7, 8]]");
}
}
101 changes: 100 additions & 1 deletion TUnit.Assertions/Conditions/EqualsAssertion.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
Expand All @@ -18,6 +19,7 @@ public class EqualsAssertion<TValue> : Assertion<TValue>
private readonly TValue? _expected;
private readonly IEqualityComparer<TValue>? _comparer;
private readonly HashSet<Type> _ignoredTypes = new();
private string? _cachedExpectedFormat;

// Cache reflection results for better performance in deep comparison
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();
Expand Down Expand Up @@ -103,9 +105,106 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
return AssertionResult._passedTask;
}

// Render collections with element contents so failure messages aren't "System.Int32[]".
// Arrays and most collections use reference equality via EqualityComparer<T>.Default —
// when references differ but contents match, surface IsEquivalentTo to the user.
if (_comparer is null && IsFormattableCollection(value) && IsFormattableCollection(_expected))
{
// Materialize both once — non-replayable sequences (yield, LINQ, File.ReadLines)
// would otherwise be exhausted by the first enumeration.
var actualItems = Materialize((IEnumerable)value!);
var expectedItems = Materialize((IEnumerable)_expected!);
var actualPreview = FormatItems(actualItems);
_cachedExpectedFormat = FormatItems(expectedItems);

if (SequenceEqualsLists(actualItems, expectedItems))
{
return Task.FromResult(AssertionResult.Failed(
$"received {actualPreview} (same contents, different reference — use IsEquivalentTo to compare by contents)"));
}

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

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

private const int CollectionPreviewMax = 10;

private static bool IsFormattableCollection(object? value)
=> value is IEnumerable && value is not string;

// Fallback formatter used by GetExpectation when the failure path did not run
// (e.g. when a custom comparer was supplied, or the receiver is not a collection).
// The hot collection path in CheckAsync materializes once and calls FormatItems directly.
private static string FormatValue(object? value) => FormatItem(value);

private static string FormatItem(object? value)
{
if (value is null)
{
return "null";
}

if (value is string s)
{
return $"\"{s}\"";
}

if (value is IEnumerable enumerable)
{
return FormatItems(Materialize(enumerable));
}

return value.ToString() ?? "null";
}

private static List<object?> Materialize(IEnumerable source)
{
var list = new List<object?>();
foreach (var item in source)
{
list.Add(item);
}
return list;
}

private static string FormatItems(List<object?> items)
{
var take = Math.Min(items.Count, CollectionPreviewMax);
var parts = new string[take];
for (var i = 0; i < take; i++)
{
parts[i] = FormatItem(items[i]);
}

var preview = string.Join(", ", parts);
if (items.Count > CollectionPreviewMax)
{
preview += $", and {items.Count - CollectionPreviewMax} more...";
}

return $"[{preview}]";
}

private static bool SequenceEqualsLists(List<object?> actual, List<object?> expected)
{
if (actual.Count != expected.Count)
{
return false;
}

for (var i = 0; i < actual.Count; i++)
{
if (!Equals(actual[i], expected[i]))
{
return false;
}
}

return true;
}

[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Deep comparison requires reflection access to all public properties and fields of runtime types")]
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Deep comparison requires reflection access to all public properties and fields of runtime types")]
private static (bool IsSuccess, string? Message) DeepEquals(object? actual, object? expected, HashSet<Type> ignoredTypes, HashSet<object> visited)
Expand Down Expand Up @@ -213,5 +312,5 @@ private static (bool IsSuccess, string? Message) DeepEquals(object? actual, obje
return (true, null);
}

protected override string GetExpectation() => $"to be equal to {(_expected is string s ? $"\"{s}\"" : _expected)}";
protected override string GetExpectation() => $"to be equal to {_cachedExpectedFormat ?? FormatValue(_expected)}";
}
Loading