Skip to content

[Refactor]: Eliminate per-item assertion overload explosion (Count / All().Satisfy) #5756

@thomhurst

Description

@thomhurst

Background

Both Count(itemAssertion) (closed: #5707, fixed in #5749) and All().Satisfy(itemAssertion) wrap each collection item in a generic ValueAssertion<TItem> and expose only IAssertionSource<TItem> to the user lambda. Specialised assertion methods like HasItemAt, ContainsKey, IsSubsetOf are unreachable inside the lambda when items are themselves collections/dictionaries/sets.

What we did in #5749

PR #5749 added 11 extension overloads on CollectionAssertionBase to expose specialised assertion sources (one per item shape — interface and concrete: IList<T>, List<T>, IReadOnlyList<T>, ISet<T>, HashSet<T>, IReadOnlySet<T>, IDictionary<K,V>, IReadOnlyDictionary<K,V>, Dictionary<K,V>, T[], IEnumerable<T>).

This solved the immediate bug for Count but the pattern has structural problems:

  1. Same gap exists in All().Satisfy()CollectionAllSatisfyAssertion uses new ValueAssertion<TItem>(item, ...) and exposes only IAssertionSource<TItem> to its lambda. Every existing user of All().Satisfy() with collection-shaped items hits the same wall.
  2. Every new assertion source type (e.g. a hypothetical SortedSetAssertion, ImmutableListAssertion) requires adding more overloads to Count and to All().Satisfy() and to any future per-item method.
  3. C# type inference doesn't unify concrete with interface types for generic constraints, so the overload count scales as O(2 × assertion_types) per per-item method.

AssertionExtensions.cs is now ~2,077 lines, a large fraction of which is this expansion.

Proposed factory-based redesign

Have Count(itemAssertion) (and All().Satisfy(itemAssertion)) accept Func<TItem, IAssertion?> — the user's lambda calls Assert.That(item) themselves, which dispatches to the correct typed source automatically:

// User code:
await Assert.That(listOfHashSets).Count(item => Assert.That(item).IsSubsetOf(universe)).IsEqualTo(2);
//                                                ^^^^^^^^^^^^^^^^^^ resolves to SetAssertion<int>

This requires zero per-collection-type overloads in the assertion-extensions layer because Assert.That(TItem) already has the correct dispatch table (it's how top-level assertions get their typed sources today).

Trade-off: slightly more verbose at the call site (item => Assert.That(item).X vs item => item.X). May be acceptable if the savings in the extensions surface is large.

Scope

  • Apply same redesign to both Count and All().Satisfy so the pattern stops spreading
  • Migration: keep the per-type overloads as [Obsolete] for one or two minor versions, point users at the new factory shape
  • Once obsolete cycle completes, delete the per-type overloads

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions