diff --git a/Src/FluentAssertions/Common/TypeExtensions.cs b/Src/FluentAssertions/Common/TypeExtensions.cs index 25d3a58931..a913bd83c3 100644 --- a/Src/FluentAssertions/Common/TypeExtensions.cs +++ b/Src/FluentAssertions/Common/TypeExtensions.cs @@ -15,9 +15,6 @@ internal static class TypeExtensions private const BindingFlags PublicInstanceMembersFlag = BindingFlags.Public | BindingFlags.Instance; - private const BindingFlags AllInstanceMembersFlag = - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - private const BindingFlags AllStaticAndInstanceMembersFlag = PublicInstanceMembersFlag | BindingFlags.NonPublic | BindingFlags.Static; @@ -176,24 +173,21 @@ public static bool OverridesEquals(this Type type) } /// - /// Finds the property by a case-sensitive name. + /// Finds the property by a case-sensitive name and with a certain visibility. /// + /// + /// If both a normal property and one that was implemented through an explicit interface implementation with the same name exist, + /// then the normal property will be returned. + /// /// /// Returns if no such property exists. /// - public static PropertyInfo FindProperty(this Type type, string propertyName) + public static PropertyInfo FindProperty(this Type type, string propertyName, MemberVisibility memberVisibility) { - while (type != typeof(object)) - { - if (type.GetProperty(propertyName, AllInstanceMembersFlag | BindingFlags.DeclaredOnly) is { } property) - { - return property; - } - - type = type.BaseType; - } + var properties = type.GetNonPrivateProperties(memberVisibility); - return null; + return Array.Find(properties, p => + p.Name == propertyName || p.Name.EndsWith("." + propertyName, StringComparison.OrdinalIgnoreCase)); } /// @@ -202,19 +196,12 @@ public static PropertyInfo FindProperty(this Type type, string propertyName) /// /// Returns if no such property exists. /// - public static FieldInfo FindField(this Type type, string fieldName) + public static FieldInfo FindField(this Type type, string fieldName, MemberVisibility memberVisibility) { - while (type != typeof(object)) - { - if (type.GetField(fieldName, AllInstanceMembersFlag | BindingFlags.DeclaredOnly) is { } field) - { - return field; - } + var fields = type.GetNonPrivateFields(memberVisibility); - type = type.BaseType; - } - - return null; + return Array.Find(fields, p => + p.Name == fieldName || p.Name.EndsWith("." + fieldName, StringComparison.OrdinalIgnoreCase)); } public static MemberInfo[] GetNonPrivateMembers(this Type typeToReflect, MemberVisibility visibility) @@ -318,8 +305,11 @@ public static bool IsIndexer(this PropertyInfo member) public static ConstructorInfo GetConstructor(this Type type, IEnumerable parameterTypes) { + const BindingFlags allInstanceMembersFlag = + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + return type - .GetConstructors(AllInstanceMembersFlag) + .GetConstructors(allInstanceMembersFlag) .SingleOrDefault(m => m.GetParameters().Select(p => p.ParameterType).SequenceEqual(parameterTypes)); } diff --git a/Src/FluentAssertions/Common/TypeMemberReflector.cs b/Src/FluentAssertions/Common/TypeMemberReflector.cs index 1706e4e32e..b38d49b306 100644 --- a/Src/FluentAssertions/Common/TypeMemberReflector.cs +++ b/Src/FluentAssertions/Common/TypeMemberReflector.cs @@ -31,27 +31,39 @@ private static PropertyInfo[] LoadNonPrivateProperties(Type typeToReflect, Membe { IEnumerable query = from propertyInfo in GetPropertiesFromHierarchy(typeToReflect, visibility) - where HasNonPrivateGetter(propertyInfo) + where HasNonPrivateGetter(propertyInfo, visibility) where !propertyInfo.IsIndexer() select propertyInfo; return query.ToArray(); } - private static List GetPropertiesFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) + private static IEnumerable GetPropertiesFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) { - bool includeInternals = memberVisibility.HasFlag(MemberVisibility.Internal); + bool includeInternal = memberVisibility.HasFlag(MemberVisibility.Internal); return GetMembersFromHierarchy(typeToReflect, type => { return type .GetProperties(AllInstanceMembersFlag | BindingFlags.DeclaredOnly) - .Where(property => property.GetMethod?.IsPrivate == false) - .Where(property => includeInternals || property.GetMethod is { IsAssembly: false, IsFamilyOrAssembly: false }) + .Where(property => includeInternal || !IsInternal(property)) + .OrderBy(property => IsExplicitImplementation(property)) .ToArray(); }); } + private static bool IsInternal(PropertyInfo property) + { + return property.GetMethod is { IsAssembly: true } or { IsFamilyOrAssembly: true }; + } + + private static bool IsExplicitImplementation(PropertyInfo property) + { + return property.GetMethod?.IsPrivate == true && + property.SetMethod?.IsPrivate != false && + property.Name.Contains('.', StringComparison.Ordinal); + } + private static FieldInfo[] LoadNonPrivateFields(Type typeToReflect, MemberVisibility visibility) { IEnumerable query = @@ -63,21 +75,26 @@ from fieldInfo in GetFieldsFromHierarchy(typeToReflect, visibility) return query.ToArray(); } - private static List GetFieldsFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) + private static IEnumerable GetFieldsFromHierarchy(Type typeToReflect, MemberVisibility memberVisibility) { - bool includeInternals = memberVisibility.HasFlag(MemberVisibility.Internal); + bool includeInternal = memberVisibility.HasFlag(MemberVisibility.Internal); return GetMembersFromHierarchy(typeToReflect, type => { return type .GetFields(AllInstanceMembersFlag) .Where(field => !field.IsPrivate) - .Where(field => includeInternals || (!field.IsAssembly && !field.IsFamilyOrAssembly)) + .Where(field => includeInternal || !IsInternal(field)) .ToArray(); }); } - private static List GetMembersFromHierarchy( + private static bool IsInternal(FieldInfo field) + { + return field.IsAssembly || field.IsFamilyOrAssembly; + } + + private static IEnumerable GetMembersFromHierarchy( Type typeToReflect, Func> getMembers) where TMemberInfo : MemberInfo @@ -90,7 +107,7 @@ private static List GetMembersFromHierarchy( return GetClassMembers(typeToReflect, getMembers); } - private static List GetInterfaceMembers(Type typeToReflect, + private static IEnumerable GetInterfaceMembers(Type typeToReflect, Func> getMembers) where TMemberInfo : MemberInfo { @@ -126,7 +143,7 @@ private static List GetInterfaceMembers(Type typeToRef return members; } - private static List GetClassMembers(Type typeToReflect, + private static TMemberInfo[] GetClassMembers(Type typeToReflect, Func> getMembers) where TMemberInfo : MemberInfo { @@ -145,12 +162,18 @@ private static List GetClassMembers(Type typeToReflect typeToReflect = typeToReflect.BaseType; } - return members; + return members.ToArray(); } - private static bool HasNonPrivateGetter(PropertyInfo propertyInfo) + private static bool HasNonPrivateGetter(PropertyInfo propertyInfo, MemberVisibility visibility) { MethodInfo getMethod = propertyInfo.GetGetMethod(nonPublic: true); + + if (visibility.HasFlag(MemberVisibility.ExplicitlyImplemented)) + { + return getMethod is { IsPrivate: false, IsFamily: false } or { IsPrivate: true, IsFinal: true }; + } + return getMethod is { IsPrivate: false, IsFamily: false }; } } diff --git a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs index e06f7ea6cd..62a68d4fb9 100644 --- a/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs +++ b/Src/FluentAssertions/Equivalency/Matching/MustMatchByNameRule.cs @@ -15,19 +15,20 @@ public IMember Match(IMember expectedMember, object subject, INode parent, IEqui if (options.IncludedProperties != MemberVisibility.None) { - PropertyInfo propertyInfo = subject.GetType().FindProperty(expectedMember.Name); + PropertyInfo propertyInfo = subject.GetType().FindProperty( + expectedMember.Name, + options.IncludedProperties | MemberVisibility.ExplicitlyImplemented); + subjectMember = propertyInfo is not null && !propertyInfo.IsIndexer() ? new Property(propertyInfo, parent) : null; } if (subjectMember is null && options.IncludedFields != MemberVisibility.None) { - FieldInfo fieldInfo = subject.GetType().FindField(expectedMember.Name); - subjectMember = fieldInfo is not null ? new Field(fieldInfo, parent) : null; - } + FieldInfo fieldInfo = subject.GetType().FindField( + expectedMember.Name, + options.IncludedFields); - if ((subjectMember is null || !options.UseRuntimeTyping) && ExpectationImplementsMemberExplicitly(subject, expectedMember)) - { - subjectMember = expectedMember; + subjectMember = fieldInfo is not null ? new Field(fieldInfo, parent) : null; } if (subjectMember is null) @@ -49,11 +50,6 @@ public IMember Match(IMember expectedMember, object subject, INode parent, IEqui return subjectMember; } - private static bool ExpectationImplementsMemberExplicitly(object expectation, IMember subjectMember) - { - return subjectMember.DeclaringType.IsInstanceOfType(expectation); - } - /// /// 2 public override string ToString() diff --git a/Src/FluentAssertions/Equivalency/Matching/TryMatchByNameRule.cs b/Src/FluentAssertions/Equivalency/Matching/TryMatchByNameRule.cs index e44c0e6779..36f817acb8 100644 --- a/Src/FluentAssertions/Equivalency/Matching/TryMatchByNameRule.cs +++ b/Src/FluentAssertions/Equivalency/Matching/TryMatchByNameRule.cs @@ -10,14 +10,20 @@ internal class TryMatchByNameRule : IMemberMatchingRule { public IMember Match(IMember expectedMember, object subject, INode parent, IEquivalencyAssertionOptions options) { - PropertyInfo property = subject.GetType().FindProperty(expectedMember.Name); - - if (property is not null && !property.IsIndexer()) + if (options.IncludedProperties != MemberVisibility.None) { - return new Property(property, parent); + PropertyInfo property = subject.GetType().FindProperty(expectedMember.Name, + options.IncludedProperties | MemberVisibility.ExplicitlyImplemented); + + if (property is not null && !property.IsIndexer()) + { + return new Property(property, parent); + } } - FieldInfo field = subject.GetType().FindField(expectedMember.Name); + FieldInfo field = subject.GetType() + .FindField(expectedMember.Name, options.IncludedFields); + return field is not null ? new Field(field, parent) : null; } diff --git a/Src/FluentAssertions/Equivalency/MemberFactory.cs b/Src/FluentAssertions/Equivalency/MemberFactory.cs index 7f12299e4d..6fed0af560 100644 --- a/Src/FluentAssertions/Equivalency/MemberFactory.cs +++ b/Src/FluentAssertions/Equivalency/MemberFactory.cs @@ -18,14 +18,14 @@ public static IMember Create(MemberInfo memberInfo, INode parent) internal static IMember Find(object target, string memberName, INode parent) { - PropertyInfo property = target.GetType().FindProperty(memberName); + PropertyInfo property = target.GetType().FindProperty(memberName, MemberVisibility.Public | MemberVisibility.ExplicitlyImplemented); if (property is not null && !property.IsIndexer()) { return new Property(property, parent); } - FieldInfo field = target.GetType().FindField(memberName); + FieldInfo field = target.GetType().FindField(memberName, MemberVisibility.Public); return field is not null ? new Field(field, parent) : null; } } diff --git a/Src/FluentAssertions/Equivalency/MemberVisibility.cs b/Src/FluentAssertions/Equivalency/MemberVisibility.cs index fd341a0346..e64b798a81 100644 --- a/Src/FluentAssertions/Equivalency/MemberVisibility.cs +++ b/Src/FluentAssertions/Equivalency/MemberVisibility.cs @@ -11,5 +11,6 @@ public enum MemberVisibility { None = 0, Internal = 1, - Public = 2 + Public = 2, + ExplicitlyImplemented = 4 } diff --git a/Tests/Approval.Tests/ApiApproval.cs b/Tests/Approval.Tests/ApiApproval.cs index a1f0bbd9ca..c9b3726f6f 100644 --- a/Tests/Approval.Tests/ApiApproval.cs +++ b/Tests/Approval.Tests/ApiApproval.cs @@ -28,10 +28,12 @@ public Task ApproveApi(string frameworkVersion) string assemblyPath = Uri.UnescapeDataString(uri.Path); var containingDirectory = Path.GetDirectoryName(assemblyPath); var configurationName = new DirectoryInfo(containingDirectory).Parent.Name; + var assemblyFile = Path.GetFullPath( Path.Combine( GetSourceDirectory(), - Path.Combine("..", "..", "Src", "FluentAssertions", "bin", configurationName, frameworkVersion, "FluentAssertions.dll"))); + Path.Combine("..", "..", "Src", "FluentAssertions", "bin", configurationName, frameworkVersion, + "FluentAssertions.dll"))); var assembly = Assembly.LoadFile(Path.GetFullPath(assemblyFile)); var publicApi = assembly.GeneratePublicApi(options: null); @@ -53,6 +55,7 @@ public static Task OnlyIncludeChanges(string received, string ver var diff = InlineDiffBuilder.Diff(verified, received); var builder = new StringBuilder(); + foreach (var line in diff.Lines) { switch (line.Type) @@ -79,9 +82,12 @@ private class TargetFrameworksTheoryData : TheoryData { public TargetFrameworksTheoryData() { - var csproj = Path.Combine(GetSourceDirectory(), Path.Combine("..", "..", "Src", "FluentAssertions", "FluentAssertions.csproj")); + var csproj = Path.Combine(GetSourceDirectory(), + Path.Combine("..", "..", "Src", "FluentAssertions", "FluentAssertions.csproj")); + var project = XDocument.Load(csproj); var targetFrameworks = project.XPathSelectElement("/Project/PropertyGroup/TargetFrameworks"); + foreach (string targetFramework in targetFrameworks!.Value.Split(';')) { Add(targetFramework); diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 377122900a..89da67fff2 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -974,6 +974,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index fa1f44fa66..0db0cc0251 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -987,6 +987,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt index 63d5985ea5..99eb81a1e3 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt @@ -974,6 +974,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt index 63d5985ea5..99eb81a1e3 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt @@ -974,6 +974,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index 33bdb75873..d14c1711b3 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -967,6 +967,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index 63d5985ea5..99eb81a1e3 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -974,6 +974,7 @@ namespace FluentAssertions.Equivalency None = 0, Internal = 1, Public = 2, + ExplicitlyImplemented = 4, } public class NestedExclusionOptionBuilder { diff --git a/Tests/FluentAssertions.Equivalency.Specs/MemberMatchingSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/MemberMatchingSpecs.cs index 7808394542..231c68de0a 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/MemberMatchingSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/MemberMatchingSpecs.cs @@ -62,8 +62,8 @@ public void Nested_properties_can_be_mapped_using_a_nested_expression() subject.Should() .BeEquivalentTo(expectation, opt => opt .WithMapping( - e => e.Parent[0].Property2, - s => s.Parent[0].Property1)); + e => e.Children[0].Property2, + s => s.Children[0].Property1)); } [Fact] @@ -83,6 +83,25 @@ public void Nested_properties_can_be_mapped_using_a_nested_type_and_property_nam .WithMapping("Property2", "Property1")); } + [Fact] + public void Nested_explicitly_implemented_properties_can_be_mapped_using_a_nested_type_and_property_names() + { + // Arrange + var subject = new ParentOfSubjectWithExplicitlyImplementedProperty(new[] { new SubjectWithExplicitImplementedProperty() }); + + ((IProperty)subject.Children[0]).Property = "Hello"; + + var expectation = new ParentOfExpectationWithProperty2(new[] + { + new ExpectationWithProperty2 { Property2 = "Hello" } + }); + + // Act / Assert + subject.Should() + .BeEquivalentTo(expectation, opt => opt + .WithMapping("Property2", "Property")); + } + [Fact] public void Nested_fields_can_be_mapped_using_a_nested_type_and_field_names() { @@ -144,6 +163,25 @@ public void Properties_can_be_mapped_by_name() .WithMapping("Property2", "Property1")); } + [Fact] + public void Properties_can_be_mapped_by_name_to_an_explicitly_implemented_property() + { + // Arrange + var subject = new SubjectWithExplicitImplementedProperty(); + + ((IProperty)subject).Property = "Hello"; + + var expectation = new ExpectationWithProperty2 + { + Property2 = "Hello" + }; + + // Act / Assert + subject.Should() + .BeEquivalentTo(expectation, opt => opt + .WithMapping("Property2", "Property")); + } + [Fact] public void Fields_can_be_mapped_by_name() { @@ -515,21 +553,31 @@ private class EntityDto internal class ParentOfExpectationWithProperty2 { - public ExpectationWithProperty2[] Parent { get; } + public ExpectationWithProperty2[] Children { get; } - public ParentOfExpectationWithProperty2(ExpectationWithProperty2[] parent) + public ParentOfExpectationWithProperty2(ExpectationWithProperty2[] children) { - Parent = parent; + Children = children; } } internal class ParentOfSubjectWithProperty1 { - public SubjectWithProperty1[] Parent { get; } + public SubjectWithProperty1[] Children { get; } + + public ParentOfSubjectWithProperty1(SubjectWithProperty1[] children) + { + Children = children; + } + } + + internal class ParentOfSubjectWithExplicitlyImplementedProperty + { + public SubjectWithExplicitImplementedProperty[] Children { get; } - public ParentOfSubjectWithProperty1(SubjectWithProperty1[] parent) + public ParentOfSubjectWithExplicitlyImplementedProperty(SubjectWithExplicitImplementedProperty[] children) { - Parent = parent; + Children = children; } } @@ -538,6 +586,16 @@ internal class SubjectWithProperty1 public string Property1 { get; set; } } + internal class SubjectWithExplicitImplementedProperty : IProperty + { + string IProperty.Property { get; set; } + } + + internal interface IProperty + { + string Property { get; set; } + } + internal class ExpectationWithProperty2 { public string Property2 { get; set; } diff --git a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs index eb906877de..cc9b070add 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.cs @@ -1763,7 +1763,7 @@ public void When_a_reference_to_an_explicit_interface_impl_is_provided_it_should } [Fact] - public void When_respecting_declared_types_explicit_interface_member_on_interfaced_subject_should_be_used() + public void Explicitly_implemented_subject_properties_are_ignored_if_a_normal_property_exists_with_the_same_name() { // Arrange IVehicle expected = new Vehicle @@ -1773,122 +1773,118 @@ public void When_respecting_declared_types_explicit_interface_member_on_interfac IVehicle subject = new ExplicitVehicle { - VehicleId = 2 // instance member + VehicleId = 2 // normal property }; - subject.VehicleId = 1; // interface member + subject.VehicleId = 1; // explicitly implemented property // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingDeclaredTypes()); + Action action = () => subject.Should().BeEquivalentTo(expected); // Assert - action.Should().NotThrow(); + action.Should().Throw(); } [Fact] - public void When_respecting_declared_types_explicit_interface_member_on_interfaced_expectation_should_be_used() + public void Explicitly_implemented_read_only_subject_properties_are_ignored_if_a_normal_property_exists_with_the_same_name() { // Arrange - IVehicle expected = new ExplicitVehicle + IReadOnlyVehicle subject = new ExplicitReadOnlyVehicle(explicitValue: 1) { - VehicleId = 2 // instance member + VehicleId = 2 // normal property }; - expected.VehicleId = 1; // interface member - - IVehicle subject = new Vehicle + var expected = new Vehicle { VehicleId = 1 }; // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingDeclaredTypes()); + Action action = () => subject.Should().BeEquivalentTo(expected); // Assert - action.Should().NotThrow(); + action.Should().Throw(); } [Fact] - public void When_respecting_runtime_types_explicit_interface_member_on_interfaced_subject_should_not_be_used() + public void Explicitly_implemented_subject_properties_are_ignored_if_only_fields_are_included() { // Arrange - IVehicle expected = new Vehicle + var expected = new VehicleWithField { - VehicleId = 1 + VehicleId = 1 // A field named like a property }; - IVehicle subject = new ExplicitVehicle + var subject = new ExplicitVehicle { - VehicleId = 2 // instance member + VehicleId = 2 // A real property }; - subject.VehicleId = 1; // interface member - // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingRuntimeTypes()); + Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt + .IncludingFields() + .ExcludingProperties()); // Assert - action.Should().Throw(); + action.Should().Throw().WithMessage("*field*VehicleId*other*"); } [Fact] - public void When_respecting_runtime_types_explicit_interface_member_on_interfaced_expectation_should_not_be_used() + public void Explicitly_implemented_subject_properties_are_ignored_if_only_fields_are_included_and_they_may_be_missing() { // Arrange - IVehicle expected = new ExplicitVehicle + var expected = new VehicleWithField { - VehicleId = 2 // instance member + VehicleId = 1 // A field named like a property }; - expected.VehicleId = 1; // interface member - - IVehicle subject = new Vehicle + var subject = new ExplicitVehicle { - VehicleId = 1 + VehicleId = 2 // A real property }; - // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingRuntimeTypes()); - - // Assert - action.Should().Throw(); + // Act / Assert + subject.Should().BeEquivalentTo(expected, opt => opt + .IncludingFields() + .ExcludingProperties() + .ExcludingMissingMembers()); } [Fact] - public void When_respecting_declared_types_explicit_interface_member_on_subject_should_not_be_used() + public void Excluding_missing_members_does_not_affect_how_explicitly_implemented_subject_properties_are_dealt_with() { // Arrange - var expected = new Vehicle + IVehicle expected = new Vehicle { VehicleId = 1 }; - var subject = new ExplicitVehicle + IVehicle subject = new ExplicitVehicle { - VehicleId = 2 + VehicleId = 2 // instance member }; - ((IVehicle)subject).VehicleId = 1; + subject.VehicleId = 1; // interface member // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingDeclaredTypes()); + Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.ExcludingMissingMembers()); // Assert action.Should().Throw(); } [Fact] - public void When_respecting_declared_types_explicit_interface_member_on_expectation_should_not_be_used() + public void When_respecting_declared_types_explicit_interface_member_on_interfaced_expectation_should_be_used() { // Arrange - var expected = new ExplicitVehicle + IVehicle expected = new ExplicitVehicle { - VehicleId = 2 + VehicleId = 2 // instance member }; - ((IVehicle)expected).VehicleId = 1; + expected.VehicleId = 1; // interface member - var subject = new Vehicle + IVehicle subject = new Vehicle { VehicleId = 1 }; @@ -1897,24 +1893,24 @@ public void When_respecting_declared_types_explicit_interface_member_on_expectat Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingDeclaredTypes()); // Assert - action.Should().Throw(); + action.Should().NotThrow(); } [Fact] - public void When_respecting_runtime_types_explicit_interface_member_on_subject_should_not_be_used() + public void When_respecting_runtime_types_explicit_interface_member_on_interfaced_subject_should_not_be_used() { // Arrange - var expected = new Vehicle + IVehicle expected = new Vehicle { VehicleId = 1 }; - var subject = new ExplicitVehicle + IVehicle subject = new ExplicitVehicle { - VehicleId = 2 + VehicleId = 2 // instance member }; - ((IVehicle)subject).VehicleId = 1; + subject.VehicleId = 1; // interface member // Act Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingRuntimeTypes()); @@ -1924,7 +1920,30 @@ public void When_respecting_runtime_types_explicit_interface_member_on_subject_s } [Fact] - public void When_respecting_runtime_types_explicit_interface_member_on_expectation_should_not_be_used() + public void When_respecting_runtime_types_explicit_interface_member_on_interfaced_expectation_should_not_be_used() + { + // Arrange + IVehicle expected = new ExplicitVehicle + { + VehicleId = 2 // instance member + }; + + expected.VehicleId = 1; // interface member + + IVehicle subject = new Vehicle + { + VehicleId = 1 + }; + + // Act + Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingRuntimeTypes()); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void When_respecting_declared_types_explicit_interface_member_on_expectation_should_not_be_used() { // Arrange var expected = new ExplicitVehicle @@ -1940,12 +1959,33 @@ public void When_respecting_runtime_types_explicit_interface_member_on_expectati }; // Act - Action action = () => subject.Should().BeEquivalentTo(expected, opt => opt.RespectingRuntimeTypes()); + Action action = () => subject.Should().BeEquivalentTo(expected); // Assert action.Should().Throw(); } + [Fact] + public void Can_find_explicitly_implemented_property_on_the_subject() + { + // Arrange + IPerson person = new Person(); + person.Name = "Bob"; + + // Act / Assert + person.Should().BeEquivalentTo(new { Name = "Bob" }); + } + + private interface IPerson + { + string Name { get; set; } + } + + private class Person : IPerson + { + string IPerson.Name { get; set; } + } + [Fact] public void Excluding_an_interface_property_through_inheritance_should_work() { diff --git a/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs b/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs index 1ab9c89f23..b731e54c1f 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/TestTypes.cs @@ -616,6 +616,11 @@ public class Vehicle : IVehicle public int VehicleId { get; set; } } +public class VehicleWithField +{ + public int VehicleId; +} + public class ExplicitVehicle : IVehicle { int IVehicle.VehicleId { get; set; } @@ -623,6 +628,25 @@ public class ExplicitVehicle : IVehicle public int VehicleId { get; set; } } +public interface IReadOnlyVehicle +{ + int VehicleId { get; } +} + +public class ExplicitReadOnlyVehicle : IReadOnlyVehicle +{ + private readonly int explicitValue; + + public ExplicitReadOnlyVehicle(int explicitValue) + { + this.explicitValue = explicitValue; + } + + int IReadOnlyVehicle.VehicleId => explicitValue; + + public int VehicleId { get; set; } +} + public interface ICar : IVehicle { int Wheels { get; set; } diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 8de6c94720..c983fdb1d3 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -16,6 +16,7 @@ sidebar: ### Fixes * `because` and `becauseArgs` were not included in the error message when collections of enums were not equivalent - [#2214](https://github.com/fluentassertions/fluentassertions/pull/2214) +* `BeEquivalentTo` will now find and can map subject properties that are implemented through an explicitly-implemented interface - [2152](https://github.com/fluentassertions/fluentassertions/pull/2152) * Improve caller identification for tests written in Visual Basic - [#2254](https://github.com/fluentassertions/fluentassertions/pull/2254) * Improved auto conversion to enums for objects of different integral type - [#2261](https://github.com/fluentassertions/fluentassertions/pull/2261) * Fixed exceptions when trying to auto convert strings or enums of different type to enums- [#2261](https://github.com/fluentassertions/fluentassertions/pull/2261)