diff --git a/TUnit.Assertions.Tests/IEquatableCollectionTests.cs b/TUnit.Assertions.Tests/IEquatableCollectionTests.cs
new file mode 100644
index 0000000000..b62aacd48f
--- /dev/null
+++ b/TUnit.Assertions.Tests/IEquatableCollectionTests.cs
@@ -0,0 +1,163 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests for collections containing types that implement IEquatable<T>.
+/// Verifies that IsEquivalentTo respects IEquatable implementation rather than using structural comparison.
+///
+public class IEquatableCollectionTests
+{
+ [Test]
+ public async Task Vector2_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Vector2 implements IEquatable
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Vector2's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task Uri_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Uri implements IEquatable
+ var array = new Uri[]
+ {
+ new Uri("https://example.com"),
+ new Uri("https://github.com"),
+ new Uri("https://stackoverflow.com"),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Uri's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CultureInfo_Array_IsEquivalentTo_List()
+ {
+ // Arrange - CultureInfo implements IEquatable
+ var array = new System.Globalization.CultureInfo[]
+ {
+ new System.Globalization.CultureInfo("en-US"),
+ new System.Globalization.CultureInfo("fr-FR"),
+ new System.Globalization.CultureInfo("de-DE"),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use CultureInfo's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task Vector3_Array_IsEquivalentTo_List()
+ {
+ // Arrange - Vector3 implements IEquatable
+ var array = new Vector3[]
+ {
+ new Vector3(1, 2, 3),
+ new Vector3(4, 5, 6),
+ new Vector3(7, 8, 9),
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use Vector3's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task DateTimeOffset_Array_IsEquivalentTo_List()
+ {
+ // Arrange - DateTimeOffset implements IEquatable
+ var array = new DateTimeOffset[]
+ {
+ new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero),
+ new DateTimeOffset(2023, 6, 15, 12, 30, 0, TimeSpan.Zero),
+ new DateTimeOffset(2023, 12, 31, 23, 59, 59, TimeSpan.Zero),
+ };
+ var list = new List(array);
+
+ // Act & Assert
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_IsEquivalentTo_List()
+ {
+ // Arrange - custom type implementing IEquatable
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ new CustomEquatable { Id = 3, Name = "Third" },
+ };
+ var list = new List(array);
+
+ // Act & Assert - should use CustomEquatable's IEquatable.Equals
+ await Assert.That(array).IsEquivalentTo(list);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_NotEquivalent_DifferentValues()
+ {
+ // Arrange
+ var array1 = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var array2 = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 3, Name = "Third" }, // Different Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await Assert.That(array1).IsEquivalentTo(array2);
+ });
+ }
+
+ ///
+ /// Custom type implementing IEquatable<T> for testing.
+ ///
+ public class CustomEquatable : IEquatable
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+
+ public bool Equals(CustomEquatable? other)
+ {
+ if (other is null) return false;
+ return Id == other.Id && Name == other.Name;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is CustomEquatable other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+#if NET6_0_OR_GREATER
+ return HashCode.Combine(Id, Name);
+#else
+ unchecked
+ {
+ int hash = 17;
+ hash = hash * 23 + Id.GetHashCode();
+ hash = hash * 23 + (Name?.GetHashCode() ?? 0);
+ return hash;
+ }
+#endif
+ }
+ }
+}
diff --git a/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs b/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs
new file mode 100644
index 0000000000..ca4419b8c5
--- /dev/null
+++ b/TUnit.Assertions.Tests/IEquatableWorkaroundTests.cs
@@ -0,0 +1,107 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests to ensure the workaround mentioned in the issue still works.
+/// Users should be able to explicitly pass EqualityComparer<T>.Default if needed.
+///
+public class IEquatableWorkaroundTests
+{
+ [Test]
+ public async Task Vector2_Array_IsEquivalentTo_WithExplicitComparer()
+ {
+ // Arrange
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var list = new List(array);
+
+ // Act & Assert - explicitly passing EqualityComparer.Default
+ await Assert.That(array).IsEquivalentTo(list, EqualityComparer.Default);
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_IsEquivalentTo_WithCustomComparer()
+ {
+ // Arrange
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var list = new List(array);
+
+ // Act & Assert - using a custom comparer that only compares Id
+ await Assert.That(array).IsEquivalentTo(list, new IdOnlyComparer());
+ }
+
+ [Test]
+ public async Task CustomEquatable_Array_WithDifferentNames_EquivalentWithCustomComparer()
+ {
+ // Arrange
+ var array = new CustomEquatable[]
+ {
+ new CustomEquatable { Id = 1, Name = "First" },
+ new CustomEquatable { Id = 2, Name = "Second" },
+ };
+ var list = new List
+ {
+ new CustomEquatable { Id = 1, Name = "Different" },
+ new CustomEquatable { Id = 2, Name = "Names" },
+ };
+
+ // Act & Assert - custom comparer ignores Name
+ await Assert.That(array).IsEquivalentTo(list, new IdOnlyComparer());
+ }
+
+ public class CustomEquatable : IEquatable
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+
+ public bool Equals(CustomEquatable? other)
+ {
+ if (other is null) return false;
+ return Id == other.Id && Name == other.Name;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is CustomEquatable other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+#if NET6_0_OR_GREATER
+ return HashCode.Combine(Id, Name);
+#else
+ unchecked
+ {
+ int hash = 17;
+ hash = hash * 23 + Id.GetHashCode();
+ hash = hash * 23 + (Name?.GetHashCode() ?? 0);
+ return hash;
+ }
+#endif
+ }
+ }
+
+ private class IdOnlyComparer : IEqualityComparer
+ {
+ public bool Equals(CustomEquatable? x, CustomEquatable? y)
+ {
+ if (x is null && y is null) return true;
+ if (x is null || y is null) return false;
+ return x.Id == y.Id;
+ }
+
+ public int GetHashCode(CustomEquatable obj)
+ {
+ return obj.Id.GetHashCode();
+ }
+ }
+}
diff --git a/TUnit.Assertions.Tests/IssueReproductionTest.cs b/TUnit.Assertions.Tests/IssueReproductionTest.cs
new file mode 100644
index 0000000000..ee8b0fc25b
--- /dev/null
+++ b/TUnit.Assertions.Tests/IssueReproductionTest.cs
@@ -0,0 +1,47 @@
+using System.Numerics;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Reproduces the exact test case from the GitHub issue to verify the fix.
+/// This test should now pass without requiring an explicit comparer.
+///
+public class IssueReproductionTest
+{
+ ///
+ /// This is the exact test case from the GitHub issue.
+ /// Previously this would fail with "Parameter count mismatch" error.
+ /// Now it should pass because Vector2 implements IEquatable<Vector2>.
+ ///
+ [Test]
+ public async Task Test1_FromGitHubIssue()
+ {
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var array2 = new List(array);
+
+ await Assert.That(array).IsEquivalentTo(array2);
+ }
+
+ ///
+ /// Verify the workaround mentioned in the issue still works.
+ /// Users can still explicitly pass EqualityComparer if needed.
+ ///
+ [Test]
+ public async Task Test2_WithExplicitComparer_StillWorks()
+ {
+ var array = new Vector2[]
+ {
+ new Vector2(1, 2),
+ new Vector2(3, 4),
+ new Vector2(5, 6),
+ };
+ var array2 = new List(array);
+
+ await Assert.That(array).IsEquivalentTo(array2, EqualityComparer.Default);
+ }
+}
diff --git a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
index 856a05d9e6..3411a44a95 100644
--- a/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
+++ b/TUnit.Assertions/Conditions/Helpers/TypeHelper.cs
@@ -53,13 +53,45 @@ public static void ClearCustomPrimitives()
CustomPrimitiveTypes.Clear();
}
+ ///
+ /// Checks if a type is a compiler-generated record type.
+ ///
+ private static bool IsCompilerGeneratedType(
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)]
+ Type type)
+ {
+ // Records have a compiler-generated EqualityContract property
+ // This is more reliable than checking for $ method
+ var equalityContract = type.GetProperty("EqualityContract",
+ System.Reflection.BindingFlags.NonPublic |
+ System.Reflection.BindingFlags.Instance);
+
+ if (equalityContract != null && equalityContract.PropertyType == typeof(Type))
+ {
+ return true;
+ }
+
+ // Also check for $ method as a fallback
+ return type.GetMethod("$", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) != null;
+ }
+
///
/// Determines if a type is a primitive or well-known immutable type that should use
/// value equality rather than structural comparison.
+ /// Types implementing IEquatable<T> are also considered primitive-like for comparison purposes.
///
/// The type to check.
/// True if the type should use value equality; false for structural comparison.
- public static bool IsPrimitiveOrWellKnownType(Type type)
+ public static bool IsPrimitiveOrWellKnownType(
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties |
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)]
+ Type type)
{
// Check user-defined primitives first (fast path for common case)
if (CustomPrimitiveTypes.ContainsKey(type))
@@ -67,18 +99,54 @@ public static bool IsPrimitiveOrWellKnownType(Type type)
return true;
}
- return type.IsPrimitive
- || type.IsEnum
- || type == typeof(string)
- || type == typeof(decimal)
- || type == typeof(DateTime)
- || type == typeof(DateTimeOffset)
- || type == typeof(TimeSpan)
- || type == typeof(Guid)
+ // Check if type is a well-known primitive type
+ if (type.IsPrimitive
+ || type.IsEnum
+ || type == typeof(string)
+ || type == typeof(decimal)
+ || type == typeof(DateTime)
+ || type == typeof(DateTimeOffset)
+ || type == typeof(TimeSpan)
+ || type == typeof(Guid)
#if NET6_0_OR_GREATER
- || type == typeof(DateOnly)
- || type == typeof(TimeOnly)
+ || type == typeof(DateOnly)
+ || type == typeof(TimeOnly)
#endif
- ;
+ )
+ {
+ return true;
+ }
+
+ // Check if type implements IEquatable for its own type
+ // Only treat as primitive-like if:
+ // 1. It's a value type (struct), OR
+ // 2. It's a sealed reference type that's not compiler-generated (e.g., Uri, CultureInfo)
+ // This ensures records and other reference types still use structural comparison
+ var interfaces = type.GetInterfaces();
+ foreach (var iface in interfaces)
+ {
+ if (iface.IsGenericType &&
+ iface.GetGenericTypeDefinition() == typeof(IEquatable<>) &&
+ iface.GetGenericArguments()[0] == type)
+ {
+ // For value types, always use IEquatable
+ if (type.IsValueType)
+ {
+ return true;
+ }
+
+ // For reference types, only use IEquatable for known immutable types
+ // that are safe to compare by value (e.g., Uri, CultureInfo)
+ // Exclude records and other types that might have mutable reference fields
+ if (type.IsSealed && !IsCompilerGeneratedType(type))
+ {
+ return true;
+ }
+
+ break;
+ }
+ }
+
+ return false;
}
}