From c4869ed7997a9791f7ec775c5fe68c79e47cead0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:09:49 +0000 Subject: [PATCH 1/2] fix: IsEquivalentTo falls back to Equals() for types with no public members (#5040) When a type has only private state and implements IEquatable, structural comparison found no public members and trivially returned true (false positive) or threw NotSupportedException. Now both StructuralEqualityComparer and StructuralEquivalencyAssertion detect empty member lists and fall back to Equals(), which respects IEquatable. Also adds a NotSupportedException safety net around reflection-based member access. --- TUnit.Assertions.Tests/Bugs/Tests5040.cs | 80 +++++++++++++++++++ .../Helpers/StructuralEqualityComparer.cs | 22 ++++- .../StructuralEquivalencyAssertion.cs | 16 ++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 TUnit.Assertions.Tests/Bugs/Tests5040.cs diff --git a/TUnit.Assertions.Tests/Bugs/Tests5040.cs b/TUnit.Assertions.Tests/Bugs/Tests5040.cs new file mode 100644 index 0000000000..b773e6bf27 --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Tests5040.cs @@ -0,0 +1,80 @@ +namespace TUnit.Assertions.Tests.Bugs; + +/// +/// Tests for issue #5040: IsEquivalentTo throws NotSupportedException on IEquatable types with private state. +/// When a type has no public members but implements IEquatable, structural comparison should fall back to Equals(). +/// +public class Tests5040 +{ + #region Test Types + + private sealed class PrivateStatePath : IEquatable + { + private readonly string _value; + + public PrivateStatePath(string value) + { + _value = value; + } + + public bool Equals(PrivateStatePath? other) + { + if (other is null) + { + return false; + } + + return _value == other._value; + } + + public override bool Equals(object? obj) => Equals(obj as PrivateStatePath); + + public override int GetHashCode() => _value.GetHashCode(); + + public override string ToString() => _value; + } + + #endregion + + [Test] + public async Task IEquatable_Class_With_No_Public_Members_Equal_Values_Passes() + { + var path1 = new PrivateStatePath("/home/user/file.txt"); + var path2 = new PrivateStatePath("/home/user/file.txt"); + + await Assert.That(path1).IsEquivalentTo(path2); + } + + [Test] + public async Task IEquatable_Class_With_No_Public_Members_Different_Values_Fails() + { + var path1 = new PrivateStatePath("/home/user/file1.txt"); + var path2 = new PrivateStatePath("/home/user/file2.txt"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(path1).IsEquivalentTo(path2)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task IEquatable_Class_Collection_Unordered_Passes() + { + // Reproduces the exact scenario from issue #5040 + var list = new List + { + new("/home/user/a.txt"), + new("/home/user/b.txt"), + new("/home/user/c.txt"), + }; + + var expected = new List + { + new("/home/user/c.txt"), + new("/home/user/a.txt"), + new("/home/user/b.txt"), + }; + + await Assert.That(list).IsEquivalentTo(expected); + } +} diff --git a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs index 13273b4a45..f5397c1246 100644 --- a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs +++ b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs @@ -105,10 +105,28 @@ private bool CompareStructurally(object? x, object? y, HashSet visited) var members = ReflectionHelper.GetMembersToCompare(xType); + // When there are no public members to compare structurally (e.g., types with only + // private state), fall back to Equals(). This respects IEquatable implementations + // and avoids false positives from empty member lists. + if (members.Count == 0) + { + return Equals(x, y); + } + foreach (var member in members) { - var xValue = ReflectionHelper.GetMemberValue(x, member); - var yValue = ReflectionHelper.GetMemberValue(y, member); + object? xValue, yValue; + try + { + xValue = ReflectionHelper.GetMemberValue(x, member); + yValue = ReflectionHelper.GetMemberValue(y, member); + } + catch (NotSupportedException) + { + // Property getter cannot be invoked via reflection (e.g., .NET runtime restrictions). + // Fall back to Equals() for the entire object comparison. + return Equals(x, y); + } if (!CompareStructurally(xValue, yValue, visited)) { diff --git a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs index 13f95790b9..0bffbdfa99 100644 --- a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs +++ b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs @@ -193,6 +193,22 @@ internal AssertionResult CompareObjects( // Compare properties and fields var expectedMembers = ReflectionHelper.GetMembersToCompare(expectedType); + // When there are no public members to compare structurally (e.g., types with only + // private state), fall back to Equals(). This respects IEquatable implementations + // and avoids false positives from empty member lists. + if (expectedMembers.Count == 0) + { + var actualMembersCheck = ReflectionHelper.GetMembersToCompare(actualType); + if (actualMembersCheck.Count == 0) + { + if (!Equals(actual, expected)) + { + return AssertionResult.Failed($"Property {path} did not match{Environment.NewLine}Expected: {FormatValue(expected)}{Environment.NewLine}Received: {FormatValue(actual)}"); + } + return AssertionResult.Passed; + } + } + foreach (var member in expectedMembers) { var memberPath = string.IsNullOrEmpty(path) ? member.Name : $"{path}.{member.Name}"; From a7b43d008cb957bd3a36714a57b328ab9b5cff7a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:32:29 +0000 Subject: [PATCH 2/2] refactor: address code review feedback - Filter non-readable properties in ReflectionHelper.GetMembersToCompare (CanRead + public GetMethod check) instead of catching NotSupportedException after the fact - Simplify StructuralEquivalencyAssertion empty-members check: remove redundant double reflection call for actualMembers - Fix error message for top-level comparison ("Objects did not match" instead of "Property did not match") - Add tests for mismatched types and partial equivalency mode with no-public-member types --- TUnit.Assertions.Tests/Bugs/Tests5040.cs | 39 +++++++++++++++++++ .../Conditions/Helpers/ReflectionHelper.cs | 2 +- .../Helpers/StructuralEqualityComparer.cs | 14 +------ .../StructuralEquivalencyAssertion.cs | 11 ++---- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/TUnit.Assertions.Tests/Bugs/Tests5040.cs b/TUnit.Assertions.Tests/Bugs/Tests5040.cs index b773e6bf27..3901b73b7a 100644 --- a/TUnit.Assertions.Tests/Bugs/Tests5040.cs +++ b/TUnit.Assertions.Tests/Bugs/Tests5040.cs @@ -34,6 +34,11 @@ public bool Equals(PrivateStatePath? other) public override string ToString() => _value; } + private class PublicStateMessage + { + public string Content { get; set; } = string.Empty; + } + #endregion [Test] @@ -77,4 +82,38 @@ public async Task IEquatable_Class_Collection_Unordered_Passes() await Assert.That(list).IsEquivalentTo(expected); } + + [Test] + public async Task No_Public_Members_Vs_Public_Members_Fails() + { + // Expected has no public members, actual has public members — types are incompatible + var expected = new PrivateStatePath("/home/user/file.txt"); + var actual = new PublicStateMessage { Content = "/home/user/file.txt" }; + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(actual).IsEquivalentTo(expected)); + + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task Partial_Equivalency_With_No_Public_Members_Equal_Values_Passes() + { + var path1 = new PrivateStatePath("/home/user/file.txt"); + var path2 = new PrivateStatePath("/home/user/file.txt"); + + await Assert.That(path1).IsEquivalentTo(path2).WithPartialEquivalency(); + } + + [Test] + public async Task Partial_Equivalency_With_No_Public_Members_Different_Values_Fails() + { + var path1 = new PrivateStatePath("/home/user/file1.txt"); + var path2 = new PrivateStatePath("/home/user/file2.txt"); + + var exception = await Assert.ThrowsAsync( + async () => await Assert.That(path1).IsEquivalentTo(path2).WithPartialEquivalency()); + + await Assert.That(exception).IsNotNull(); + } } diff --git a/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs b/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs index ab52490c39..5f0079393e 100644 --- a/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs +++ b/TUnit.Assertions/Conditions/Helpers/ReflectionHelper.cs @@ -25,7 +25,7 @@ public static List GetMembersToCompare( var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var prop in properties) { - if (prop.GetIndexParameters().Length == 0) + if (prop.GetIndexParameters().Length == 0 && prop.CanRead && prop.GetMethod?.IsPublic == true) { members.Add(prop); } diff --git a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs index f5397c1246..389195e0d9 100644 --- a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs +++ b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs @@ -115,18 +115,8 @@ private bool CompareStructurally(object? x, object? y, HashSet visited) foreach (var member in members) { - object? xValue, yValue; - try - { - xValue = ReflectionHelper.GetMemberValue(x, member); - yValue = ReflectionHelper.GetMemberValue(y, member); - } - catch (NotSupportedException) - { - // Property getter cannot be invoked via reflection (e.g., .NET runtime restrictions). - // Fall back to Equals() for the entire object comparison. - return Equals(x, y); - } + var xValue = ReflectionHelper.GetMemberValue(x, member); + var yValue = ReflectionHelper.GetMemberValue(y, member); if (!CompareStructurally(xValue, yValue, visited)) { diff --git a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs index 0bffbdfa99..29b80d4d89 100644 --- a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs +++ b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs @@ -198,15 +198,12 @@ internal AssertionResult CompareObjects( // and avoids false positives from empty member lists. if (expectedMembers.Count == 0) { - var actualMembersCheck = ReflectionHelper.GetMembersToCompare(actualType); - if (actualMembersCheck.Count == 0) + if (!Equals(actual, expected)) { - if (!Equals(actual, expected)) - { - return AssertionResult.Failed($"Property {path} did not match{Environment.NewLine}Expected: {FormatValue(expected)}{Environment.NewLine}Received: {FormatValue(actual)}"); - } - return AssertionResult.Passed; + var label = string.IsNullOrEmpty(path) ? "Objects" : $"Property {path}"; + return AssertionResult.Failed($"{label} did not match{Environment.NewLine}Expected: {FormatValue(expected)}{Environment.NewLine}Received: {FormatValue(actual)}"); } + return AssertionResult.Passed; } foreach (var member in expectedMembers)