Skip to content

fix: IsEquivalentTo falls back to Equals() for types with no public members#5041

Merged
thomhurst merged 2 commits intomainfrom
fix/5040-isequivalentto-private-state
Mar 1, 2026
Merged

fix: IsEquivalentTo falls back to Equals() for types with no public members#5041
thomhurst merged 2 commits intomainfrom
fix/5040-isequivalentto-private-state

Conversation

@thomhurst
Copy link
Owner

Summary

Fixes #5040.

  • IsEquivalentTo now falls back to Equals() when a type has no public properties or fields to compare structurally. This fixes false positives (and NotSupportedException crashes) for types like sealed class FilePath that implement IEquatable<T> with only private state.
  • Applied to both StructuralEqualityComparer (collection equivalence) and StructuralEquivalencyAssertion (direct object equivalence).
  • Added NotSupportedException safety net around reflection-based member access in StructuralEqualityComparer.
  • Does not regress [Bug]: IsEquivalentTo broken for value types #4358 — types with public members (records, structs, tuples) still use structural comparison.

Test plan

…embers (#5040)

When a type has only private state and implements IEquatable<T>,
structural comparison found no public members and trivially returned
true (false positive) or threw NotSupportedException.

Now both StructuralEqualityComparer and StructuralEquivalencyAssertion
detect empty member lists and fall back to Equals(), which respects
IEquatable<T>. Also adds a NotSupportedException safety net around
reflection-based member access.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review: fix: IsEquivalentTo falls back to Equals() for types with no public members

The core fix is valid and addresses a real UX problem — IsEquivalentTo silently "passing" types with no public members (because 0 members = nothing failed = pass) is a confusing false positive. The fallback to Equals() is the right semantic intent. A few architectural concerns worth discussing below.


Issue 1: try/catch (NotSupportedException) is Too Broad ⚠️

Location: StructuralEqualityComparer.cs, lines 119–129

try
{
    xValue = ReflectionHelper.GetMemberValue(x, member);
    yValue = ReflectionHelper.GetMemberValue(y, member);
}
catch (NotSupportedException)
{
    // Fall back to Equals() for the entire object comparison.
    return Equals(x, y);
}

Why this is problematic:

  1. Silences legitimate exceptions: A property getter that throws NotSupportedException for intentional business reasons (e.g., throw new NotSupportedException("Use X instead")) will silently fall back to Equals() instead of surfacing the bug.
  2. Inconsistent mid-stream semantics: If member A is compared structurally and then member B throws, the result is Equals(x, y) for the whole object. You've already validated some members structurally, then validated the whole object by equality — this is internally inconsistent and hard to reason about.
  3. The original NotSupportedException concern should be addressed at the source: Check PropertyInfo.CanRead before accessing the getter, or handle this in ReflectionHelper.GetMemberValue more precisely.

Suggested approach:

// In ReflectionHelper.GetMembersToCompare, filter out non-readable properties:
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
    if (prop.GetIndexParameters().Length == 0 && prop.CanRead && prop.GetMethod?.IsPublic == true)
    {
        members.Add(prop);
    }
}

This prevents the problem at the data source rather than catching exceptions after the fact.


Issue 2: Unnecessary Double Reflection Call + Overly Complex Check ⚠️

Location: StructuralEquivalencyAssertion.cs, lines 199–209

if (expectedMembers.Count == 0)
{
    var actualMembersCheck = ReflectionHelper.GetMembersToCompare(actualType);
    if (actualMembersCheck.Count == 0)
    {
        if (\!Equals(actual, expected))
        {
            return AssertionResult.Failed(...);
        }
        return AssertionResult.Passed;
    }
}

Two problems here:

a) The inner actualMembersCheck is redundant. When expectedMembers.Count == 0 but actual does have members, the code falls through to the existing "extra properties" check (lines 267–301), which will correctly fail with a descriptive message. The nested guard adds complexity without changing correctness in most practical cases.

b) ReflectionHelper.GetMembersToCompare(actualType) gets called up to 3 times in the failing path: once in the new check (line 201), again at line 269 in the extra-properties block. Reflection isn't free, especially for large type hierarchies.

Simpler equivalent:

if (expectedMembers.Count == 0)
{
    // No public members to compare structurally — respect IEquatable<T>/Equals()
    if (\!Equals(actual, expected))
    {
        return AssertionResult.Failed($"Objects at '{path}' are not equal{Environment.NewLine}Expected: {FormatValue(expected)}{Environment.NewLine}Received: {FormatValue(actual)}");
    }
    return AssertionResult.Passed;
}

If actual has extra members but expected doesn't, Equals() (which delegates to the type's equality semantics) is the correct arbiter here anyway.


Issue 3: Misleading Error Message for the No-Member Case (Minor) 💬

When path is empty (top-level comparison) and the fallback triggers, the error reads:

"Property did not match" (note double space) is odd when there are no properties. Suggest using a different message format for the equality-fallback path, e.g. "Objects are not equal" or "Values at root did not match".


Issue 4: Test Coverage Gap 🔍

The test covers the happy paths well, but misses:

  • Mismatched types: expected has no public members but actual does (or vice versa). What happens when comparing PrivateStatePath to a plain string via IsEquivalentTo?
  • Property throws NotSupportedException for non-reflection reasons: Verifying the catch doesn't suppress real errors.
  • Partial equivalency mode (.WithPartialEquivalency()) with no-public-member types.

Positive Notes ✅

  • The root cause analysis is correct: 0 members → no failures → false positive pass is the real bug.
  • Applying the fix to both StructuralEqualityComparer (used for collection equivalence) and StructuralEquivalencyAssertion (direct object comparison) is the right scope.
  • The members.Count == 0 check in StructuralEqualityComparer is clean and symmetric (since that comparer only works on one type parameter).
  • Regression test mention for #4358 is reassuring — records/structs/tuples with public members are unaffected.
  • New test class is well-structured with a realistic reproducer matching the issue scenario.

Summary

The fix addresses the reported issue correctly. The main architectural concern is the try/catch (NotSupportedException) — it trades one class of confusing behavior (silent false-positive) for another (silently swallowed exceptions with inconsistent mid-comparison fallback). The cleanest fix is to filter non-readable properties in ReflectionHelper.GetMembersToCompare rather than catching exceptions later. The double-member-check in StructuralEquivalencyAssertion can also be simplified for clarity.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review: fix: IsEquivalentTo falls back to Equals() for types with no public members

The core fix is valid and addresses a real UX problem — IsEquivalentTo silently "passing" types with no public members (because 0 members = nothing failed = pass) is a confusing false positive. The fallback to Equals() is the right semantic intent. A few architectural concerns worth discussing below.


Issue 1: try/catch (NotSupportedException) is Too Broad ⚠️

Location: StructuralEqualityComparer.cs, lines 119–129

The catch (NotSupportedException) block has two problems:

  1. Silences legitimate exceptions: A property getter that throws NotSupportedException for intentional business reasons will silently fall back to Equals() instead of surfacing the issue.
  2. Inconsistent mid-stream semantics: If member A is compared structurally and then member B throws, the result is Equals(x, y) for the whole object. You have already validated some members structurally, then validated the whole object by equality — this is internally inconsistent and hard to reason about.

Suggested approach: Address this at the source in ReflectionHelper.GetMembersToCompare by filtering out non-readable properties:

var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in properties)
{
    if (prop.GetIndexParameters().Length == 0 && prop.CanRead && prop.GetMethod?.IsPublic == true)
    {
        members.Add(prop);
    }
}

This prevents the problem at the data source rather than catching exceptions after the fact, and does not swallow legitimate errors.


Issue 2: Unnecessary Double Reflection Call + Overly Complex Check ⚠️

Location: StructuralEquivalencyAssertion.cs, lines 199–209

The nested actualMembersCheck.Count == 0 guard is redundant. When expectedMembers.Count == 0 but actual does have members, the code falls through to the existing "extra properties" check (lines 267–301), which correctly fails with a descriptive message. The nested guard adds complexity without changing correctness.

It also results in ReflectionHelper.GetMembersToCompare(actualType) being called up to 3 times in the failing path (lines 201, 269).

Simpler equivalent:

if (expectedMembers.Count == 0)
{
    // No public members to compare structurally — respect IEquatable<T>/Equals()
    if (!Equals(actual, expected))
    {
        var label = string.IsNullOrEmpty(path) ? "Objects" : $"Property {path}";
        return AssertionResult.Failed($"{label} did not match{Environment.NewLine}Expected: {FormatValue(expected)}{Environment.NewLine}Received: {FormatValue(actual)}");
    }
    return AssertionResult.Passed;
}

If actual has extra members but expected doesn't, Equals() is the correct arbiter anyway.


Issue 3: Misleading Error Message for the No-Member Case (Minor) 💬

When path is an empty string (top-level comparison) and the fallback triggers, the current message reads "Property did not match" (double space). Since there are no properties involved, the message should be adapted. See the snippet above for a suggested fix using a label variable.


Issue 4: Test Coverage Gap 🔍

The tests cover the main happy paths well, but miss:

  • Mismatched types: what happens when expected has no public members but actual does?
  • Partial equivalency mode (.WithPartialEquivalency()) combined with no-public-member types.
  • A test confirming that a property intentionally throwing NotSupportedException is not silently swallowed (relevant if the catch block stays).

Positive Notes ✅

  • The root cause diagnosis is correct: 0 members → no failures → false-positive pass, and Equals() fallback is the right semantic fix.
  • Fixing both StructuralEqualityComparer (collection equivalence) and StructuralEquivalencyAssertion (direct comparison) covers the full scope.
  • The members.Count == 0 check in StructuralEqualityComparer is clean and symmetric.
  • Regression test coverage for #4358 is reassuring — records/structs/tuples with public members remain unaffected.
  • New test class is well-structured with a realistic reproducer matching the reported scenario.

Summary

The fix correctly addresses the reported issue. The main architectural concern is the try/catch (NotSupportedException) — it trades one class of confusing behavior (silent false-positive) for another (silently swallowed exceptions + inconsistent mid-comparison fallback). The cleanest fix is to filter non-readable properties in ReflectionHelper.GetMembersToCompare rather than catching exceptions later. The double-member-check in StructuralEquivalencyAssertion can also be simplified for clarity and performance.

- Filter non-readable properties in ReflectionHelper.GetMembersToCompare
  (CanRead + public GetMethod check) instead of catching
  NotSupportedException after the fact
- Simplify StructuralEquivalencyAssertion empty-members check: remove
  redundant double reflection call for actualMembers
- Fix error message for top-level comparison ("Objects did not match"
  instead of "Property  did not match")
- Add tests for mismatched types and partial equivalency mode with
  no-public-member types
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review: All Previous Issues Addressed ✅

The previous review raised four concerns. All four have been resolved in the current state of the PR.


Previous Issue 1: try/catch (NotSupportedException) was too broad → Fixed

The catch block has been removed entirely. Instead, ReflectionHelper.GetMembersToCompare now guards at the source:

if (prop.GetIndexParameters().Length == 0 && prop.CanRead && prop.GetMethod?.IsPublic == true)

This is the correct approach — preventive filtering beats exception-based fallback. The prop.GetMethod?.IsPublic == true guard is also strictly necessary: GetProperties(BindingFlags.Public | BindingFlags.Instance) includes properties where any accessor is public (e.g., private get; public set;), so the getter must be checked separately.


Previous Issue 2: Unnecessary double reflection call + overly complex check → Fixed

The nested actualMembersCheck.Count == 0 guard has been removed. The expectedMembers.Count == 0 block in StructuralEquivalencyAssertion is now a clean early-return without redundant reflection calls.


Previous Issue 3: Misleading error message (double space) → Fixed

The label variable correctly handles the top-level vs. nested path cases:

var label = string.IsNullOrEmpty(path) ? "Objects" : $"Property {path}";

Previous Issue 4: Test coverage gaps → Fixed

Three new tests cover the previously missing scenarios:

  • No_Public_Members_Vs_Public_Members_Fails — mismatched types
  • Partial_Equivalency_With_No_Public_Members_Equal_Values_Passes
  • Partial_Equivalency_With_No_Public_Members_Different_Values_Fails

Remaining Observations (Non-blocking)

Behavior note worth documenting: For types with no public members that also do not override Equals() or implement IEquatable<T>, the fallback uses reference equality. This is correct by definition — there's no other equality contract to honor — but it's worth noting in the PR description or a code comment for future readers who might wonder why two new BlackBoxType(42) instances fail equivalency.

StructuralEqualityComparer vs StructuralEquivalencyAssertion asymmetry: The comparer checks xType (runtime type of x) while the assertion checks expectedType. This asymmetry is intentional and correct given their different comparison models.


Summary

Clean fix, properly scoped, well-tested, and all previous feedback has been incorporated. The implementation is correct and the approach is sound. Approved.

@thomhurst thomhurst enabled auto-merge (squash) March 1, 2026 11:36
@thomhurst thomhurst merged commit 1ff5d10 into main Mar 1, 2026
14 of 15 checks passed
@thomhurst thomhurst deleted the fix/5040-isequivalentto-private-state branch March 1, 2026 11:57
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Mar 2, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.17.36 to
1.18.0.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.18.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.18.0 -->

## What's Changed
### Other Changes
* refactor: convert 15 manual assertions to [GenerateAssertion] by
@​thomhurst in thomhurst/TUnit#5029
* Fix invisible chart labels on benchmark pages by @​Copilot in
thomhurst/TUnit#5033
* docs: fix position of `--results-directory` in documentation by
@​vbreuss in thomhurst/TUnit#5038
* fix: IsEquivalentTo falls back to Equals() for types with no public
members by @​thomhurst in thomhurst/TUnit#5041
* perf: make test metadata creation fully synchronous by @​thomhurst in
thomhurst/TUnit#5045
* perf: eliminate <>c display class from generated TestSource classes by
@​thomhurst in thomhurst/TUnit#5047
* perf: generate per-class helper to reduce JIT compilations by ~18,000
by @​thomhurst in thomhurst/TUnit#5048
* perf: consolidate per-method TestSource into per-class TestSource
(~27k fewer JITs) by @​thomhurst in
thomhurst/TUnit#5049
* perf: eliminate per-class TestSource .ctor JITs via delegate
registration by @​thomhurst in
thomhurst/TUnit#5051
* feat: rich HTML test reports by @​thomhurst in
thomhurst/TUnit#5044
### Dependencies
* chore(deps): update tunit to 1.17.54 by @​thomhurst in
thomhurst/TUnit#5028
* chore(deps): update dependency polyfill to 9.13.0 by @​thomhurst in
thomhurst/TUnit#5035
* chore(deps): update dependency polyfill to 9.13.0 by @​thomhurst in
thomhurst/TUnit#5036


**Full Changelog**:
thomhurst/TUnit@v1.17.54...v1.18.0

## 1.17.54

<!-- Release notes generated using configuration in .github/release.yml
at v1.17.54 -->

## What's Changed
### Other Changes
* docs: restructure, deduplicate, and clean up documentation by
@​thomhurst in thomhurst/TUnit#5019
* docs: trim, deduplicate, and restructure sidebar by @​thomhurst in
thomhurst/TUnit#5020
* fix: add newline to github reporter summary to fix rendering by
@​robertcoltheart in thomhurst/TUnit#5023
* docs: consolidate hooks, trim duplication, and restructure sidebar by
@​thomhurst in thomhurst/TUnit#5024
* Redesign mixed tests template by @​thomhurst in
thomhurst/TUnit#5026
* feat: add IsAssignableFrom<T>() and IsNotAssignableFrom<T>()
assertions by @​thomhurst in
thomhurst/TUnit#5027
### Dependencies
* chore(deps): update tunit to 1.17.36 by @​thomhurst in
thomhurst/TUnit#5018
* chore(deps): update actions/upload-artifact action to v7 by
@​thomhurst in thomhurst/TUnit#5015
* chore(deps): update dependency
microsoft.testing.extensions.codecoverage to 18.5.1 by @​thomhurst in
thomhurst/TUnit#5025


**Full Changelog**:
thomhurst/TUnit@v1.17.36...v1.17.54

Commits viewable in [compare
view](thomhurst/TUnit@v1.17.36...v1.18.0).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.17.36&new-version=1.18.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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.

[Bug]: Specified method is not supported exception when using IsEquivalentTo on two structs that implement IEquatable

1 participant