Skip to content

Commit

Permalink
Treat record structs as records (#2009)
Browse files Browse the repository at this point in the history
Co-authored-by: IT-VBFK <49762557+IT-VBFK@users.noreply.github.com>
  • Loading branch information
salvois and IT-VBFK committed Jan 11, 2023
1 parent dff22b0 commit ebee22a
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 9 deletions.
27 changes: 20 additions & 7 deletions Src/FluentAssertions/Common/TypeExtensions.cs
Expand Up @@ -5,6 +5,7 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using FluentAssertions.Equivalency;

namespace FluentAssertions.Common;
Expand Down Expand Up @@ -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("<Clone>$", 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("<Clone>$", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) is { } &&
type.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)?
.GetMethod?.IsDecoratedWith<CompilerGeneratedAttribute>() == 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<CompilerGeneratedAttribute>() == true;
}

private static bool IsKeyValuePair(Type type)
Expand Down
15 changes: 15 additions & 0 deletions Tests/FluentAssertions.Equivalency.Specs/RecordSpecs.cs
Expand Up @@ -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()
{
Expand Down Expand Up @@ -90,4 +100,9 @@ private record MyRecord

public string[] CollectionProperty { get; init; }
}

private record struct MyRecordStruct(string StringField, string[] CollectionProperty)
{
public string StringField = StringField;
}
}
125 changes: 125 additions & 0 deletions 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;
Expand Down Expand Up @@ -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<int>).IsRecord().Should().BeFalse();
}

private static MethodInfo GetFakeConversionOperator(Type type, string name, BindingFlags bindingAttr, Type returnType)
{
MethodInfo[] methods = type.GetMethods(bindingAttr);
Expand Down Expand Up @@ -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<MyStructWithFakeCompilerGeneratedEquality>
{
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<MyStructWithFakeCompilerGeneratedEqualityAndPrintMembers>
{
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<MyStructWithOverriddenEquality>
{
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; }
}
}
4 changes: 2 additions & 2 deletions docs/_pages/objectgraphs.md
Expand Up @@ -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<T>`, `ComparingByMembers<T>`, `ComparingRecordsByValue` and `ComparingRecordsByMembers` options for individual assertions:

Expand All @@ -48,7 +48,7 @@ subject.Should().BeEquivalentTo(expected,
options => options.ComparingByValue<IPAddress>());
```

For records, this works like this:
For `record`s and `record struct`s this works like this:

```csharp
actual.Should().BeEquivalentTo(expected, options => options
Expand Down
1 change: 1 addition & 0 deletions docs/_pages/releases.md
Expand Up @@ -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
* `PropertyInfoSelector.ThatArePublicOrInternal` now takes the setter into account when determining if a property is `public` or `internal` - [#2082] (https://github.com/fluentassertions/fluentassertions/pull/2082)
Expand Down

0 comments on commit ebee22a

Please sign in to comment.