Skip to content

Commit ed8b1b3

Browse files
committed
feat: add collection count range assertions (HasAtLeast, HasAtMost, HasCountBetween)
Add three new collection count assertions for more expressive range checking: - HasAtLeast(minCount): asserts collection count >= minCount - HasAtMost(maxCount): asserts collection count <= maxCount - HasCountBetween(min, max): asserts min <= collection count <= max Implemented for both IEnumerable<T> (via CollectionAssertionBase) and IAsyncEnumerable<T> (via AsyncEnumerableAssertionBase), following existing patterns with CollectionChecks delegation and EnumerableAdapter usage. Closes #4869
1 parent c0a521c commit ed8b1b3

5 files changed

Lines changed: 310 additions & 0 deletions

File tree

TUnit.Assertions/Collections/CollectionChecks.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,48 @@ public static AssertionResult CheckCount<TItem>(ICollectionAdapter<TItem> adapte
160160
return AssertionResult.Failed($"found {actual}");
161161
}
162162

163+
/// <summary>
164+
/// Checks if the collection has at least the specified minimum number of items.
165+
/// </summary>
166+
public static AssertionResult CheckHasAtLeast<TItem>(ICollectionAdapter<TItem> adapter, int minCount)
167+
{
168+
var actual = adapter.Count;
169+
if (actual >= minCount)
170+
{
171+
return AssertionResult.Passed;
172+
}
173+
174+
return AssertionResult.Failed($"found {actual}");
175+
}
176+
177+
/// <summary>
178+
/// Checks if the collection has at most the specified maximum number of items.
179+
/// </summary>
180+
public static AssertionResult CheckHasAtMost<TItem>(ICollectionAdapter<TItem> adapter, int maxCount)
181+
{
182+
var actual = adapter.Count;
183+
if (actual <= maxCount)
184+
{
185+
return AssertionResult.Passed;
186+
}
187+
188+
return AssertionResult.Failed($"found {actual}");
189+
}
190+
191+
/// <summary>
192+
/// Checks if the collection count is between the specified minimum and maximum (inclusive).
193+
/// </summary>
194+
public static AssertionResult CheckHasCountBetween<TItem>(ICollectionAdapter<TItem> adapter, int min, int max)
195+
{
196+
var actual = adapter.Count;
197+
if (actual >= min && actual <= max)
198+
{
199+
return AssertionResult.Passed;
200+
}
201+
202+
return AssertionResult.Failed($"found {actual}");
203+
}
204+
163205
/// <summary>
164206
/// Checks if the collection has exactly one item.
165207
/// </summary>

TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,84 @@ protected override AssertionResult CheckMaterialized(List<TItem> items)
111111
protected override string GetExpectation() => $"to have {_expected} items";
112112
}
113113

114+
/// <summary>
115+
/// Asserts that the async enumerable has at least the specified minimum number of items.
116+
/// </summary>
117+
public class AsyncEnumerableHasAtLeastAssertion<TItem> : AsyncEnumerableAssertionConditionBase<TItem>
118+
{
119+
private readonly int _minCount;
120+
121+
public AsyncEnumerableHasAtLeastAssertion(
122+
AssertionContext<IAsyncEnumerable<TItem>> context,
123+
int minCount)
124+
: base(context)
125+
{
126+
_minCount = minCount;
127+
}
128+
129+
protected override AssertionResult CheckMaterialized(List<TItem> items)
130+
{
131+
return items.Count >= _minCount
132+
? AssertionResult.Passed
133+
: AssertionResult.Failed($"found {items.Count} items");
134+
}
135+
136+
protected override string GetExpectation() => $"to have at least {_minCount} item(s)";
137+
}
138+
139+
/// <summary>
140+
/// Asserts that the async enumerable has at most the specified maximum number of items.
141+
/// </summary>
142+
public class AsyncEnumerableHasAtMostAssertion<TItem> : AsyncEnumerableAssertionConditionBase<TItem>
143+
{
144+
private readonly int _maxCount;
145+
146+
public AsyncEnumerableHasAtMostAssertion(
147+
AssertionContext<IAsyncEnumerable<TItem>> context,
148+
int maxCount)
149+
: base(context)
150+
{
151+
_maxCount = maxCount;
152+
}
153+
154+
protected override AssertionResult CheckMaterialized(List<TItem> items)
155+
{
156+
return items.Count <= _maxCount
157+
? AssertionResult.Passed
158+
: AssertionResult.Failed($"found {items.Count} items");
159+
}
160+
161+
protected override string GetExpectation() => $"to have at most {_maxCount} item(s)";
162+
}
163+
164+
/// <summary>
165+
/// Asserts that the async enumerable count is between the specified minimum and maximum (inclusive).
166+
/// </summary>
167+
public class AsyncEnumerableHasCountBetweenAssertion<TItem> : AsyncEnumerableAssertionConditionBase<TItem>
168+
{
169+
private readonly int _min;
170+
private readonly int _max;
171+
172+
public AsyncEnumerableHasCountBetweenAssertion(
173+
AssertionContext<IAsyncEnumerable<TItem>> context,
174+
int min,
175+
int max)
176+
: base(context)
177+
{
178+
_min = min;
179+
_max = max;
180+
}
181+
182+
protected override AssertionResult CheckMaterialized(List<TItem> items)
183+
{
184+
return items.Count >= _min && items.Count <= _max
185+
? AssertionResult.Passed
186+
: AssertionResult.Failed($"found {items.Count} items");
187+
}
188+
189+
protected override string GetExpectation() => $"to have count between {_min} and {_max}";
190+
}
191+
114192
/// <summary>
115193
/// Asserts that the async enumerable contains or does not contain the expected item.
116194
/// </summary>

TUnit.Assertions/Conditions/CollectionAssertions.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,117 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti
230230
protected override string GetExpectation() => $"to have count {_expectedCount}";
231231
}
232232

233+
/// <summary>
234+
/// Asserts that a collection has at least the specified minimum number of items (count >= minCount).
235+
/// Delegates to CollectionChecks for the actual logic.
236+
/// </summary>
237+
public class CollectionHasAtLeastAssertion<TCollection, TItem> : Sources.CollectionAssertionBase<TCollection, TItem>
238+
where TCollection : IEnumerable<TItem>
239+
{
240+
private readonly int _minCount;
241+
242+
public CollectionHasAtLeastAssertion(
243+
AssertionContext<TCollection> context,
244+
int minCount)
245+
: base(context)
246+
{
247+
_minCount = minCount;
248+
}
249+
250+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
251+
{
252+
if (metadata.Exception != null)
253+
{
254+
return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}"));
255+
}
256+
257+
if (metadata.Value == null)
258+
{
259+
return Task.FromResult(AssertionResult.Failed("collection was null"));
260+
}
261+
262+
var adapter = new EnumerableAdapter<TItem>(metadata.Value);
263+
return Task.FromResult(CollectionChecks.CheckHasAtLeast(adapter, _minCount));
264+
}
265+
266+
protected override string GetExpectation() => $"to have at least {_minCount} item(s)";
267+
}
268+
269+
/// <summary>
270+
/// Asserts that a collection has at most the specified maximum number of items (count <= maxCount).
271+
/// Delegates to CollectionChecks for the actual logic.
272+
/// </summary>
273+
public class CollectionHasAtMostAssertion<TCollection, TItem> : Sources.CollectionAssertionBase<TCollection, TItem>
274+
where TCollection : IEnumerable<TItem>
275+
{
276+
private readonly int _maxCount;
277+
278+
public CollectionHasAtMostAssertion(
279+
AssertionContext<TCollection> context,
280+
int maxCount)
281+
: base(context)
282+
{
283+
_maxCount = maxCount;
284+
}
285+
286+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
287+
{
288+
if (metadata.Exception != null)
289+
{
290+
return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}"));
291+
}
292+
293+
if (metadata.Value == null)
294+
{
295+
return Task.FromResult(AssertionResult.Failed("collection was null"));
296+
}
297+
298+
var adapter = new EnumerableAdapter<TItem>(metadata.Value);
299+
return Task.FromResult(CollectionChecks.CheckHasAtMost(adapter, _maxCount));
300+
}
301+
302+
protected override string GetExpectation() => $"to have at most {_maxCount} item(s)";
303+
}
304+
305+
/// <summary>
306+
/// Asserts that a collection count is between the specified minimum and maximum (inclusive).
307+
/// Delegates to CollectionChecks for the actual logic.
308+
/// </summary>
309+
public class CollectionHasCountBetweenAssertion<TCollection, TItem> : Sources.CollectionAssertionBase<TCollection, TItem>
310+
where TCollection : IEnumerable<TItem>
311+
{
312+
private readonly int _min;
313+
private readonly int _max;
314+
315+
public CollectionHasCountBetweenAssertion(
316+
AssertionContext<TCollection> context,
317+
int min,
318+
int max)
319+
: base(context)
320+
{
321+
_min = min;
322+
_max = max;
323+
}
324+
325+
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
326+
{
327+
if (metadata.Exception != null)
328+
{
329+
return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}"));
330+
}
331+
332+
if (metadata.Value == null)
333+
{
334+
return Task.FromResult(AssertionResult.Failed("collection was null"));
335+
}
336+
337+
var adapter = new EnumerableAdapter<TItem>(metadata.Value);
338+
return Task.FromResult(CollectionChecks.CheckHasCountBetween(adapter, _min, _max));
339+
}
340+
341+
protected override string GetExpectation() => $"to have count between {_min} and {_max}";
342+
}
343+
233344
/// <summary>
234345
/// Helper for All().Satisfy() pattern - allows custom assertions on all collection items.
235346
/// </summary>

TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,44 @@ public AsyncEnumerableHasCountAssertion<TItem> HasCount(
7171
return new AsyncEnumerableHasCountAssertion<TItem>(Context, expected);
7272
}
7373

74+
/// <summary>
75+
/// Asserts that the async enumerable has at least the specified minimum number of items (count >= minCount).
76+
/// Example: await Assert.That(asyncEnumerable).HasAtLeast(3);
77+
/// </summary>
78+
public AsyncEnumerableHasAtLeastAssertion<TItem> HasAtLeast(
79+
int minCount,
80+
[CallerArgumentExpression(nameof(minCount))] string? expression = null)
81+
{
82+
Context.ExpressionBuilder.Append($".HasAtLeast({expression})");
83+
return new AsyncEnumerableHasAtLeastAssertion<TItem>(Context, minCount);
84+
}
85+
86+
/// <summary>
87+
/// Asserts that the async enumerable has at most the specified maximum number of items (count <= maxCount).
88+
/// Example: await Assert.That(asyncEnumerable).HasAtMost(10);
89+
/// </summary>
90+
public AsyncEnumerableHasAtMostAssertion<TItem> HasAtMost(
91+
int maxCount,
92+
[CallerArgumentExpression(nameof(maxCount))] string? expression = null)
93+
{
94+
Context.ExpressionBuilder.Append($".HasAtMost({expression})");
95+
return new AsyncEnumerableHasAtMostAssertion<TItem>(Context, maxCount);
96+
}
97+
98+
/// <summary>
99+
/// Asserts that the async enumerable count is between the specified minimum and maximum (inclusive).
100+
/// Example: await Assert.That(asyncEnumerable).HasCountBetween(2, 5);
101+
/// </summary>
102+
public AsyncEnumerableHasCountBetweenAssertion<TItem> HasCountBetween(
103+
int min,
104+
int max,
105+
[CallerArgumentExpression(nameof(min))] string? minExpression = null,
106+
[CallerArgumentExpression(nameof(max))] string? maxExpression = null)
107+
{
108+
Context.ExpressionBuilder.Append($".HasCountBetween({minExpression}, {maxExpression})");
109+
return new AsyncEnumerableHasCountBetweenAssertion<TItem>(Context, min, max);
110+
}
111+
74112
/// <summary>
75113
/// Asserts that the async enumerable contains the expected item.
76114
/// Example: await Assert.That(asyncEnumerable).Contains(5);

TUnit.Assertions/Sources/CollectionAssertionBase.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,47 @@ public HasSingleItemPredicateAssertion<TCollection, TItem> HasSingleItem(
273273
return new HasSingleItemPredicateAssertion<TCollection, TItem>(Context, predicate, expression ?? "predicate");
274274
}
275275

276+
/// <summary>
277+
/// Asserts that the collection has at least the specified minimum number of items (count >= minCount).
278+
/// This instance method enables calling HasAtLeast with proper type inference.
279+
/// Example: await Assert.That(list).HasAtLeast(3);
280+
/// </summary>
281+
public CollectionHasAtLeastAssertion<TCollection, TItem> HasAtLeast(
282+
int minCount,
283+
[CallerArgumentExpression(nameof(minCount))] string? expression = null)
284+
{
285+
Context.ExpressionBuilder.Append($".HasAtLeast({expression})");
286+
return new CollectionHasAtLeastAssertion<TCollection, TItem>(Context, minCount);
287+
}
288+
289+
/// <summary>
290+
/// Asserts that the collection has at most the specified maximum number of items (count <= maxCount).
291+
/// This instance method enables calling HasAtMost with proper type inference.
292+
/// Example: await Assert.That(list).HasAtMost(10);
293+
/// </summary>
294+
public CollectionHasAtMostAssertion<TCollection, TItem> HasAtMost(
295+
int maxCount,
296+
[CallerArgumentExpression(nameof(maxCount))] string? expression = null)
297+
{
298+
Context.ExpressionBuilder.Append($".HasAtMost({expression})");
299+
return new CollectionHasAtMostAssertion<TCollection, TItem>(Context, maxCount);
300+
}
301+
302+
/// <summary>
303+
/// Asserts that the collection count is between the specified minimum and maximum (inclusive).
304+
/// This instance method enables calling HasCountBetween with proper type inference.
305+
/// Example: await Assert.That(list).HasCountBetween(2, 5);
306+
/// </summary>
307+
public CollectionHasCountBetweenAssertion<TCollection, TItem> HasCountBetween(
308+
int min,
309+
int max,
310+
[CallerArgumentExpression(nameof(min))] string? minExpression = null,
311+
[CallerArgumentExpression(nameof(max))] string? maxExpression = null)
312+
{
313+
Context.ExpressionBuilder.Append($".HasCountBetween({minExpression}, {maxExpression})");
314+
return new CollectionHasCountBetweenAssertion<TCollection, TItem>(Context, min, max);
315+
}
316+
276317
/// <summary>
277318
/// Asserts that the collection contains only distinct (unique) items.
278319
/// This instance method enables calling HasDistinctItems with proper type inference.

0 commit comments

Comments
 (0)