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 Materialize(IEnumerable source) + { + var list = new List(); + foreach (var item in source) + { + list.Add(item); + } + return list; + } + + private static string FormatItems(List 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 actual, List 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 ignoredTypes, HashSet visited) @@ -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)}"; }