diff --git a/Src/FluentAssertions/Common/TypeExtensions.cs b/Src/FluentAssertions/Common/TypeExtensions.cs index d221b482a2..0559e7dc7d 100644 --- a/Src/FluentAssertions/Common/TypeExtensions.cs +++ b/Src/FluentAssertions/Common/TypeExtensions.cs @@ -5,6 +5,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; +using System.Text; using FluentAssertions.Equivalency; namespace FluentAssertions.Common; @@ -587,13 +588,25 @@ private static bool IsAnonymousType(this Type type) public static bool IsRecord(this Type type) { - return TypeIsRecordCache.GetOrAdd(type, static t => - { - return t.GetMethod("$", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) is { } && - t.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)? - .GetMethod? - .GetCustomAttribute(typeof(CompilerGeneratedAttribute)) is { }; - }); + return TypeIsRecordCache.GetOrAdd(type, static t => t.IsRecordClass() || t.IsRecordStruct()); + } + + private static bool IsRecordClass(this Type type) + { + return type.GetMethod("$", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) is { } && + type.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)? + .GetMethod?.IsDecoratedWith() == true; + } + + private static bool IsRecordStruct(this Type type) + { + // As noted here: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#open-questions + // recognizing record structs from metadata is an open point. The following check is based on common sense + // and heuristic testing, apparently giving good results but not supported by official documentation. + return type.BaseType == typeof(ValueType) && + type.GetMethod("PrintMembers", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { typeof(StringBuilder) }, null) is { } && + type.GetMethod("op_Equality", BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly, null, new[] { type, type }, null)? + .IsDecoratedWith() == true; } private static bool IsKeyValuePair(Type type) diff --git a/Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs index d7931e25df..9ce6473ae1 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs @@ -16,6 +16,16 @@ public void When_the_subject_is_a_record_it_should_compare_it_by_its_members() actual.Should().BeEquivalentTo(expected); } + [Fact] + public void When_the_subject_is_a_record_struct_it_should_compare_it_by_its_members() + { + var actual = new MyRecordStruct("foo", new[] { "bar", "zip", "foo" }); + + var expected = new MyRecordStruct("foo", new[] { "bar", "zip", "foo" }); + + actual.Should().BeEquivalentTo(expected); + } + [Fact] public void When_the_subject_is_a_record_it_should_mention_that_in_the_configuration_output() { @@ -90,4 +100,9 @@ private record MyRecord public string[] CollectionProperty { get; init; } } + + private record struct MyRecordStruct(string StringField, string[] CollectionProperty) + { + public string StringField = StringField; + } } diff --git a/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs b/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs index 382d15fa5d..bb95403d3d 100644 --- a/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs +++ b/Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Linq; using System.Reflection; using FluentAssertions.Common; @@ -124,6 +125,42 @@ public void When_getting_fake_implicit_conversion_operator_from_a_type_with_fake result.Should().NotBeNull(); } + [Theory] + [InlineData(typeof(MyRecord), true)] + [InlineData(typeof(MyRecordStruct), true)] + [InlineData(typeof(MyRecordStructWithCustomPrintMembers), true)] + [InlineData(typeof(MyRecordStructWithOverriddenEquality), true)] + [InlineData(typeof(MyReadonlyRecordStruct), true)] + [InlineData(typeof(MyStruct), false)] + [InlineData(typeof(MyStructWithFakeCompilerGeneratedEquality), false)] + [InlineData(typeof(MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers), true)] // false positive! + [InlineData(typeof(MyStructWithOverriddenEquality), false)] + [InlineData(typeof(MyClass), false)] + [InlineData(typeof(int), false)] + [InlineData(typeof(string), false)] + public void IsRecord_should_detect_records_correctly(Type type, bool expected) + { + type.IsRecord().Should().Be(expected); + } + + [Fact] + public void When_checking_if_anonymous_type_is_record_it_should_return_false() + { + new { Value = 42 }.GetType().IsRecord().Should().BeFalse(); + } + + [Fact] + public void When_checking_if_value_tuple_is_record_it_should_return_false() + { + (42, "the answer").GetType().IsRecord().Should().BeFalse(); + } + + [Fact] + public void When_checking_if_class_with_multiple_equality_methods_is_record_it_should_return_false() + { + typeof(ImmutableArray).IsRecord().Should().BeFalse(); + } + private static MethodInfo GetFakeConversionOperator(Type type, string name, BindingFlags bindingAttr, Type returnType) { MethodInfo[] methods = type.GetMethods(bindingAttr); @@ -153,4 +190,92 @@ private TypeWithFakeConversionOperators(int value) public static byte op_Explicit(TypeWithFakeConversionOperators typeWithFakeConversionOperators) => (byte)typeWithFakeConversionOperators.value; #pragma warning restore SA1300, IDE1006 } + + private record MyRecord(int Value); + + private record struct MyRecordStruct(int Value); + + private record struct MyRecordStructWithCustomPrintMembers(int Value) + { + private bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append(Value); + return true; + } + } + + private record struct MyRecordStructWithOverriddenEquality(int Value) + { + public bool Equals(MyRecordStructWithOverriddenEquality other) => Value == other.Value; + + public override int GetHashCode() => Value; + } + + private readonly record struct MyReadonlyRecordStruct(int Value); + + private struct MyStruct + { + public int Value { get; set; } + } + + private struct MyStructWithFakeCompilerGeneratedEquality : IEquatable + { + public int Value { get; set; } + + public bool Equals(MyStructWithFakeCompilerGeneratedEquality other) => Value == other.Value; + + public override bool Equals(object obj) => obj is MyStructWithFakeCompilerGeneratedEquality other && Equals(other); + + public override int GetHashCode() => Value; + + [System.Runtime.CompilerServices.CompilerGenerated] + public static bool operator ==(MyStructWithFakeCompilerGeneratedEquality left, MyStructWithFakeCompilerGeneratedEquality right) => left.Equals(right); + + public static bool operator !=(MyStructWithFakeCompilerGeneratedEquality left, MyStructWithFakeCompilerGeneratedEquality right) => !left.Equals(right); + } + + // Note that this struct is mistakenly detected as a record struct by the current version of TypeExtensions.IsRecord. + // This cannot be avoided at present, unless something is changed at language level, + // or a smarter way to check for record structs is found. + private struct MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers : IEquatable + { + public int Value { get; set; } + + public bool Equals(MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers other) => Value == other.Value; + + public override bool Equals(object obj) => obj is MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers other && Equals(other); + + public override int GetHashCode() => Value; + + [System.Runtime.CompilerServices.CompilerGenerated] + public static bool operator ==(MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers left, MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers right) => left.Equals(right); + + public static bool operator !=(MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers left, MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers right) => !left.Equals(right); + + private bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append(Value); + return true; + } + } + + private struct MyStructWithOverriddenEquality : IEquatable + { + public int Value { get; set; } + + public bool Equals(MyStructWithOverriddenEquality other) => Value == other.Value; + + public override bool Equals(object obj) => obj is MyStructWithOverriddenEquality other && Equals(other); + + public override int GetHashCode() => Value; + + public static bool operator ==(MyStructWithOverriddenEquality left, MyStructWithOverriddenEquality right) => left.Equals(right); + + public static bool operator !=(MyStructWithOverriddenEquality left, MyStructWithOverriddenEquality right) => !left.Equals(right); + } + + private class MyClass + { + public int Value { get; set; } + } } diff --git a/docs/_pages/objectgraphs.md b/docs/_pages/objectgraphs.md index fc37bf3496..37e5b5bb7a 100644 --- a/docs/_pages/objectgraphs.md +++ b/docs/_pages/objectgraphs.md @@ -39,7 +39,7 @@ orderDto.Should().BeEquivalentTo(order, options => ### Value Types -To determine whether Fluent Assertions should recurs into an object's properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides `Object.Equals` as an object that was designed to have value semantics. Anonymous types, records and tuples also override this method, but because the community proved us that they use them quite often in equivalency comparisons, we decided to always compare them by their members. +To determine whether Fluent Assertions should recurs into an object's properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides `Object.Equals` as an object that was designed to have value semantics. Anonymous types, `record`s, `record struct`s and tuples also override this method, but because the community proved us that they use them quite often in equivalency comparisons, we decided to always compare them by their members. You can easily override this by using the `ComparingByValue`, `ComparingByMembers`, `ComparingRecordsByValue` and `ComparingRecordsByMembers` options for individual assertions: @@ -48,7 +48,7 @@ subject.Should().BeEquivalentTo(expected, options => options.ComparingByValue()); ``` -For records, this works like this: +For `record`s and `record struct`s this works like this: ```csharp actual.Should().BeEquivalentTo(expected, options => options diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 7996def235..ec078bd03f 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -20,6 +20,7 @@ sidebar: * Added `BeOneOf` methods for object comparisons and `IComparable`s - [#2028](https://github.com/fluentassertions/fluentassertions/pull/2028) * Added `BeCloseTo` and `NotBeCloseTo` to `TimeOnly` - [#2030](https://github.com/fluentassertions/fluentassertions/pull/2030) * Added new extension methods to be able to write `Exactly.Times(n)`, `AtLeast.Times(n)` and `AtMost.Times(n)` in a more fluent way - [#2047](https://github.com/fluentassertions/fluentassertions/pull/2047) +* Changed `BeEquivalentTo` to treat record structs like records, thus comparing them by member by default - [#2009](https://github.com/fluentassertions/fluentassertions/pull/2009) ### Fixes * Quering properties on classes, e.g. `typeof(MyClass).Properties()`, now also includes static properties - [#2054](https://github.com/fluentassertions/fluentassertions/pull/2054)