diff --git a/TUnit.Assertions.Tests/Bugs/Tests5040.cs b/TUnit.Assertions.Tests/Bugs/Tests5040.cs new file mode 100644 index 0000000000..3901b73b7a --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Tests5040.cs @@ -0,0 +1,119 @@ +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; + } + + private class PublicStateMessage + { + public string Content { get; set; } = string.Empty; + } + + #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); + } + + [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 13273b4a45..389195e0d9 100644 --- a/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs +++ b/TUnit.Assertions/Conditions/Helpers/StructuralEqualityComparer.cs @@ -105,6 +105,14 @@ 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); diff --git a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs index 13f95790b9..29b80d4d89 100644 --- a/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs +++ b/TUnit.Assertions/Conditions/StructuralEquivalencyAssertion.cs @@ -193,6 +193,19 @@ 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) + { + if (!Equals(actual, expected)) + { + 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) { var memberPath = string.IsNullOrEmpty(path) ? member.Name : $"{path}.{member.Name}";