From 6b0d771a02974b9786455475a949ed48b17919fd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:36:46 +0100 Subject: [PATCH 1/3] fix(assertions): render collection contents in IsEqualTo failure messages (#5613 B) Arrays and List fall back to reference equality in EqualityComparer.Default and don't override ToString, so failures surfaced as "System.Int32[]" vs "System.Int32[]". Failures now show element contents (capped at 10 items) and, when contents sequence-equal but references differ, point users at IsEquivalentTo to clarify why a visually-equal assertion failed. --- .../Bugs/Issue5613ArrayFormatTests.cs | 81 ++++++++++++++++ .../Conditions/EqualsAssertion.cs | 93 ++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs diff --git a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs new file mode 100644 index 0000000000..e931856108 --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs @@ -0,0 +1,81 @@ +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 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..."); + } +} diff --git a/TUnit.Assertions/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs index b2d074ffe6..82b5571985 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; @@ -103,9 +104,99 @@ 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)) + { + var actualPreview = FormatValue(value); + if (SequenceEqualsNonGeneric(value!, _expected!)) + { + 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; + + private static string FormatValue(object? value) + { + if (value is null) + { + return "null"; + } + + if (value is string s) + { + return $"\"{s}\""; + } + + if (value is IEnumerable enumerable) + { + var items = new List(CollectionPreviewMax); + var total = 0; + foreach (var item in enumerable) + { + if (total < CollectionPreviewMax) + { + items.Add(item?.ToString() ?? "null"); + } + total++; + } + + var preview = string.Join(", ", items); + if (total > CollectionPreviewMax) + { + preview += $", and {total - CollectionPreviewMax} more..."; + } + + return $"[{preview}]"; + } + + return value.ToString() ?? "null"; + } + + private static bool SequenceEqualsNonGeneric(object actual, object expected) + { + var enumActual = ((IEnumerable)actual).GetEnumerator(); + var enumExpected = ((IEnumerable)expected).GetEnumerator(); + try + { + while (true) + { + var hasActual = enumActual.MoveNext(); + var hasExpected = enumExpected.MoveNext(); + if (hasActual != hasExpected) + { + return false; + } + + if (!hasActual) + { + return true; + } + + if (!Equals(enumActual.Current, enumExpected.Current)) + { + return false; + } + } + } + finally + { + (enumActual as IDisposable)?.Dispose(); + (enumExpected as IDisposable)?.Dispose(); + } + } + [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 +304,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 {FormatValue(_expected)}"; } From a5b7adace8ab498c0ab12b31db70d186be0520e1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:08:48 +0100 Subject: [PATCH 2/3] fix(assertions): materialize collections once in IsEqualTo failure path Addresses review feedback on #5619: FormatValue(value) exhausted the enumerator, then SequenceEqualsNonGeneric tried to enumerate again. For non-replayable sequences (yield generators, LINQ queries, File.ReadLines, IAsyncEnumerable-backed sources) the second pass saw an empty sequence and silently misreported "same contents, different reference". Same hazard for _expected between SequenceEquals and GetExpectation. Enumerate both sides exactly once into List, format and compare against the materialized lists, and cache the expected-side format so GetExpectation reuses it. Adds a regression test using a yield generator. --- .../Bugs/Issue5613ArrayFormatTests.cs | 22 +++++ .../Conditions/EqualsAssertion.cs | 93 ++++++++++--------- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs index e931856108..3564aaf157 100644 --- a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs +++ b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs @@ -66,6 +66,28 @@ public async Task List_IsEqualTo_Failure_Renders_Contents() 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() { diff --git a/TUnit.Assertions/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs index 82b5571985..701f76eb18 100644 --- a/TUnit.Assertions/Conditions/EqualsAssertion.cs +++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs @@ -19,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(); @@ -109,8 +110,14 @@ protected override Task CheckAsync(EvaluationMetadata m // when references differ but contents match, surface IsEquivalentTo to the user. if (_comparer is null && IsFormattableCollection(value) && IsFormattableCollection(_expected)) { - var actualPreview = FormatValue(value); - if (SequenceEqualsNonGeneric(value!, _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)")); @@ -141,60 +148,56 @@ private static string FormatValue(object? value) if (value is IEnumerable enumerable) { - var items = new List(CollectionPreviewMax); - var total = 0; - foreach (var item in enumerable) - { - if (total < CollectionPreviewMax) - { - items.Add(item?.ToString() ?? "null"); - } - total++; - } - - var preview = string.Join(", ", items); - if (total > CollectionPreviewMax) - { - preview += $", and {total - CollectionPreviewMax} more..."; - } - - return $"[{preview}]"; + return FormatItems(Materialize(enumerable)); } return value.ToString() ?? "null"; } - private static bool SequenceEqualsNonGeneric(object actual, object expected) + private static List Materialize(IEnumerable source) { - var enumActual = ((IEnumerable)actual).GetEnumerator(); - var enumExpected = ((IEnumerable)expected).GetEnumerator(); - try + var list = new List(); + foreach (var item in source) { - while (true) - { - var hasActual = enumActual.MoveNext(); - var hasExpected = enumExpected.MoveNext(); - if (hasActual != hasExpected) - { - return false; - } + list.Add(item); + } + return list; + } - if (!hasActual) - { - return true; - } + 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] = items[i]?.ToString() ?? "null"; + } - if (!Equals(enumActual.Current, enumExpected.Current)) - { - return false; - } - } + 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; } - finally + + for (var i = 0; i < actual.Count; i++) { - (enumActual as IDisposable)?.Dispose(); - (enumExpected as IDisposable)?.Dispose(); + 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")] @@ -304,5 +307,5 @@ private static (bool IsSuccess, string? Message) DeepEquals(object? actual, obje return (true, null); } - protected override string GetExpectation() => $"to be equal to {FormatValue(_expected)}"; + protected override string GetExpectation() => $"to be equal to {_cachedExpectedFormat ?? FormatValue(_expected)}"; } From 88fb0d65cf7a868a4e564e2fd62ce4e93b8b1b0a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:32:09 +0100 Subject: [PATCH 3/3] refactor(assertions): quote strings and recurse into nested collections in IsEqualTo format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses follow-up review on #5619: - String items inside collections are now quoted so ["hello", "world"] is unambiguous — the literal "null" and a null element no longer look identical, and whitespace-only strings remain visible. - Nested collections recurse, so List renders as [[1, 2], [3, 4]] rather than leaking "System.Int32[]". - Clarify via comment that FormatValue is only reached from the GetExpectation fallback path; the collection path in CheckAsync materializes once and calls FormatItems directly. Adds StringArray_IsEqualTo_Failure_Quotes_Items and NestedCollection_IsEqualTo_Failure_Recurses_Into_Items regressions. --- .../Bugs/Issue5613ArrayFormatTests.cs | 28 +++++++++++++++++++ .../Conditions/EqualsAssertion.cs | 9 ++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs index 3564aaf157..9f58acd5c3 100644 --- a/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs +++ b/TUnit.Assertions.Tests/Bugs/Issue5613ArrayFormatTests.cs @@ -100,4 +100,32 @@ public async Task LargeArray_IsEqualTo_Failure_Truncates_Contents() 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 701f76eb18..6da44ad7b0 100644 --- a/TUnit.Assertions/Conditions/EqualsAssertion.cs +++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs @@ -134,7 +134,12 @@ protected override Task CheckAsync(EvaluationMetadata m private static bool IsFormattableCollection(object? value) => value is IEnumerable && value is not string; - private static string FormatValue(object? value) + // 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) { @@ -170,7 +175,7 @@ private static string FormatItems(List items) var parts = new string[take]; for (var i = 0; i < take; i++) { - parts[i] = items[i]?.ToString() ?? "null"; + parts[i] = FormatItem(items[i]); } var preview = string.Join(", ", parts);