Skip to content

Fail with a descriptive error when path-based rules are used on value-semantic types#3187

Open
dennisdoomen wants to merge 4 commits intomainfrom
copilot/fix-fluentassertions-member-exclusions
Open

Fail with a descriptive error when path-based rules are used on value-semantic types#3187
dennisdoomen wants to merge 4 commits intomainfrom
copilot/fix-fluentassertions-member-exclusions

Conversation

@dennisdoomen
Copy link
Copy Markdown
Member

@dennisdoomen dennisdoomen commented Mar 28, 2026

Summary

Closes #2571

When Excluding() or Including() targets a member of a type that overrides Equals(), the rule is silently ignored because BeEquivalentTo() compares the whole object by value and never traverses its members. This is surprising and hard to debug.

Changes

This PR detects the conflict at comparison time and fails with a descriptive assertion error:

CustomerDto is compared by value (because it overrides Equals), so the Excluding member o.Etag selection rule does not apply. Either call ComparingByMembers<CustomerDto>() to force member-wise comparison, or remove the selection rule.

All user-specified selection rule types are detected:

Rule type Example Detection method
Path-based exclusion Excluding(o => o.Prop) Path string matching (cheap, handles deep paths like o.Child.Text)
Path-based inclusion Including(o => o.Prop) Path string matching
Predicate exclusion Excluding(m => m.Name == "Etag") Evaluated against the actual member list
Predicate inclusion Including(m => m.Name == "Id") Evaluated from empty set vs. full member list
Type exclusion Excluding<Guid>() Evaluated against the actual member list

Design decisions

  • Only fires for auto-detected value semantics (EqualityStrategy.Equals). Explicitly calling ComparingByValue<T>() (ForceEquals) is intentional and does not trigger the error.
  • Works at any depth — if a nested type uses value semantics and a selection rule targets one of its members, the conflict is caught when that node is visited.
  • Infrastructure rules (AllPropertiesSelectionRule, AllFieldsSelectionRule, ExcludeNonBrowsableMembersRule) are skipped since they are not user-specified selections.
  • Path detection lives in SelectMemberByPathSelectionRule.SelectsMembersOf(INode) (no reflection needed). Non-path rules are evaluated by running them against the member list of the current node's type.

Tests

Seven new tests covering:

  • Single-property exclusion by path
  • Multi-segment path exclusion (o.Child.Text)
  • Exclusion by predicate (m.Name == "...")
  • Inclusion by predicate (m.Name == "...")
  • Explicit ComparingByValue<T>() — no error
  • Explicit ComparingByMembers<T>() — exclusion works normally
  • Collection of value-semantic types — exclusion correctly detected per item

Dennis Doomen and others added 3 commits March 28, 2026 11:59
…-semantic types

When Excluding() or Including() targets a member of a type that overrides
Equals(), the rule is silently ignored because BeEquivalentTo() compares
by value and never traverses members. This commit detects that conflict at
comparison time and fails with a descriptive assertion error that tells the
user to call ComparingByMembers<T>() or remove the selection rule.

Fixes #2571

Co-authored-by: Copilot <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 28, 2026

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

    30 files   -     7      30 suites   - 7   5m 8s ⏱️ - 1m 41s
 6 300 tests +    3   6 297 ✅ +    1  1 💤 ±0  2 ❌ +2 
37 877 runs   - 1 251  37 865 ✅  - 1 257  4 💤  - 2  8 ❌ +8 

For more details on these failures, see this check.

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

This pull request removes 14 and adds 15 tests. Note that renamed tests count towards both.
Approval.Tests.ApiApproval ‑ ApproveApi(framework: "net47")
Approval.Tests.ApiApproval ‑ ApproveApi(framework: "net6.0")
Approval.Tests.ApiApproval ‑ ApproveApi(framework: "netstandard2.0")
Approval.Tests.ApiApproval ‑ ApproveApi(framework: "netstandard2.1")
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.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_fail_with_a_descriptive_error
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Excluding_a_nested_member_by_path_on_a_type_with_value_semantics_should_fail_with_a_descriptive_error
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+Excluding ‑ Including_members_by_predicate_on_a_type_with_value_semantics_should_fail_with_a_descriptive_error
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'.)
…

♻️ 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 23692926007

Details

  • 58 of 58 (100.0%) changed or added relevant lines in 4 files are covered.
  • 9 unchanged lines in 2 files lost coverage.
  • Overall coverage decreased (-0.03%) to 97.168%

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.03%
Covered Lines: 12989
Relevant Lines: 13213

💛 - Coveralls

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses surprising behavior in BeEquivalentTo() where path-based selection rules (Excluding() / Including()) can be silently ignored when a type is auto-compared by value semantics (EqualityStrategy.Equals). It introduces conflict detection to fail fast with a descriptive assertion message.

Changes:

  • Detect and report conflicts between auto-detected value-semantics comparison and path-based selection rules during equivalency comparison.
  • Add SelectsMembersOf(INode) to path-based selection rules to encapsulate overlap detection logic.
  • Add/extend selection rule specs and document the behavior change in the release notes.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
docs/_pages/releases.md Adds a release note describing the new fail-fast behavior for conflicting path rules + value semantics.
Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Including.cs Adds a test asserting Including() fails with a descriptive message on value-semantic types.
Tests/FluentAssertions.Equivalency.Specs/SelectionRulesSpecs.Excluding.cs Adds tests asserting Excluding() fails (and explicit overrides do/don’t fail).
Src/FluentAssertions/Equivalency/Steps/ValueTypeEquivalencyStep.cs Adds conflict detection and emits a dedicated assertion failure instead of silently ignoring path rules.
Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs Implements overlap detection (SelectsMembersOf) and normalizes current node paths.
Src/FluentAssertions/Equivalency/Selection/IncludeMemberByPathSelectionRule.cs Refactors to expose the rule MemberPath via the base class for conflict detection.
Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs Refactors to expose the rule MemberPath via the base class for conflict detection.

Comment on lines +38 to +40
return rulePath.StartsWith(currentPath + ".", StringComparison.Ordinal)
|| rulePath.StartsWith(currentPath + "[", StringComparison.Ordinal);
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SelectsMembersOf uses StartsWith(currentPath + "."/"[") against currentNode.Expectation.PathAndName. This won’t match when the node path contains specific indices (e.g. Children[0]) but the rule path comes from NestedExclusionOptionBuilder/MemberPath.AsParentCollectionOf and uses [] wildcards (e.g. Children[]Text). That can prevent conflicts from being detected for value-semantic types inside collections (non-root). Consider matching via MemberPath segment logic (or normalizing both paths to ignore specific indices and handle the [] separator convention) instead of raw string prefix checks.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +76
AssertionChain.GetOrCreate()
.For(context)
.FailWith(
$"{expectationType.Name} is compared by value (because it overrides Equals), " +
$"so the {conflictingRule} selection rule does not apply. " +
$"Either call ComparingByMembers<{expectationType.Name}>() to force member-wise comparison, " +
$"or remove the selection rule.");
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conflict failure message hardcodes expectationType.Name and doesn’t include {reason} / the caller-provided because text. Also, Type.Name will render generics as Foo1, making the suggested ComparingByMembers<Foo1>() invalid C#. Consider including {reason} and formatting the type in a compilable way (e.g., recommending the ComparingByMembers(Type) overload for generics).

Suggested change
AssertionChain.GetOrCreate()
.For(context)
.FailWith(
$"{expectationType.Name} is compared by value (because it overrides Equals), " +
$"so the {conflictingRule} selection rule does not apply. " +
$"Either call ComparingByMembers<{expectationType.Name}>() to force member-wise comparison, " +
$"or remove the selection rule.");
var reason = context.Reason;
bool isGeneric = expectationType.IsGenericType;
string suggestion = isGeneric
? "Either call the ComparingByMembers(Type) overload to force member-wise comparison, "
: $"Either call ComparingByMembers<{expectationType.Name}>() to force member-wise comparison, ";
string message =
$"{expectationType} is compared by value (because it overrides Equals), " +
$"so the {conflictingRule} selection rule does not apply. " +
suggestion +
"or remove the selection rule." +
reason.FormattedMessage;
AssertionChain.GetOrCreate()
.For(context)
.FailWith(message, reason.Arguments);

Copilot uses AI. Check for mistakes.
Comment on lines +1263 to +1267
[Fact]
public void Excluding_a_member_by_path_on_a_type_with_value_semantics_should_fail_with_a_descriptive_error()
{
// Arrange
var actual = new ClassWithValueSemanticsOnSingleProperty { Key = "same", NestedProperty = "x" };
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions coverage for “Collection of value-semantic types — exclusion correctly detected per item”, but there’s no test here exercising a collection member path built via .For(...).Exclude(...) (which uses [] wildcards in MemberPath). Adding that test would both align with the stated behavior and guard against regressions in SelectsMembersOf for indexed paths.

Copilot uses AI. Check for mistakes.
Extend the value-semantics conflict detection to cover non-path selection
rules (Excluding(m => predicate), Including(m => predicate), Excluding<T>()).

- Path-based rules are still detected cheaply via SelectsMembersOf (handles
  deep paths like o.Child.Text without reflection)
- Non-path rules are detected by running them against the actual member list:
  exclusion rules are applied to the full member set; inclusion rules are
  applied from an empty seed and compared to the full member set
- Infrastructure rules (AllPropertiesSelectionRule, AllFieldsSelectionRule,
  ExcludeNonBrowsableMembersRule) are skipped as they are not user-specified

Co-authored-by: Copilot <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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