Skip to content

Commit 989adb7

Browse files
authored
feat: improve assertion typing for enumerable returning delegates (#3927)
* feat: improve assertion typing for enumerable returning delegates * feat: add support for lambda-wrapped collection assertions in FuncCollectionAssertion and AsyncFuncCollectionAssertion * feat: add async and func collection assertions to assertion library
1 parent 472b487 commit 989adb7

9 files changed

+489
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace TUnit.Assertions.Tests;
2+
3+
public class CollectionAssertionTests
4+
{
5+
[Test]
6+
public async Task IsEmpty()
7+
{
8+
var items = new List<int>();
9+
10+
await Assert.That(items).IsEmpty();
11+
}
12+
13+
[Test]
14+
public async Task IsEmpty2()
15+
{
16+
var items = new List<int>();
17+
18+
await Assert.That(() => items).IsEmpty();
19+
}
20+
21+
[Test]
22+
public async Task Count()
23+
{
24+
var items = new List<int>();
25+
26+
await Assert.That(items).Count().IsEqualTo(0);
27+
}
28+
29+
[Test]
30+
public async Task Count2()
31+
{
32+
var items = new List<int>();
33+
34+
await Assert.That(() => items).Count().IsEqualTo(0);
35+
}
36+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
using TUnit.Assertions.Exceptions;
2+
3+
namespace TUnit.Assertions.Tests;
4+
5+
/// <summary>
6+
/// Tests for FuncCollectionAssertion - verifies that collection assertions work when
7+
/// collections are wrapped in lambdas. Addresses GitHub issue #3910.
8+
/// </summary>
9+
public class FuncCollectionAssertionTests
10+
{
11+
[Test]
12+
public async Task Lambda_Collection_IsEmpty_Passes_For_Empty_Collection()
13+
{
14+
var items = new List<int>();
15+
await Assert.That(() => items).IsEmpty();
16+
}
17+
18+
[Test]
19+
public async Task Lambda_Collection_IsEmpty_Fails_For_NonEmpty_Collection()
20+
{
21+
var items = new List<int> { 1, 2, 3 };
22+
await Assert.That(async () => await Assert.That(() => items).IsEmpty())
23+
.Throws<TUnitAssertionException>();
24+
}
25+
26+
[Test]
27+
public async Task Lambda_Collection_IsNotEmpty_Passes_For_NonEmpty_Collection()
28+
{
29+
var items = new List<int> { 1, 2, 3 };
30+
await Assert.That(() => items).IsNotEmpty();
31+
}
32+
33+
[Test]
34+
public async Task Lambda_Collection_IsNotEmpty_Fails_For_Empty_Collection()
35+
{
36+
var items = new List<int>();
37+
await Assert.That(async () => await Assert.That(() => items).IsNotEmpty())
38+
.Throws<TUnitAssertionException>();
39+
}
40+
41+
[Test]
42+
public async Task Lambda_Collection_Count_IsEqualTo_Passes()
43+
{
44+
var items = new List<int> { 1, 2, 3 };
45+
await Assert.That(() => items).Count().IsEqualTo(3);
46+
}
47+
48+
[Test]
49+
public async Task Lambda_Collection_Count_IsGreaterThan_Passes()
50+
{
51+
var items = new List<int> { 1, 2, 3, 4, 5 };
52+
await Assert.That(() => items).Count().IsGreaterThan(3);
53+
}
54+
55+
[Test]
56+
public async Task Lambda_Collection_Contains_Passes()
57+
{
58+
var items = new List<int> { 1, 2, 3 };
59+
await Assert.That(() => items).Contains(2);
60+
}
61+
62+
[Test]
63+
public async Task Lambda_Collection_Contains_Fails_When_Item_Not_Present()
64+
{
65+
var items = new List<int> { 1, 2, 3 };
66+
await Assert.That(async () => await Assert.That(() => items).Contains(99))
67+
.Throws<TUnitAssertionException>();
68+
}
69+
70+
[Test]
71+
public async Task Lambda_Collection_DoesNotContain_Passes()
72+
{
73+
var items = new List<int> { 1, 2, 3 };
74+
await Assert.That(() => items).DoesNotContain(99);
75+
}
76+
77+
[Test]
78+
public async Task Lambda_Collection_HasSingleItem_Passes()
79+
{
80+
var items = new List<int> { 42 };
81+
await Assert.That(() => items).HasSingleItem();
82+
}
83+
84+
[Test]
85+
public async Task Lambda_Collection_All_Passes()
86+
{
87+
var items = new List<int> { 2, 4, 6 };
88+
await Assert.That(() => items).All(x => x % 2 == 0);
89+
}
90+
91+
[Test]
92+
public async Task Lambda_Collection_Any_Passes()
93+
{
94+
var items = new List<int> { 1, 2, 3 };
95+
await Assert.That(() => items).Any(x => x > 2);
96+
}
97+
98+
[Test]
99+
public async Task Lambda_Collection_IsInOrder_Passes()
100+
{
101+
var items = new List<int> { 1, 2, 3, 4, 5 };
102+
await Assert.That(() => items).IsInOrder();
103+
}
104+
105+
[Test]
106+
public async Task Lambda_Collection_IsInDescendingOrder_Passes()
107+
{
108+
var items = new List<int> { 5, 4, 3, 2, 1 };
109+
await Assert.That(() => items).IsInDescendingOrder();
110+
}
111+
112+
[Test]
113+
public async Task Lambda_Collection_HasDistinctItems_Passes()
114+
{
115+
var items = new List<int> { 1, 2, 3 };
116+
await Assert.That(() => items).HasDistinctItems();
117+
}
118+
119+
[Test]
120+
public async Task Lambda_Collection_Throws_Passes_When_Exception_Thrown()
121+
{
122+
await Assert.That(() => ThrowingMethod()).Throws<InvalidOperationException>();
123+
}
124+
125+
[Test]
126+
public async Task Lambda_Collection_Chaining_With_And()
127+
{
128+
var items = new List<int> { 1, 2, 3 };
129+
await Assert.That(() => items)
130+
.IsNotEmpty()
131+
.And.Contains(2)
132+
.And.HasDistinctItems();
133+
}
134+
135+
[Test]
136+
public async Task Lambda_Array_IsEmpty_Passes()
137+
{
138+
var items = Array.Empty<string>();
139+
await Assert.That(() => items).IsEmpty();
140+
}
141+
142+
[Test]
143+
public async Task Lambda_Array_IsNotEmpty_Passes()
144+
{
145+
var items = new[] { "a", "b", "c" };
146+
await Assert.That(() => items).IsNotEmpty();
147+
}
148+
149+
[Test]
150+
public async Task Lambda_Enumerable_IsEmpty_Passes()
151+
{
152+
IEnumerable<int> items = Enumerable.Empty<int>();
153+
await Assert.That(() => items).IsEmpty();
154+
}
155+
156+
[Test]
157+
public async Task Lambda_HashSet_Contains_Passes()
158+
{
159+
var items = new HashSet<int> { 1, 2, 3 };
160+
await Assert.That(() => items).Contains(2);
161+
}
162+
163+
private static IEnumerable<int> ThrowingMethod()
164+
{
165+
throw new InvalidOperationException("Test exception");
166+
}
167+
168+
// Async lambda tests
169+
[Test]
170+
public async Task AsyncLambda_Collection_IsEmpty_Passes()
171+
{
172+
await Assert.That(async () => await GetEmptyCollectionAsync()).IsEmpty();
173+
}
174+
175+
[Test]
176+
public async Task AsyncLambda_Collection_IsNotEmpty_Passes()
177+
{
178+
await Assert.That(async () => await GetCollectionAsync()).IsNotEmpty();
179+
}
180+
181+
[Test]
182+
public async Task AsyncLambda_Collection_Count_IsEqualTo_Passes()
183+
{
184+
await Assert.That(async () => await GetCollectionAsync()).Count().IsEqualTo(3);
185+
}
186+
187+
[Test]
188+
public async Task AsyncLambda_Collection_Contains_Passes()
189+
{
190+
await Assert.That(async () => await GetCollectionAsync()).Contains(2);
191+
}
192+
193+
[Test]
194+
public async Task AsyncLambda_Collection_All_Passes()
195+
{
196+
await Assert.That(async () => await GetCollectionAsync()).All(x => x > 0);
197+
}
198+
199+
[Test]
200+
public async Task AsyncLambda_Collection_Throws_Passes()
201+
{
202+
await Assert.That(async () => await ThrowingMethodAsync()).Throws<InvalidOperationException>();
203+
}
204+
205+
[Test]
206+
public async Task AsyncLambda_Collection_Chaining_With_And()
207+
{
208+
await Assert.That(async () => await GetCollectionAsync())
209+
.IsNotEmpty()
210+
.And.Contains(2)
211+
.And.HasDistinctItems();
212+
}
213+
214+
private static Task<IEnumerable<int>> GetEmptyCollectionAsync()
215+
{
216+
return Task.FromResult<IEnumerable<int>>(new List<int>());
217+
}
218+
219+
private static Task<IEnumerable<int>> GetCollectionAsync()
220+
{
221+
return Task.FromResult<IEnumerable<int>>(new List<int> { 1, 2, 3 });
222+
}
223+
224+
private static async Task<IEnumerable<int>> ThrowingMethodAsync()
225+
{
226+
await Task.Yield();
227+
throw new InvalidOperationException("Test exception");
228+
}
229+
}

TUnit.Assertions/Extensions/Assert.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ public static ValueAssertion<TValue> That<TValue>(
8686
return new ValueAssertion<TValue>(value, expression);
8787
}
8888

89+
/// <summary>
90+
/// Creates an assertion for a synchronous function that returns a collection.
91+
/// This overload enables collection-specific assertions (IsEmpty, IsNotEmpty, HasCount, etc.) on lambda-wrapped collections.
92+
/// Example: await Assert.That(() => GetItems()).IsEmpty();
93+
/// Example: await Assert.That(() => GetItems()).HasCount(5);
94+
/// </summary>
95+
[OverloadResolutionPriority(1)]
96+
public static FuncCollectionAssertion<TItem> That<TItem>(
97+
Func<IEnumerable<TItem>?> func,
98+
[CallerArgumentExpression(nameof(func))] string? expression = null)
99+
{
100+
return new FuncCollectionAssertion<TItem>(func!, expression);
101+
}
102+
89103
/// <summary>
90104
/// Creates an assertion for a synchronous function.
91105
/// Example: await Assert.That(() => GetValue()).IsGreaterThan(10);
@@ -97,6 +111,20 @@ public static FuncAssertion<TValue> That<TValue>(
97111
return new FuncAssertion<TValue>(func, expression);
98112
}
99113

114+
/// <summary>
115+
/// Creates an assertion for an asynchronous function that returns a collection.
116+
/// This overload enables collection-specific assertions (IsEmpty, IsNotEmpty, HasCount, etc.) on async lambda-wrapped collections.
117+
/// Example: await Assert.That(async () => await GetItemsAsync()).IsEmpty();
118+
/// Example: await Assert.That(async () => await GetItemsAsync()).HasCount(5);
119+
/// </summary>
120+
[OverloadResolutionPriority(1)]
121+
public static AsyncFuncCollectionAssertion<TItem> That<TItem>(
122+
Func<Task<IEnumerable<TItem>?>> func,
123+
[CallerArgumentExpression(nameof(func))] string? expression = null)
124+
{
125+
return new AsyncFuncCollectionAssertion<TItem>(func!, expression);
126+
}
127+
100128
/// <summary>
101129
/// Creates an assertion for an asynchronous function.
102130
/// Example: await Assert.That(async () => await GetValueAsync()).IsEqualTo(expected);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Text;
2+
using TUnit.Assertions.Conditions;
3+
using TUnit.Assertions.Core;
4+
5+
namespace TUnit.Assertions.Sources;
6+
7+
/// <summary>
8+
/// Source assertion for asynchronous functions that return collections.
9+
/// This is the entry point for: Assert.That(async () => await GetCollectionAsync())
10+
/// Combines the lazy evaluation of AsyncFuncAssertion with the collection methods of CollectionAssertionBase.
11+
/// Enables collection assertions like IsEmpty(), IsNotEmpty(), HasCount() on async lambda-wrapped collections.
12+
/// </summary>
13+
public class AsyncFuncCollectionAssertion<TItem> : CollectionAssertionBase<IEnumerable<TItem>, TItem>, IDelegateAssertionSource<IEnumerable<TItem>>
14+
{
15+
public AsyncFuncCollectionAssertion(Func<Task<IEnumerable<TItem>?>> func, string? expression)
16+
: base(CreateContext(func, expression))
17+
{
18+
}
19+
20+
private static AssertionContext<IEnumerable<TItem>> CreateContext(Func<Task<IEnumerable<TItem>?>> func, string? expression)
21+
{
22+
var expressionBuilder = new StringBuilder();
23+
expressionBuilder.Append($"Assert.That({expression ?? "?"})");
24+
var evaluationContext = new EvaluationContext<IEnumerable<TItem>>(async () =>
25+
{
26+
try
27+
{
28+
var result = await func().ConfigureAwait(false);
29+
return (result, null);
30+
}
31+
catch (Exception ex)
32+
{
33+
return (default, ex);
34+
}
35+
});
36+
return new AssertionContext<IEnumerable<TItem>>(evaluationContext, expressionBuilder);
37+
}
38+
39+
/// <summary>
40+
/// Asserts that the function throws the specified exception type (or subclass).
41+
/// Example: await Assert.That(async () => await GetItemsAsync()).Throws&lt;InvalidOperationException&gt;();
42+
/// </summary>
43+
public ThrowsAssertion<TException> Throws<TException>() where TException : Exception
44+
{
45+
Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()");
46+
var mappedContext = Context.MapException<TException>();
47+
return new ThrowsAssertion<TException>(mappedContext!);
48+
}
49+
50+
/// <summary>
51+
/// Asserts that the function throws exactly the specified exception type (not subclasses).
52+
/// Example: await Assert.That(async () => await GetItemsAsync()).ThrowsExactly&lt;InvalidOperationException&gt;();
53+
/// </summary>
54+
public ThrowsExactlyAssertion<TException> ThrowsExactly<TException>() where TException : Exception
55+
{
56+
Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()");
57+
var mappedContext = Context.MapException<TException>();
58+
return new ThrowsExactlyAssertion<TException>(mappedContext!);
59+
}
60+
}

0 commit comments

Comments
 (0)