Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions TUnit.Assertions.Tests/CollectionAssertionTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Assertions.Extensions;

namespace TUnit.Assertions.Tests;

public class CollectionAssertionTests
Expand Down Expand Up @@ -33,4 +36,96 @@ public async Task Count2()

await Assert.That(() => items).Count().IsEqualTo(0);
}

[Test]
[SuppressMessage("Obsolete", "CS0618:Type or member is obsolete")]
public async Task Count_WithPredicate()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

#pragma warning disable CS0618 // Type or member is obsolete
await Assert.That(items).Count(x => x > 2).IsEqualTo(3);
#pragma warning restore CS0618
}

[Test]
public async Task Count_WithInnerAssertion_IsGreaterThan()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Count items where item > 2 using inner assertion builder
await Assert.That(items).Count(item => item.IsGreaterThan(2)).IsEqualTo(3);
}

[Test]
public async Task Count_WithInnerAssertion_IsLessThan()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Count items where item < 4 using inner assertion builder
await Assert.That(items).Count(item => item.IsLessThan(4)).IsEqualTo(3);
}

[Test]
public async Task Count_WithInnerAssertion_IsBetween()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Count items where 2 <= item <= 4 using inner assertion builder
await Assert.That(items).Count(item => item.IsBetween(2, 4)).IsEqualTo(3);
}

[Test]
public async Task Count_WithInnerAssertion_String_Contains()
{
var items = new List<string> { "apple", "banana", "apricot", "cherry" };

// Count items that contain "ap" using inner assertion builder
await Assert.That(items).Count(item => item.Contains("ap")).IsEqualTo(2);
}

[Test]
public async Task Count_WithInnerAssertion_String_StartsWith()
{
var items = new List<string> { "apple", "banana", "apricot", "cherry" };

// Count items that start with "a" using inner assertion builder
await Assert.That(items).Count(item => item.StartsWith("a")).IsEqualTo(2);
}

[Test]
public async Task Count_WithInnerAssertion_EmptyCollection()
{
var items = new List<int>();

// Count on empty collection should return 0
await Assert.That(items).Count(item => item.IsGreaterThan(0)).IsEqualTo(0);
}

[Test]
public async Task Count_WithInnerAssertion_NoneMatch()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Count items > 10 (none match)
await Assert.That(items).Count(item => item.IsGreaterThan(10)).IsEqualTo(0);
}

[Test]
public async Task Count_WithInnerAssertion_AllMatch()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Count items > 0 (all match)
await Assert.That(items).Count(item => item.IsGreaterThan(0)).IsEqualTo(5);
}

[Test]
public async Task Count_WithInnerAssertion_Lambda_Collection()
{
var items = new List<int> { 1, 2, 3, 4, 5 };

// Test with lambda-wrapped collection
await Assert.That(() => items).Count(item => item.IsGreaterThan(2)).IsEqualTo(3);
}
}
63 changes: 62 additions & 1 deletion TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using System.Collections;
using TUnit.Assertions.Core;
using TUnit.Assertions.Sources;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Assertion that evaluates the count of a collection and provides numeric assertions on that count.
/// Implements IAssertionSource&lt;int&gt; to enable all numeric assertion methods.
/// </summary>
public class CollectionCountValueAssertion<TCollection, TItem> : Sources.ValueAssertion<int>
public class CollectionCountValueAssertion<TCollection, TItem> : ValueAssertion<int>
where TCollection : IEnumerable<TItem>
{
public CollectionCountValueAssertion(
Expand Down Expand Up @@ -42,3 +43,63 @@ private static AssertionContext<int> CreateIntContext(
});
}
}

/// <summary>
/// Assertion that evaluates the count of items satisfying an inner assertion and provides numeric assertions on that count.
/// Implements IAssertionSource&lt;int&gt; to enable all numeric assertion methods.
/// This allows using the full assertion builder (e.g., item => item.IsGreaterThan(10)) instead of a simple predicate.
/// </summary>
public class CollectionCountWithAssertionValueAssertion<TCollection, TItem> : ValueAssertion<int>
where TCollection : IEnumerable<TItem>
{
public CollectionCountWithAssertionValueAssertion(
AssertionContext<TCollection> collectionContext,
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion)
: base(CreateIntContext(collectionContext, assertion))
{
}

private static AssertionContext<int> CreateIntContext(
AssertionContext<TCollection> collectionContext,
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion)
{
return collectionContext.Map<int>(async collection =>
{
if (collection == null)
{
return 0;
}

int count = 0;
int index = 0;

foreach (var item in collection)
{
var itemAssertion = new ValueAssertion<TItem>(item, $"item[{index}]");
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

String interpolation in a loop creates a new string for each iteration. Since this expression is only used for potential error messages in the inner assertion and not for the count logic itself, consider if this allocation is necessary in a hot path.

If the expression isn't critical for this use case, you might pass null or a constant string to avoid repeated allocations. Alternatively, use a cached format if the expression is needed.

Suggested change
var itemAssertion = new ValueAssertion<TItem>(item, $"item[{index}]");
var itemAssertion = new ValueAssertion<TItem>(item, null);

Copilot uses AI. Check for mistakes.
var resultingAssertion = assertion(itemAssertion);

if (resultingAssertion != null)
{
try
{
await resultingAssertion.AssertAsync();
count++;
}
catch
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

Bare catch blocks without exception type specification should be avoided as they catch all exceptions, including system-critical ones like OutOfMemoryException, StackOverflowException, and ThreadAbortException. This can hide unexpected bugs and make debugging difficult.

Change to catch (AssertionException) to specifically catch only assertion failures, which is the expected exception type from AssertAsync(). Other exception types (e.g., NullReferenceException, InvalidOperationException) likely indicate bugs that should not be silently ignored.

Copilot uses AI. Check for mistakes.
{
// Item did not satisfy the assertion, don't count it
}
Comment on lines +83 to +91
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

Using exceptions for control flow is a performance anti-pattern. When items don't satisfy the assertion, an AssertionException is thrown and caught for each non-matching item. Exception creation and stack trace generation is expensive.

Consider checking if Assertion<TItem> provides a non-throwing method to evaluate the assertion result, similar to patterns used elsewhere in the codebase with CheckAsync returning AssertionResult. If not available, document this performance characteristic or consider an alternative approach that doesn't rely on exception handling for expected control flow.

Suggested change
try
{
await resultingAssertion.AssertAsync();
count++;
}
catch
{
// Item did not satisfy the assertion, don't count it
}
var result = await resultingAssertion.CheckAsync();
if (result.IsPassed)
{
count++;
}
// If not passed, don't count it

Copilot uses AI. Check for mistakes.
}
else
{
// Null assertion means no constraint, count all items
count++;
}

index++;
}

return count;
});
}
}
15 changes: 15 additions & 0 deletions TUnit.Assertions/Sources/CollectionAssertionBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public CollectionCountValueAssertion<TCollection, TItem> Count()
/// This enables fluent assertions on filtered counts.
/// Example: await Assert.That(list).Count(x => x > 10).IsEqualTo(3);
/// </summary>
[Obsolete("Use Count(item => item.YourAssertion()) instead to leverage the full assertion builder. Example: Count(item => item.IsGreaterThan(10))")]
public CollectionCountValueAssertion<TCollection, TItem> Count(
Func<TItem, bool> predicate,
[CallerArgumentExpression(nameof(predicate))] string? expression = null)
Expand All @@ -154,6 +155,20 @@ public CollectionCountValueAssertion<TCollection, TItem> Count(
return new CollectionCountValueAssertion<TCollection, TItem>(Context, predicate);
}

/// <summary>
/// Gets the count of items satisfying the given assertion for further numeric assertions.
/// This enables fluent assertions on filtered counts using the full assertion builder.
/// Example: await Assert.That(list).Count(item => item.IsGreaterThan(10)).IsEqualTo(3);
/// </summary>
[OverloadResolutionPriority(1)]
public CollectionCountWithAssertionValueAssertion<TCollection, TItem> Count(
Func<IAssertionSource<TItem>, Assertion<TItem>?> assertion,
[CallerArgumentExpression(nameof(assertion))] string? expression = null)
{
Context.ExpressionBuilder.Append($".Count({expression})");
return new CollectionCountWithAssertionValueAssertion<TCollection, TItem>(Context, assertion);
}

/// <summary>
/// Asserts that the collection is ordered by the specified key selector in ascending order.
/// This instance method enables calling IsOrderedBy with proper type inference.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,11 @@ namespace .Conditions
{
public CollectionCountValueAssertion(.<TCollection> collectionContext, <TItem, bool>? predicate) { }
}
public class CollectionCountWithAssertionValueAssertion<TCollection, TItem> : .<int>
where TCollection : .<TItem>
{
public CollectionCountWithAssertionValueAssertion(.<TCollection> collectionContext, <.<TItem>, .<TItem>?> assertion) { }
}
[.("DoesNotContain")]
public class CollectionDoesNotContainAssertion<TCollection, TItem> : .<TCollection, TItem>
where TCollection : .<TItem>
Expand Down Expand Up @@ -4366,7 +4371,11 @@ namespace .Sources
public .<TCollection, TItem> Contains(TItem expected, [.("expected")] string? expression = null) { }
public .<TCollection, TItem> ContainsOnly(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count() { }
[("Use Count(item => ()) instead to leverage the full assertion bu" +
"ilder. Example: Count(item => (10))")]
public .<TCollection, TItem> Count(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
[.(1)]
public .<TCollection, TItem> Count(<.<TItem>, .<TItem>?> assertion, [.("assertion")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(TItem expected, [.("expected")] string? expression = null) { }
protected override string GetExpectation() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,11 @@ namespace .Conditions
{
public CollectionCountValueAssertion(.<TCollection> collectionContext, <TItem, bool>? predicate) { }
}
public class CollectionCountWithAssertionValueAssertion<TCollection, TItem> : .<int>
where TCollection : .<TItem>
{
public CollectionCountWithAssertionValueAssertion(.<TCollection> collectionContext, <.<TItem>, .<TItem>?> assertion) { }
}
[.("DoesNotContain")]
public class CollectionDoesNotContainAssertion<TCollection, TItem> : .<TCollection, TItem>
where TCollection : .<TItem>
Expand Down Expand Up @@ -4344,7 +4349,10 @@ namespace .Sources
public .<TCollection, TItem> Contains(TItem expected, [.("expected")] string? expression = null) { }
public .<TCollection, TItem> ContainsOnly(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count() { }
[("Use Count(item => ()) instead to leverage the full assertion bu" +
"ilder. Example: Count(item => (10))")]
public .<TCollection, TItem> Count(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count(<.<TItem>, .<TItem>?> assertion, [.("assertion")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(TItem expected, [.("expected")] string? expression = null) { }
protected override string GetExpectation() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,11 @@ namespace .Conditions
{
public CollectionCountValueAssertion(.<TCollection> collectionContext, <TItem, bool>? predicate) { }
}
public class CollectionCountWithAssertionValueAssertion<TCollection, TItem> : .<int>
where TCollection : .<TItem>
{
public CollectionCountWithAssertionValueAssertion(.<TCollection> collectionContext, <.<TItem>, .<TItem>?> assertion) { }
}
[.("DoesNotContain")]
public class CollectionDoesNotContainAssertion<TCollection, TItem> : .<TCollection, TItem>
where TCollection : .<TItem>
Expand Down Expand Up @@ -4366,7 +4371,11 @@ namespace .Sources
public .<TCollection, TItem> Contains(TItem expected, [.("expected")] string? expression = null) { }
public .<TCollection, TItem> ContainsOnly(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count() { }
[("Use Count(item => ()) instead to leverage the full assertion bu" +
"ilder. Example: Count(item => (10))")]
public .<TCollection, TItem> Count(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
[.(1)]
public .<TCollection, TItem> Count(<.<TItem>, .<TItem>?> assertion, [.("assertion")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(TItem expected, [.("expected")] string? expression = null) { }
protected override string GetExpectation() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ namespace .Conditions
{
public CollectionCountValueAssertion(.<TCollection> collectionContext, <TItem, bool>? predicate) { }
}
public class CollectionCountWithAssertionValueAssertion<TCollection, TItem> : .<int>
where TCollection : .<TItem>
{
public CollectionCountWithAssertionValueAssertion(.<TCollection> collectionContext, <.<TItem>, .<TItem>?> assertion) { }
}
[.("DoesNotContain")]
public class CollectionDoesNotContainAssertion<TCollection, TItem> : .<TCollection, TItem>
where TCollection : .<TItem>
Expand Down Expand Up @@ -3833,7 +3838,10 @@ namespace .Sources
public .<TCollection, TItem> Contains(TItem expected, [.("expected")] string? expression = null) { }
public .<TCollection, TItem> ContainsOnly(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count() { }
[("Use Count(item => ()) instead to leverage the full assertion bu" +
"ilder. Example: Count(item => (10))")]
public .<TCollection, TItem> Count(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> Count(<.<TItem>, .<TItem>?> assertion, [.("assertion")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(<TItem, bool> predicate, [.("predicate")] string? expression = null) { }
public .<TCollection, TItem> DoesNotContain(TItem expected, [.("expected")] string? expression = null) { }
protected override string GetExpectation() { }
Expand Down
37 changes: 26 additions & 11 deletions docs/docs/assertions/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,21 +131,36 @@ public async Task Count_With_Comparison()
}
```

### Count with Predicate
### Count with Inner Assertion

Count items matching a predicate:
Count items that satisfy an assertion, allowing you to reuse existing assertion methods:

```csharp
[Test]
public async Task Count_With_Predicate()
public async Task Count_With_Inner_Assertion()
{
var numbers = new[] { 1, 2, 3, 4, 5, 6 };

// Count even numbers
var evenCount = await Assert.That(numbers)
.Count(n => n % 2 == 0);
// Count numbers greater than 3 using assertion builder
await Assert.That(numbers)
.Count(item => item.IsGreaterThan(3))
.IsEqualTo(3);

// Count numbers between 2 and 5
await Assert.That(numbers)
.Count(item => item.IsBetween(2, 5))
.IsEqualTo(4);
}

[Test]
public async Task Count_Strings_With_Inner_Assertion()
{
var names = new[] { "Alice", "Bob", "Andrew", "Charlie" };

await Assert.That(evenCount).IsEqualTo(3);
// Count names starting with "A"
await Assert.That(names)
.Count(item => item.StartsWith("A"))
.IsEqualTo(2);
}
```

Expand Down Expand Up @@ -490,7 +505,7 @@ The default behavior (ignoring order) is ideal for:
public async Task Database_Query_Results()
{
var results = await database.GetActiveUsersAsync();

// Order doesn't matter for this assertion
await Assert.That(results)
.IsEquivalentTo(new[] { user1, user2, user3 });
Expand All @@ -510,7 +525,7 @@ Use order-sensitive comparison when:
public async Task Sorted_Query_Results()
{
var results = await database.GetUsersSortedByNameAsync();

// Order matters here
await Assert.That(results)
.IsEquivalentTo(
Expand All @@ -530,7 +545,7 @@ public async Task Multiple_Order_Sensitive_Checks()
{
var list1 = GetSortedList1();
var list2 = GetSortedList2();

// Be explicit about ordering requirements
await Assert.That(list1).IsEquivalentTo(expected1, CollectionOrdering.Matching);
await Assert.That(list2).IsEquivalentTo(expected2, CollectionOrdering.Matching);
Expand All @@ -544,7 +559,7 @@ For ordered comparisons, you can also use `IsInOrder()`:
public async Task Verify_Ordering_Separately()
{
var actual = new[] { 1, 2, 3 };

// Check both equivalency and ordering
await Assert.That(actual).IsEquivalentTo(new[] { 1, 2, 3 });
await Assert.That(actual).IsInOrder();
Expand Down
Loading