-
-
Notifications
You must be signed in to change notification settings - Fork 108
feat: add collection assertions for Memory, Set, Dictionary, List, ReadOnlyList, and AsyncEnumerable types #4226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
This PR introduces a new adapter-based architecture for collection assertions, enabling support for types beyond IEnumerable<T>: ## New Collection Types Supported - Memory<T> and ReadOnlyMemory<T> - first-class assertion support - ISet<T>, IReadOnlySet<T>, HashSet<T> - with set-specific operations ## Architecture Changes - **Trait Interfaces** (Abstractions/): IItemSequence, ICountable, IContainsCheck, ICollectionAdapter, IDictionaryAdapter, ISetAdapter, ISetOperations - **Struct Adapters** (Adapters/): Zero-allocation adapters for each collection type - **CollectionChecks** (Collections/): Single source of truth for all assertion logic ## New Assertions ### Memory/ReadOnlyMemory: - IsEmpty, IsNotEmpty, HasCount, Contains, All, Any, HasDistinctItems, etc. ### Set-specific: - IsSubsetOf, IsSupersetOf, IsProperSubsetOf, IsProperSupersetOf - Overlaps, DoesNotOverlap, SetEquals ### Dictionary (new methods): - ContainsValue, DoesNotContainValue, ContainsKeyWithValue - AllKeys, AllValues, AnyKey, AnyValue ## Benefits - Zero-allocation struct adapters for performance - Centralized assertion logic in CollectionChecks - Type-preserving And/Or chaining - Extensible pattern for custom collection types Closes #4224 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
SummaryAdds Memory, ReadOnlyMemory, and Set-specific assertions with an adapter-based architecture that provides zero-allocation wrappers for different collection types. Critical IssuesNone found ✅ Suggestions1. Memory ToArray() allocation in AsEnumerable (minor performance concern)Location: TUnit.Assertions/Adapters/MemoryAdapter.cs Both MemoryAdapter and ReadOnlyMemoryAdapter implement public IEnumerable<TItem> AsEnumerable() => _source.ToArray();Impact: This allocates on the heap when Why this might be acceptable:
Potential optimization (optional): 2. Consider documenting the adapter pattern for extensibilityThe adapter-based architecture is well-designed and extensible. Consider adding a brief doc comment or example showing how users could add custom collection type support by implementing the trait interfaces. This would make the extensibility promise in the PR description more actionable. TUnit Rules Compliance✅ AOT Compatible - Uses only Verdict✅ APPROVE - No critical issues. The minor allocation in Memory.AsEnumerable() is acceptable as it's a fallback path and the optimized paths avoid it entirely. |
There was a problem hiding this 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 introduces an adapter-based architecture for collection assertions, enabling first-class support for Memory, ReadOnlyMemory, and set types (ISet, IReadOnlySet, HashSet), along with enhanced dictionary assertions.
Key Changes
- New trait-based adapter pattern for unified collection handling
- Zero-allocation struct adapters for performance
- Centralized assertion logic in
CollectionChecksclass - Memory/ReadOnlyMemory support with 23 new tests
- Set-specific operations (subset, superset, overlaps) with 23 new tests
- Enhanced dictionary methods (ContainsValue, AllKeys, AnyValue) with 31 total tests
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
ICollectionAdapter.cs |
Defines trait interfaces (IItemSequence, ICountable, IContainsCheck, ISetOperations) and composite adapter interfaces |
MemoryAdapter.cs |
Struct adapters for Memory and ReadOnlyMemory with indexed access and optimized Contains |
SetAdapter.cs |
Struct adapters for ISet and IReadOnlySet with set operation delegates |
EnumerableAdapter.cs |
Generic adapter for IEnumerable with optimizations for known collection types |
DictionaryAdapter.cs |
Struct adapters for IDictionary and IReadOnlyDictionary with key-value operations |
CollectionChecks.cs |
Centralized assertion logic for all collection types (single source of truth) |
MemoryAssertionBase.cs |
Base class providing collection-like assertions for Memory types |
MemoryAssertion.cs |
Entry points for Memory and ReadOnlyMemory assertions |
SetAssertionBase.cs |
Base class with set-specific methods (IsSubsetOf, Overlaps, etc.) |
SetAssertion.cs |
Entry points for ISet, IReadOnlySet, and HashSet |
DictionaryAssertionBase.cs |
Enhanced with ContainsValue, AllKeys/Values, AnyKey/Value methods |
Assert.cs |
New overloads with OverloadResolutionPriority for Memory and Set types |
CollectionAssertions.cs |
Refactored to delegate to CollectionChecks |
DictionaryAssertions.cs |
New assertion classes for enhanced dictionary methods |
SetAssertions.cs |
Assertion classes for all set operations |
MemoryAssertions.cs |
Assertion classes for Memory-specific operations including count comparisons |
| Test files | Comprehensive test coverage for all new functionality |
| var seen = new HashSet<TItem>(comparer); | ||
| var index = 0; | ||
|
|
||
| foreach (var item in adapter.AsEnumerable()) | ||
| { | ||
| if (!seen.Add(item)) | ||
| { | ||
| return AssertionResult.Failed($"duplicate item found at index {index}: {item}"); | ||
| } | ||
| index++; | ||
| } | ||
|
|
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method CheckHasDistinctItems in this updated implementation does not provide detailed error information about which items are duplicates or how many duplicates exist. The original implementation collected all duplicates and reported the total count along with the duplicate items themselves. The new implementation only reports the first duplicate found, which provides less helpful diagnostic information for test failures.
| var seen = new HashSet<TItem>(comparer); | |
| var index = 0; | |
| foreach (var item in adapter.AsEnumerable()) | |
| { | |
| if (!seen.Add(item)) | |
| { | |
| return AssertionResult.Failed($"duplicate item found at index {index}: {item}"); | |
| } | |
| index++; | |
| } | |
| var counts = new Dictionary<TItem, int>(comparer); | |
| foreach (var item in adapter.AsEnumerable()) | |
| { | |
| if (counts.TryGetValue(item, out var currentCount)) | |
| { | |
| counts[item] = currentCount + 1; | |
| } | |
| else | |
| { | |
| counts[item] = 1; | |
| } | |
| } | |
| var duplicateItems = new List<TItem>(); | |
| foreach (var kvp in counts) | |
| { | |
| if (kvp.Value > 1) | |
| { | |
| duplicateItems.Add(kvp.Key); | |
| } | |
| } | |
| if (duplicateItems.Count > 0) | |
| { | |
| var duplicatesPreview = string.Join(", ", duplicateItems); | |
| return AssertionResult.Failed( | |
| $"collection contains {duplicateItems.Count} duplicate item(s): [{duplicatesPreview}]"); | |
| } |
| } | ||
| } | ||
|
|
||
| return AssertionResult.Failed($"the item was not found in the collection"); |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message "the item was not found in the collection" is inconsistent with error messages from other similar assertions. For example, CheckIsNotEmpty returns "it was empty" which is more conversational. Consider making error messages consistent across all collection checks, either using "it" or explicit references to "the collection".
| public int Length => _source.Length; | ||
|
|
||
| /// <inheritdoc /> | ||
| public IEnumerable<TItem> AsEnumerable() => _source.ToArray(); |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AsEnumerable() method allocates a new array for every call on Memory types. This creates unnecessary allocations in hot paths when the same memory needs to be enumerated multiple times (e.g., in chained assertions or when error messages need to display items). Consider caching or using MemoryMarshal.ToEnumerable if available, or document this allocation cost clearly.
|
|
||
| /// <inheritdoc /> | ||
| public bool IsSupersetOf(IEnumerable<TItem> other) | ||
| => _source?.IsSupersetOf(other) ?? !other.Any(); // Empty set is only superset of empty |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the SetAdapter, when checking IsSupersetOf with a null source, the implementation returns !other.Any(). However, this eagerly evaluates the entire other collection just to check if it's empty. Consider using other is ICollection c ? c.Count == 0 : !other.Any() for better performance when other is a collection type.
| => _source?.IsSupersetOf(other) ?? !other.Any(); // Empty set is only superset of empty | |
| => _source?.IsSupersetOf(other) ?? | |
| (other is System.Collections.ICollection c ? c.Count == 0 : !other.Any()); // Empty set is only superset of empty |
| foreach (var element in _source) | ||
| { | ||
| if (comparer.Equals(element, item)) | ||
| { | ||
| return true; | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var item in adapter.AsEnumerable()) | ||
| { | ||
| if (predicate(item)) | ||
| { | ||
| return AssertionResult.Passed; | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var key in adapter.Keys) | ||
| { | ||
| if (!predicate(key)) | ||
| { | ||
| return AssertionResult.Failed($"key [{key}] does not satisfy the predicate"); | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var value in adapter.Values) | ||
| { | ||
| if (!predicate(value)) | ||
| { | ||
| return AssertionResult.Failed($"value [{value}] does not satisfy the predicate"); | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var key in adapter.Keys) | ||
| { | ||
| if (predicate(key)) | ||
| { | ||
| return AssertionResult.Passed; | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var value in adapter.Values) | ||
| { | ||
| if (predicate(value)) | ||
| { | ||
| return AssertionResult.Passed; | ||
| } | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
Updates the TUnit.Assertions public API snapshots to include: - New Abstractions namespace (ICollectionAdapter, ISetAdapter, etc.) - New Adapters namespace (EnumerableAdapter, MemoryAdapter, etc.) - New Collections namespace (CollectionChecks) - New Memory/Set assertion classes and methods - New dictionary assertion methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
SummaryAdds adapter-based architecture for collection assertions with Memory, ReadOnlyMemory, and Set-specific assertion support. Critical IssuesNone found ✅ SuggestionsPerformance: GetPreview allocation for Memory types (minor)In CollectionChecks.GetPreview() calling adapter.AsEnumerable() on Memory adapters triggers .ToArray() allocation even when only previewing first N items. This is only called in error paths (not hot paths), so impact is minimal. Optional optimization: Check if adapter implements IIndexable and use indexed access to avoid the full array copy. Architecture: Excellent use of struct adapters✅ Zero-allocation struct adapters (MemoryAdapter, SetAdapter, DictionaryAdapter) Testing: Comprehensive coverage✅ 23 tests for Memory assertions TUnit Rule Compliance✅ Rule 1 (Dual-Mode): N/A - Only affects assertion library, not test discovery/engine Verdict✅ APPROVE - Excellent architectural implementation with comprehensive testing and proper TUnit compliance. The GetPreview optimization is optional and does not block merge. |
Adds first-class assertion support for mutable dictionaries (IDictionary<TKey, TValue>) in addition to the existing IReadOnlyDictionary support. ## New Types - MutableDictionaryAssertionBase<TDictionary, TKey, TValue> - MutableDictionaryAssertion<TKey, TValue> - MutableDictionaryAndContinuation / MutableDictionaryOrContinuation - 9 assertion classes: ContainsKey, DoesNotContainKey, ContainsValue, etc. ## New Assert.That Overload - Assert.That(IDictionary<TKey, TValue>) now returns MutableDictionaryAssertion ## Tests - Added 13 tests for IDictionary assertions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
SummaryAdds first-class assertion support for Memory, ReadOnlyMemory, and Set types via a new adapter-based architecture. Critical IssuesNone found ✅ Suggestions1. Consider Memory.Span optimizations (Performance)For Memory and ReadOnlyMemory, you could potentially optimize Contains/Any/All checks by using // In MemoryAdapter.cs
public bool Contains(TItem item, IEqualityComparer<TItem>? comparer = null)
{
return comparer == null
? _memory.Span.Contains(item) // Use Span.Contains directly
: AsEnumerable().Contains(item, comparer);
}This would avoid the enumeration overhead for the default comparer case. Similar optimization could apply to 2. NET5_0_OR_GREATER conditional compilationThe
This is more a question about your target framework strategy than a bug. TUnit Rules Compliance ✅
Verdict✅ APPROVE - Well-designed architecture that aligns perfectly with TUnit's principles. The adapter pattern is clean and extensible. Suggestions above are optional optimizations only. |
…on support This commit extends the collection assertion architecture with: IList<T> Support: - ListAdapter<TItem> - zero-allocation struct adapter - Index-based methods: HasItemAt, ItemAt, FirstItem, LastItem - Type-preserving And/Or continuations - 30 tests in ListAssertionTests.cs IReadOnlyList<T> Support: - ReadOnlyListAdapter<TItem> - zero-allocation struct adapter - Same index-based methods as IList<T> - Separate overload with priority 3 (IList has priority 4) - 27 tests in ReadOnlyListAssertionTests.cs IAsyncEnumerable<T> Support: - Automatic materialization to List<T> for assertion evaluation - Full collection methods: IsEmpty, IsNotEmpty, HasCount, Contains, All, Any - Type-preserving And/Or continuations - 19 tests in AsyncEnumerableAssertionTests.cs Overload Resolution Priority: - Array: 5 (most specific) - IList<T>: 4 (mutable + indexed) - IReadOnlyList<T>: 3 (read-only indexed) - ISet<T>: 2 (set operations) - IEnumerable<T>: 1 (fallback) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
SummaryAdds adapter-based architecture for collection assertions with first-class support for Memory, ReadOnlyMemory, ISet, IDictionary<TKey, TValue>, IList, IReadOnlyList, and IAsyncEnumerable. Critical IssuesNone found ✅ Previous Review StatusThree previous reviews by @thomhurst (PR author) have already approved this PR with no critical issues:
All previous reviewers approved the PR. TUnit Rules Compliance✅ Rule 1 (Dual-Mode): N/A - Changes only affect TUnit.Assertions, not core engine or source generator
Architecture HighlightsExcellent design with:
Verdict✅ APPROVE - Excellent implementation that perfectly aligns with TUnit's critical rules and design principles. The adapter pattern is well-designed, extensible, and performance-conscious. |
Summary
This PR introduces a new adapter-based architecture for collection assertions, enabling first-class support for types beyond
IEnumerable<T>:Architecture Highlights
Abstractions/)IItemSequence,ICountable,IContainsCheck,ISetOperations,IIndexableAdapters/)Collections/)New Collection Types Supported
Memory/ReadOnlyMemory:
Set-specific:
Dictionary (IDictionary<TKey, TValue>):
IList (index-based):
IReadOnlyList (index-based):
IAsyncEnumerable:
Overload Resolution Priority
Benefits
CollectionChecksfor maintainabilityAnd/Ormaintain specific collection typeTest plan
MemoryAssertionTests.cs- 23 tests covering Memory assertionsSetAssertionTests.cs- 23 tests covering Set assertionsDictionaryCollectionTests.cs- 31 tests including IDictionary methodsListAssertionTests.cs- 30 tests covering IList index-based assertionsReadOnlyListAssertionTests.cs- 27 tests covering IReadOnlyList assertionsAsyncEnumerableAssertionTests.cs- 19 tests covering async stream assertionsArrayAssertionTests.cs- 11 tests verifying Array inherits list behaviorsCloses #4224
🤖 Generated with Claude Code