From 4f8a0eeff824c5f1f2d9f2cd6eeb81d676a8c620 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:15:40 +0000 Subject: [PATCH 1/2] feat: add Count method with inner assertion support for collections --- .../CollectionAssertionTests.cs | 91 +++++++++++++++++++ .../CollectionCountValueAssertion.cs | 63 ++++++++++++- .../Sources/CollectionAssertionBase.cs | 14 +++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 7 ++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 6 ++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 7 ++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 6 ++ 7 files changed, 193 insertions(+), 1 deletion(-) diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs index 1cc925828eb..6b864b8298c 100644 --- a/TUnit.Assertions.Tests/CollectionAssertionTests.cs +++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs @@ -1,3 +1,5 @@ +using TUnit.Assertions.Extensions; + namespace TUnit.Assertions.Tests; public class CollectionAssertionTests @@ -33,4 +35,93 @@ public async Task Count2() await Assert.That(() => items).Count().IsEqualTo(0); } + + [Test] + public async Task Count_WithPredicate() + { + var items = new List { 1, 2, 3, 4, 5 }; + + await Assert.That(items).Count(x => x > 2).IsEqualTo(3); + } + + [Test] + public async Task Count_WithInnerAssertion_IsGreaterThan() + { + var items = new List { 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 { 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 { 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 { "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 { "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(); + + // 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 { 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 { 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 { 1, 2, 3, 4, 5 }; + + // Test with lambda-wrapped collection + await Assert.That(() => items).Count(item => item.IsGreaterThan(2)).IsEqualTo(3); + } } diff --git a/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs b/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs index 2b1c5a9670a..c47ad6935fc 100644 --- a/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs @@ -1,5 +1,6 @@ using System.Collections; using TUnit.Assertions.Core; +using TUnit.Assertions.Sources; namespace TUnit.Assertions.Conditions; @@ -7,7 +8,7 @@ namespace TUnit.Assertions.Conditions; /// Assertion that evaluates the count of a collection and provides numeric assertions on that count. /// Implements IAssertionSource<int> to enable all numeric assertion methods. /// -public class CollectionCountValueAssertion : Sources.ValueAssertion +public class CollectionCountValueAssertion : ValueAssertion where TCollection : IEnumerable { public CollectionCountValueAssertion( @@ -42,3 +43,63 @@ private static AssertionContext CreateIntContext( }); } } + +/// +/// Assertion that evaluates the count of items satisfying an inner assertion and provides numeric assertions on that count. +/// Implements IAssertionSource<int> to enable all numeric assertion methods. +/// This allows using the full assertion builder (e.g., item => item.IsGreaterThan(10)) instead of a simple predicate. +/// +public class CollectionCountWithAssertionValueAssertion : ValueAssertion + where TCollection : IEnumerable +{ + public CollectionCountWithAssertionValueAssertion( + AssertionContext collectionContext, + Func, Assertion?> assertion) + : base(CreateIntContext(collectionContext, assertion)) + { + } + + private static AssertionContext CreateIntContext( + AssertionContext collectionContext, + Func, Assertion?> assertion) + { + return collectionContext.Map(async collection => + { + if (collection == null) + { + return 0; + } + + int count = 0; + int index = 0; + + foreach (var item in collection) + { + var itemAssertion = new ValueAssertion(item, $"item[{index}]"); + var resultingAssertion = assertion(itemAssertion); + + if (resultingAssertion != null) + { + try + { + await resultingAssertion.AssertAsync(); + count++; + } + catch + { + // Item did not satisfy the assertion, don't count it + } + } + else + { + // Null assertion means no constraint, count all items + count++; + } + + index++; + } + + return count; + }); + } +} diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index d6e0ee81f4a..029d5dab8be 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -154,6 +154,20 @@ public CollectionCountValueAssertion Count( return new CollectionCountValueAssertion(Context, predicate); } + /// + /// 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); + /// + [OverloadResolutionPriority(1)] + public CollectionCountWithAssertionValueAssertion Count( + Func, Assertion?> assertion, + [CallerArgumentExpression(nameof(assertion))] string? expression = null) + { + Context.ExpressionBuilder.Append($".Count({expression})"); + return new CollectionCountWithAssertionValueAssertion(Context, assertion); + } + /// /// Asserts that the collection is ordered by the specified key selector in ascending order. /// This instance method enables calling IsOrderedBy with proper type inference. diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index e1e5f6bbf0f..bf5fb4f8bdc 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -426,6 +426,11 @@ namespace .Conditions { public CollectionCountValueAssertion(. collectionContext, ? predicate) { } } + public class CollectionCountWithAssertionValueAssertion : . + where TCollection : . + { + public CollectionCountWithAssertionValueAssertion(. collectionContext, <., .?> assertion) { } + } [.("DoesNotContain")] public class CollectionDoesNotContainAssertion : . where TCollection : . @@ -4367,6 +4372,8 @@ namespace .Sources public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } public . Count( predicate, [.("predicate")] string? expression = null) { } + [.(1)] + public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } public . DoesNotContain(TItem expected, [.("expected")] string? expression = null) { } protected override string GetExpectation() { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index d5b2de52a29..96bc253b5bf 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -421,6 +421,11 @@ namespace .Conditions { public CollectionCountValueAssertion(. collectionContext, ? predicate) { } } + public class CollectionCountWithAssertionValueAssertion : . + where TCollection : . + { + public CollectionCountWithAssertionValueAssertion(. collectionContext, <., .?> assertion) { } + } [.("DoesNotContain")] public class CollectionDoesNotContainAssertion : . where TCollection : . @@ -4345,6 +4350,7 @@ namespace .Sources public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } public . Count( predicate, [.("predicate")] string? expression = null) { } + public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } public . DoesNotContain(TItem expected, [.("expected")] string? expression = null) { } protected override string GetExpectation() { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index a6c9c276211..ed72df174be 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -426,6 +426,11 @@ namespace .Conditions { public CollectionCountValueAssertion(. collectionContext, ? predicate) { } } + public class CollectionCountWithAssertionValueAssertion : . + where TCollection : . + { + public CollectionCountWithAssertionValueAssertion(. collectionContext, <., .?> assertion) { } + } [.("DoesNotContain")] public class CollectionDoesNotContainAssertion : . where TCollection : . @@ -4367,6 +4372,8 @@ namespace .Sources public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } public . Count( predicate, [.("predicate")] string? expression = null) { } + [.(1)] + public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } public . DoesNotContain(TItem expected, [.("expected")] string? expression = null) { } protected override string GetExpectation() { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 6e579544ed8..097a16a6829 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -417,6 +417,11 @@ namespace .Conditions { public CollectionCountValueAssertion(. collectionContext, ? predicate) { } } + public class CollectionCountWithAssertionValueAssertion : . + where TCollection : . + { + public CollectionCountWithAssertionValueAssertion(. collectionContext, <., .?> assertion) { } + } [.("DoesNotContain")] public class CollectionDoesNotContainAssertion : . where TCollection : . @@ -3834,6 +3839,7 @@ namespace .Sources public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } public . Count( predicate, [.("predicate")] string? expression = null) { } + public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } public . DoesNotContain(TItem expected, [.("expected")] string? expression = null) { } protected override string GetExpectation() { } From f030baa5a811d74c3156cc758c1c66fbc69e3cef Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:21:15 +0000 Subject: [PATCH 2/2] feat: add Count method with inner assertion support for collections --- .../CollectionAssertionTests.cs | 4 ++ .../Sources/CollectionAssertionBase.cs | 1 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 2 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 + ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 + docs/docs/assertions/collections.md | 37 +++++++++++++------ 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs index 6b864b8298c..751e68dc5fc 100644 --- a/TUnit.Assertions.Tests/CollectionAssertionTests.cs +++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using TUnit.Assertions.Extensions; namespace TUnit.Assertions.Tests; @@ -37,11 +38,14 @@ public async Task Count2() } [Test] + [SuppressMessage("Obsolete", "CS0618:Type or member is obsolete")] public async Task Count_WithPredicate() { var items = new List { 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] diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index 029d5dab8be..96964b17ec4 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -146,6 +146,7 @@ public CollectionCountValueAssertion Count() /// This enables fluent assertions on filtered counts. /// Example: await Assert.That(list).Count(x => x > 10).IsEqualTo(3); /// + [Obsolete("Use Count(item => item.YourAssertion()) instead to leverage the full assertion builder. Example: Count(item => item.IsGreaterThan(10))")] public CollectionCountValueAssertion Count( Func predicate, [CallerArgumentExpression(nameof(predicate))] string? expression = null) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index bf5fb4f8bdc..3fb14f4de14 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -4371,6 +4371,8 @@ namespace .Sources public . Contains(TItem expected, [.("expected")] string? expression = null) { } public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } + [("Use Count(item => ()) instead to leverage the full assertion bu" + + "ilder. Example: Count(item => (10))")] public . Count( predicate, [.("predicate")] string? expression = null) { } [.(1)] public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 96bc253b5bf..af9658c1fa2 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -4349,6 +4349,8 @@ namespace .Sources public . Contains(TItem expected, [.("expected")] string? expression = null) { } public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } + [("Use Count(item => ()) instead to leverage the full assertion bu" + + "ilder. Example: Count(item => (10))")] public . Count( predicate, [.("predicate")] string? expression = null) { } public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index ed72df174be..66d5d3d2827 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -4371,6 +4371,8 @@ namespace .Sources public . Contains(TItem expected, [.("expected")] string? expression = null) { } public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } + [("Use Count(item => ()) instead to leverage the full assertion bu" + + "ilder. Example: Count(item => (10))")] public . Count( predicate, [.("predicate")] string? expression = null) { } [.(1)] public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 097a16a6829..0b1c05b81b4 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -3838,6 +3838,8 @@ namespace .Sources public . Contains(TItem expected, [.("expected")] string? expression = null) { } public . ContainsOnly( predicate, [.("predicate")] string? expression = null) { } public . Count() { } + [("Use Count(item => ()) instead to leverage the full assertion bu" + + "ilder. Example: Count(item => (10))")] public . Count( predicate, [.("predicate")] string? expression = null) { } public . Count(<., .?> assertion, [.("assertion")] string? expression = null) { } public . DoesNotContain( predicate, [.("predicate")] string? expression = null) { } diff --git a/docs/docs/assertions/collections.md b/docs/docs/assertions/collections.md index db496652636..f35d0f9a521 100644 --- a/docs/docs/assertions/collections.md +++ b/docs/docs/assertions/collections.md @@ -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); } ``` @@ -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 }); @@ -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( @@ -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); @@ -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();