diff --git a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs
new file mode 100644
index 0000000000..9f58acd5c3
--- /dev/null
+++ b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs
@@ -0,0 +1,131 @@
+using TUnit.Assertions.Exceptions;
+
+namespace TUnit.Assertions.Tests.Bugs;
+
+///
+/// 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.
+///
+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();
+
+ 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();
+
+ 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();
+
+ 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 { 1, 2, 3 };
+ var expected = new List { 4, 5, 6 };
+ var action = async () => await Assert.That(actual).IsEqualTo(expected);
+
+ var exception = await Assert.That(action).Throws();
+
+ 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 Yield(params int[] values)
+ {
+ foreach (var v in values) yield return v;
+ }
+
+ IEnumerable actual = Yield(1, 2, 3);
+ IEnumerable expected = Yield(4, 5, 6);
+ var action = async () => await Assert.That(actual).IsEqualTo(expected);
+
+ var exception = await Assert.That(action).Throws();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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]]");
+ }
+}
diff --git a/TUnit.Assertions/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs
index b2d074ffe6..6da44ad7b0 100644
--- a/TUnit.Assertions/Conditions/EqualsAssertion.cs
+++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs
@@ -1,3 +1,4 @@
+using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
@@ -18,6 +19,7 @@ public class EqualsAssertion : Assertion
private readonly TValue? _expected;
private readonly IEqualityComparer? _comparer;
private readonly HashSet _ignoredTypes = new();
+ private string? _cachedExpectedFormat;
// Cache reflection results for better performance in deep comparison
private static readonly ConcurrentDictionary PropertyCache = new();
@@ -103,9 +105,106 @@ protected override Task CheckAsync(EvaluationMetadata 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.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