Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Oct 18, 2025

Summary

This PR implements the API proposal from #2390 to add a cleaner, more intuitive way to exclude all members of a certain type from structural equivalency assertions, with full support for inheritance and generic type hierarchies.

Motivation

Previously, to exclude all members of a specific type (e.g., TimeSpan), you had to write verbose predicate expressions:

options.Excluding(mi => mi.Type == typeof(TimeSpan))

For interfaces, the syntax was even more complex:

options.Excluding(mi => typeof(IEnumerable<int>).IsAssignableFrom(mi.Type))

And for open generic types like Nullable<>, it was quite cumbersome:

options.Excluding(mi => mi.Type.IsGenericType && 
                         mi.Type.GetGenericTypeDefinition() == typeof(Nullable<>))

Changes

This PR adds two new methods to SelfReferenceEquivalencyOptions<TSelf>:

1. Generic Method: Excluding<TMember>()

subject.Should().BeEquivalentTo(expectation, options => options
    .Excluding<TimeSpan>());

2. Type Parameter Method: Excluding(Type type)

subject.Should().BeEquivalentTo(expectation, options => options
    .Excluding(typeof(Nullable<>)));

Features

Type Matching with Inheritance Support

For non-sealed types, the exclusion includes all derived types:

options.Excluding<BaseClass>()  // Excludes BaseClass and all types deriving from it

Sealed Type Matching

For sealed types (like string), only exact matches are excluded:

options.Excluding<string>()  // Excludes only string (sealed type)

Interface and Abstract Type Support

For interfaces and abstract types, all implementing/derived types are excluded:

options.Excluding<IEnumerable<int>>()  // Excludes List<int>, int[], HashSet<int>, etc.

Open Generic Type Matching with Full Inheritance Support

Supports excluding all closed generics and types deriving from them:

options.Excluding(typeof(Nullable<>))        // Excludes int?, double?, DateTime?, etc.
options.Excluding(typeof(OpenGeneric<>))    // Excludes OpenGeneric<int>, DerivedOpenGeneric<T>, etc.

This includes:

  • Closed generics: class ClosedGeneric : OpenGeneric<int>
  • Derived open generics: class DerivedOpenGeneric<T> : OpenGeneric<T>
  • Closed derived generics: class ClosedDerivedGeneric : DerivedOpenGeneric<int>

Multiple Exclusions

Methods can be chained for excluding multiple types:

options.Excluding<TimeSpan>()
       .Excluding<DateTime>()
       .Excluding<double>()

Works at All Nesting Levels

Exclusions apply throughout the entire object graph, including nested objects and collections.

Implementation Details

  • Added new ExcludeMemberByTypeSelectionRule class that implements IMemberSelectionRule
  • The rule intelligently handles multiple scenarios:
    • Sealed types: Uses exact type equality (memberType == targetType)
    • Non-sealed types: Uses assignability check to include derived types (targetType.IsAssignableFrom(memberType))
    • Open generic types: Matches closed generics and checks inheritance hierarchy for all derived types
  • Comprehensive XML documentation for all public APIs
  • Updated documentation in objectgraphs.md and releases.md

Testing

  • Added 21 unit tests covering all scenarios including inheritance cases
  • All 628 existing equivalency tests pass (no regressions)
  • Full test suite: 5,875+ tests passing
  • Manual verification confirms all use cases work correctly

Breaking Changes

None. This is a purely additive change that maintains full backward compatibility.

Fixes #2390

Original prompt

This section details on the original issue you should resolve

<issue_title>[API Proposal]: Allow BeEquivalentTo to exclude all members of a certain type</issue_title>
<issue_description>### Background and motivation

A shortcut to options.Excluding(mi => mi.Type == typeof(TimeSpan));

API Proposal

public abstract class SelfReferenceEquivalencyAssertionOptions<TSelf> : IEquivalencyOptions
    where TSelf : SelfReferenceEquivalencyAssertionOptions<TSelf>
{
    public TSelf Excluding<TMember>()
    {
    }
}

Also supports excluding all members whose type is assignable to IMyInterface

options.Excluding<IMyInterface>();

And excludeing all members whose type is a closed generic of Nullable<T>, i.e. it excludes int?, double?, etc.

options.Excluding(typeof(Nullable<>))

API Usage

Instead of options.Excluding(mi => mi.Type == typeof(TimeSpan));, you can do options.Excluding<TimeSpan>()

Alternative Designs

Maybe name it ExcludingMembersOfType<T>

Risks

No response

Are you willing to help with a proof-of-concept (as PR in that or a separate repo) first and as pull-request later on?

No</issue_description>

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

@jnyrup Should this also be able to handle polymorphism and open generic types?

Examples:

Excludes all members whose type is assignable to MyInterface

options.Excluding<MyInterface>()

Excludes all members whose type is a closed generic of Nullable<T>, i.e. it excludes int?, double?, etc.

options.Excluding(typeof(Nullable<>))
```</body></comment_new>
<comment_new><author>@dennisdoomen</author><body>
I like that. Let's do that.</body></comment_new>
</comments>

Fixes #3114


💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click here to start the survey.

Copilot AI changed the title [WIP] Add API options for excluding member types Add Excluding<T>() and Excluding(Type) methods to exclude members by type in equivalency assertions Oct 18, 2025
Copilot AI requested a review from dennisdoomen October 18, 2025 07:50
public class ExcludingByType
{
[Fact]
public void When_excluding_members_by_type_it_should_exclude_exact_type_matches()
Copy link
Member

Choose a reason for hiding this comment

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

@copilot don't use terms like "when" and "should", but instead use a current or future tense form for all test name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed all test methods to use present/future tense without "when" and "should" (commit edeadd1).

Copy link
Member

@dennisdoomen dennisdoomen left a comment

Choose a reason for hiding this comment

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

Also update the objectgraphs.md and releases.md

@github-actions
Copy link

github-actions bot commented Oct 18, 2025

Test Results

    37 files  ±  0      37 suites  ±0   2m 53s ⏱️ +5s
 6 288 tests + 20   6 288 ✅ + 20  0 💤 ±0  0 ❌ ±0 
39 074 runs  +120  39 074 ✅ +120  0 💤 ±0  0 ❌ ±0 

Results for commit 56a159e. ± Comparison against base commit d73f064.

This pull request removes 10 and adds 28 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+ExcludingByType ‑ Can_exclude_all_closed_generic_types
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_all_types_deriving_from_an_abstract_type
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_all_types_implementing_a_generic_interface
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_exact_type_using_a_type_instance
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_exact_type_using_a_type_parameter
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_multiple_types
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Can_exclude_value_types
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Excluding_applies_to_all_levels_in_a_nested_structure
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Excluding_applies_to_collections_as_well
FluentAssertions.Equivalency.Specs.SelectionRulesSpecs+ExcludingByType ‑ Excluding_by_type_with_nested_generics_works
…

♻️ This comment has been updated with latest results.

Copy link
Contributor Author

Copilot AI commented Oct 18, 2025

Also update the objectgraphs.md and releases.md

Updated both objectgraphs.md and releases.md with documentation for the new Excluding<T>() and Excluding(Type) methods.

Copilot AI changed the title Add Excluding<T>() and Excluding(Type) methods to exclude members by type in equivalency assertions Add Excluding by type with inheritance support Oct 18, 2025
Copilot AI requested a review from dennisdoomen October 18, 2025 09:15
@github-actions
Copy link

github-actions bot commented Oct 18, 2025

Qodana for .NET

5 new problems were found

Inspection name Severity Problems
Auto-property accessor is never used (private accessibility) 🔶 Warning 3
Redundant using directive 🔶 Warning 1
Unused type parameter 🔶 Warning 1

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

Contact Qodana team

Contact us at qodana-support@jetbrains.com

Copilot AI and others added 4 commits December 14, 2025 13:08
…by type

Co-authored-by: dennisdoomen <572734+dennisdoomen@users.noreply.github.com>
Co-authored-by: dennisdoomen <572734+dennisdoomen@users.noreply.github.com>
…hould"

Co-authored-by: dennisdoomen <572734+dennisdoomen@users.noreply.github.com>
@dennisdoomen dennisdoomen force-pushed the copilot/add-excluding-options-to-api branch from 6fb54bc to 1b5e9a0 Compare December 14, 2025 12:08
@github-actions
Copy link

github-actions bot commented Dec 14, 2025

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

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen dennisdoomen force-pushed the copilot/add-excluding-options-to-api branch from 1b5e9a0 to 4033707 Compare December 14, 2025 12:30
@coveralls
Copy link

coveralls commented Dec 14, 2025

Pull Request Test Coverage Report for Build 20550527439

Details

  • 23 of 23 (100.0%) changed or added relevant lines in 2 files are covered.
  • 5 unchanged lines in 2 files lost coverage.
  • Overall coverage decreased (-0.02%) to 97.164%

Files with Coverage Reduction New Missed Lines %
Src/FluentAssertions/Equivalency/Steps/EnumerableEquivalencyValidator.cs 2 96.15%
Src/FluentAssertions/Equivalency/Node.cs 3 87.8%
Totals Coverage Status
Change from base Build 20440195444: -0.02%
Covered Lines: 12849
Relevant Lines: 13068

💛 - Coveralls

@dennisdoomen dennisdoomen force-pushed the copilot/add-excluding-options-to-api branch 3 times, most recently from 8231fea to c6fcbe4 Compare December 14, 2025 19:54
@dennisdoomen dennisdoomen marked this pull request as ready for review December 14, 2025 19:55
@dennisdoomen dennisdoomen requested a review from jnyrup December 14, 2025 19:55
@dennisdoomen dennisdoomen force-pushed the copilot/add-excluding-options-to-api branch from c6fcbe4 to 56a159e Compare December 28, 2025 07:18
@dennisdoomen dennisdoomen requested a review from jnyrup December 28, 2025 07:20
@github-actions
Copy link

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

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen dennisdoomen merged commit dc2445b into main Dec 28, 2025
14 checks passed
@dennisdoomen dennisdoomen deleted the copilot/add-excluding-options-to-api branch December 28, 2025 08:11
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.

[API Proposal]: Allow BeEquivalentTo to exclude all members of a certain type

4 participants