Skip to content

Fail assertion when path-based Excluding/Including rules are applied on value-semantic types#3167

Closed
Copilot wants to merge 1 commit intomainfrom
copilot/fix-fluentassertions-member-exclusions
Closed

Fail assertion when path-based Excluding/Including rules are applied on value-semantic types#3167
Copilot wants to merge 1 commit intomainfrom
copilot/fix-fluentassertions-member-exclusions

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 1, 2026

When a type overrides Equals, FluentAssertions compares it by value and silently ignores any path-based Excluding()/Including() rules — making the assertion pass or fail for the wrong reason.

Changes

  • SelectMemberByPathSelectionRule — Added abstract MemberPath property to expose the targeted member path from path-based selection rules
  • ExcludeMemberByPathSelectionRule / IncludeMemberByPathSelectionRule — Implement MemberPath
  • ValueTypeEquivalencyStep — When EqualityStrategy.Equals is in effect (auto-detected, not explicitly forced via ComparingByValue<T>()), checks whether any path-based selection rules apply to the current node. If so, fails with a descriptive error

Behavior

var actual = new SomeEquatableClass { Id = 1, Etag = "x" };
var expected = new SomeEquatableClass { Id = 1, Etag = "y" };

// Before: silently ignored Excluding(), compared via Equals(), failed for the wrong reason
// After: fails with a clear message pointing to ComparingByMembers<T>()
actual.Should().BeEquivalentTo(expected, opt => opt.Excluding(o => o.Etag));

// Workaround still works as before:
actual.Should().BeEquivalentTo(expected, opt => opt
    .ComparingByMembers<SomeEquatableClass>()
    .Excluding(o => o.Etag));

Not affected:

  • ForceEquals via explicit ComparingByValue<T>() — user opted in deliberately, no error
  • Predicate-based Excluding(member => ...) — cannot be attributed to a specific type/path
Original prompt

This section details on the original issue you should resolve

<issue_title>Excluding and Including options should fail when applied on types with value semantics</issue_title>
<issue_description>### Description

I am updating the FluentAssertions package on a project from a very old version that would always do member-wise comparisons for Should().BeEquivalentTo(), even if the objects implemented IEquatable<T>. In general, I am ok with the new behavior, but here is a consequence I think is unexpected and undesirable: Explicit member exclusions are being completely ignored.

IMO, FluentAssertions should consider the fact that the implementation of IEquatable<T>.Equals is opaque and hence cannot be assumed to work in any particular way, so it should automatically fall back to perform member-wise comparisons when the assertion is configured with any type of explicit member exclusion.

In practice with the current behavior (which repros on 6.12 and 7.0.0 preview), I am forced to add ComparingByMembers<T>() specifying an explicit T (because it cannot be inferred by the compiler, which adds to the friction) for BeEquivalentTo() call that uses Excluding().

Reproduction Steps

// Paste this in a console app:

using FluentAssertions;
using FluentAssertions.Equivalency;

var actual = new SomeEquatableClass { Id = 1, Etag = "x" };
var expected = new SomeEquatableClass { Id = 1, Etag = "y" };

// This assertion fails, even if only Etag property has different values and it's being explicitly excluded:
actual.Should().BeEquivalentTo(expected, opt => opt.Excluding(o => o.Etag));

public class SomeEquatableClass : IEquatable<SomeEquatableClass>
{
    public int Id { get; set; }
    public string? Etag { get; set; }

    public override bool Equals(object? obj)
    {
        return Equals(obj as SomeEquatableClass);
    }
    public bool Equals(SomeEquatableClass? other)
    {
        if (other == null)
            return false;

        return Id == other.Id && StringComparer.OrdinalIgnoreCase.Equals(Etag, other.Etag);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Id, Etag);
    }
}

Expected behavior

Explicit exclusions should never be ignored, and this assertion should pass:

actual.Should().BeEquivalentTo(expected, opt => opt.Excluding(o => o.Etag));

Actual behavior

Exclusions are ignored so this assertion fails:

actual.Should().BeEquivalentTo(expected, opt => opt.Excluding(o => o.Etag));

Regression?

This is a regression but only from very old FluentAssertions versions like 3.5.0.

Known Workarounds

  • Add a call to ComparingByMembers<T>() whenever Excluding() is also used. This is particularly painful because T has to be specified explicitly:
     actual.Should().BeEquivalentTo(expected, 
         opt => opt.ComparingByMembers<SomeEquatableClass>().Excluding(o => o.Etag));
  • Come up with your own exclusion extension that automates this and leverages type inference, like:
     public static EquivalencyAssertionOptions<TExpectation> ExcludingByMembers<TExpectation>(
         this EquivalencyAssertionOptions<TExpectation> options, 
         params string[] exclusions)
      {
          return options
              .ComparingByMembers<TExpectation>()
              .Excluding(memberInfo => 
                  exclusions.Any(e => memberInfo.Path.Equals(e, StringComparison.OrdinalIgnoreCase)));
      }

Configuration

Repros on FA 6.12 and 7.0.0 preview.
Used to work fine with FA 3.5.0.
Tested on both .NET Framework 4.x and .NET 8.

Other information

Apart from this, the way FluentAssertions now leverages IEquatable<T>.Equals forced us to debug a bunch of incorrect equality evaluation code in our domain classes. While this is ultimately a good thing and I am thankful to FA team for this 😄, it feels like a different concern from what the tests were initially designed to cover. I know that the behavior can be customized, so this is just a comment on the choice of default behavior.

Note: I am a newbie using FluentAssertions so I will be the first to admit that my mental model might be lacking. I apologize if I am being naive or if I missed any discussion in which it was decided that this was by design (I did a search on the repo, but I did not find anything that seemed relevant).

Are you willing to help with a pull-request?

Maybe 😄

I am not sure how long it can take me to understand the FluentAssertions codebase well enough.</issue_description>

Comments on the Issue (you are @copilot in this section)

@dennisdoomen Looking at the example a bit closer, I notice that you also override `Equals`. _That_ is the reason why FA assumes you want it to treat the type as having value semantics. It doesn'...

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Copilot AI changed the title [WIP] Fix behavior of member exclusions in FluentAssertions Fail assertion when path-based Excluding/Including rules are applied on value-semantic types Mar 1, 2026
@dennisdoomen dennisdoomen force-pushed the copilot/fix-fluentassertions-member-exclusions branch from 5dfc522 to e34dcf2 Compare March 28, 2026 18:59
@github-actions
Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Detected 63 dependencies

Third-party software list

This page lists the third-party software dependencies used in FluentAssertions

Dependency Version Licenses
MSTest.Analyzers 3.11.1 MIT
MSTest.TestAdapter 3.11.1 MIT
MSTest.TestFramework 3.11.1 MIT
Microsoft.NET.Test.Sdk 17.3.3 MS-NET-LIBRARY-2019-06
Microsoft.NETCore.Platforms 2.1.0 MIT
Microsoft.NETCore.Targets 1.0.1 MIT
Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
Microsoft.Net.Native.Compiler 2.2.12-rel-31116-00 MS-NET-LIBRARY-2018-11
Microsoft.Net.UWPCoreRuntimeSdk 2.2.14 MS-NET-LIBRARY-2018-11
Newtonsoft.Json 13.0.1 MIT
System.ComponentModel.Primitives 4.1.0 MIT
System.ComponentModel.TypeConverter 4.1.0 MIT
System.Globalization 4.0.11 MIT
System.IO 4.1.0 MIT
System.Private.Uri 4.0.1 MIT
System.Reflection.Primitives 4.0.1 MIT
System.Reflection 4.1.0 MIT
System.Resources.ResourceManager 4.0.1 MIT
System.Runtime.CompilerServices.Unsafe 4.5.3 MIT
System.Runtime.InteropServices.RuntimeInformation 4.0.0 MIT
System.Runtime 4.1.0 MIT
System.Text.Encoding.Extensions 4.0.11 MIT
System.Text.Encoding 4.0.11 MIT
System.Threading.Tasks.Extensions 4.5.4 MIT
System.Threading.Tasks 4.0.11 MIT
System.Threading 4.0.11 MIT
System.Xml.XPath.XmlDocument 4.7.0 MIT
runtime.any.System.Globalization 4.0.11 MIT
runtime.any.System.IO 4.1.0 MIT
runtime.any.System.Reflection.Primitives 4.0.1 MIT
runtime.any.System.Reflection 4.1.0 MIT
runtime.any.System.Resources.ResourceManager 4.0.1 MIT
runtime.any.System.Runtime 4.1.0 MIT
runtime.any.System.Text.Encoding 4.0.11 MIT
runtime.any.System.Threading.Tasks 4.0.11 MIT
runtime.aot.System.Globalization 4.0.11 MIT
runtime.aot.System.IO 4.1.0 MIT
runtime.aot.System.Reflection.Primitives 4.0.0 MIT
runtime.aot.System.Reflection 4.0.10 MIT
runtime.aot.System.Resources.ResourceManager 4.0.0 MIT
runtime.aot.System.Runtime 4.0.20 MIT
runtime.aot.System.Text.Encoding.Extensions 4.0.11 MIT
runtime.aot.System.Text.Encoding 4.0.11 MIT
runtime.aot.System.Threading.Tasks 4.0.11 MIT
runtime.win10-arm-aot.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-arm.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-arm.Microsoft.Net.Native.Compiler 2.2.12-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-arm.Microsoft.Net.Native.SharedLibrary 2.2.8-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-arm.Microsoft.Net.UWPCoreRuntimeSdk 2.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-arm64-aot.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-arm64.Microsoft.Net.Native.Compiler 2.2.12-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-arm64.Microsoft.Net.Native.SharedLibrary 2.2.8-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-x64-aot.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-x64.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-x64.Microsoft.Net.Native.Compiler 2.2.12-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-x64.Microsoft.Net.Native.SharedLibrary 2.2.8-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-x64.Microsoft.Net.UWPCoreRuntimeSdk 2.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-x86-aot.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-x86.Microsoft.NETCore.UniversalWindowsPlatform 6.2.14 MS-NET-LIBRARY-2018-11
runtime.win10-x86.Microsoft.Net.Native.Compiler 2.2.12-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-x86.Microsoft.Net.Native.SharedLibrary 2.2.8-rel-31116-00 MS-NET-LIBRARY-2018-11
runtime.win10-x86.Microsoft.Net.UWPCoreRuntimeSdk 2.2.14 MS-NET-LIBRARY-2018-11
runtime.win7.System.Private.Uri 4.0.1 MIT
Contact Qodana team

Contact us at [email protected]

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 28, 2026

Test Results

    37 files  ± 0      37 suites  ±0   6m 14s ⏱️ -35s
 6 302 tests + 5   6 301 ✅ + 5  1 💤 ±0  0 ❌ ±0 
39 158 runs  +30  39 152 ✅ +30  6 💤 ±0  0 ❌ ±0 

Results for commit e34dcf2. ± Comparison against base commit 27bd2f9.

This pull request removes 10 and adds 13 tests. Note that renamed tests count towards both.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Excluding_a_member_by_path_and_then_forcing_member_comparison_should_not_fail
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Excluding_a_member_by_path_on_a_type_with_value_semantics_should_fail_with_a_descriptive_error
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Excluding_a_member_by_path_when_forcing_value_semantics_explicitly_should_not_fail
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Excluding_a_member_by_predicate_on_a_type_with_value_semantics_should_not_fail
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Including ‑ Including_a_member_by_path_on_a_type_with_value_semantics_should_fail_with_a_descriptive_error
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
…

♻️ This comment has been updated with latest results.

@coveralls
Copy link
Copy Markdown

coveralls commented Mar 28, 2026

Pull Request Test Coverage Report for Build 23692109001

Details

  • 23 of 23 (100.0%) changed or added relevant lines in 3 files are covered.
  • 9 unchanged lines in 2 files lost coverage.
  • Overall coverage decreased (-0.04%) to 97.162%

Files with Coverage Reduction New Missed Lines %
Src/FluentAssertions/AssertionExtensions.cs 4 94.74%
Src/FluentAssertions/Collections/GenericCollectionAssertions.cs 5 99.17%
Totals Coverage Status
Change from base Build 23614882058: -0.04%
Covered Lines: 12985
Relevant Lines: 13209

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Excluding and Including options should fail when applied on types with value semantics

3 participants