Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(assertions): update HasSingleItem to return the single item when …
…awaited
  • Loading branch information
thomhurst committed Oct 17, 2025
commit 6144cdf268e15eaf9861593dcb1cd4b7b32b09ab
26 changes: 26 additions & 0 deletions TUnit.Assertions.Tests/TypeInferenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,30 @@ await Assert.That(enumerable)
// Just want to surface any compiler errors if we knock out type inference
}
}

[Test]
public async Task HasSingleItemReturnsSingleItem()
{
// HasSingleItem can be awaited to get the single item
// Or chained with .And to continue collection assertions
IEnumerable<int> enumerable = [42];

try
{
// Test 1: Await to get the single item
var item = await Assert.That(enumerable).HasSingleItem();
await Assert.That(item).IsEqualTo(42);

// Test 2: Chain with .And for collection assertions
await Assert.That(enumerable)
.HasSingleItem()
.And
.ContainsOnly(x => x == 42);
}
catch
{
// Don't care for assertion failures
// Just want to surface any compiler errors if we knock out type inference
}
}
}
34 changes: 29 additions & 5 deletions TUnit.Assertions/Conditions/CollectionAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,17 +479,20 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollecti

/// <summary>
/// Asserts that a collection contains exactly one item.
/// When awaited, returns the single item for further assertions.
/// </summary>
public class HasSingleItemAssertion<TValue> : Assertion<TValue>
where TValue : IEnumerable
public class HasSingleItemAssertion<TCollection, TItem> : Assertion<TCollection>
where TCollection : IEnumerable<TItem>
{
private TItem? _singleItem;

public HasSingleItemAssertion(
AssertionContext<TValue> context)
AssertionContext<TCollection> context)
: base(context)
{
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> metadata)
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
{
var value = metadata.Value;
var exception = metadata.Exception;
Expand All @@ -512,7 +515,10 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
return Task.FromResult(AssertionResult.Failed("collection is empty"));
}

// First item exists, check if there's a second
// Store the single item
_singleItem = enumerator.Current;

// Check if there's a second item
if (enumerator.MoveNext())
{
return Task.FromResult(AssertionResult.Failed("collection has more than one item"));
Expand All @@ -527,6 +533,24 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
}

protected override string GetExpectation() => "to have exactly one item";

/// <summary>
/// Enables await syntax that returns the single item.
/// This allows both chaining (.And) and item capture (await).
/// </summary>
public new System.Runtime.CompilerServices.TaskAwaiter<TItem> GetAwaiter()
{
return ExecuteAndReturnItemAsync().GetAwaiter();
}

private async Task<TItem> ExecuteAndReturnItemAsync()
{
// Execute the assertion (will throw if not exactly one item)
await AssertAsync();

// Return the single item
return _singleItem!;
}
}

/// <summary>
Expand Down
8 changes: 4 additions & 4 deletions TUnit.Assertions/Extensions/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -948,13 +948,13 @@ public static CollectionAnyAssertion<TCollection, TItem> Any<TCollection, TItem>

/// <summary>
/// Asserts that the collection contains exactly one item.
/// When awaited, returns the single item for further assertions.
/// </summary>
public static HasSingleItemAssertion<TValue> HasSingleItem<TValue>(
this IAssertionSource<TValue> source)
where TValue : IEnumerable
public static HasSingleItemAssertion<IEnumerable<TItem>, TItem> HasSingleItem<TItem>(
this IAssertionSource<IEnumerable<TItem>> source)
{
source.Context.ExpressionBuilder.Append(".HasSingleItem()");
return new HasSingleItemAssertion<TValue>(source.Context);
return new HasSingleItemAssertion<IEnumerable<TItem>, TItem>(source.Context);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -853,11 +853,12 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class HasSingleItemAssertion<TValue> : .<TValue>
where TValue : .IEnumerable
public class HasSingleItemAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public HasSingleItemAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
public HasSingleItemAssertion(.<TCollection> context) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
public new .<TItem> GetAwaiter() { }
protected override string GetExpectation() { }
}
public static class HttpStatusCodeAssertionExtensions
Expand Down Expand Up @@ -1692,8 +1693,7 @@ namespace .Extensions
where TEnum : struct, { }
public static ..HasSameValueAsAssertion<TEnum> HasSameValueAs<TEnum>(this .<TEnum> source, otherEnumValue, [.("otherEnumValue")] string? expression = null)
where TEnum : struct, { }
public static .<TValue> HasSingleItem<TValue>(this .<TValue> source)
where TValue : .IEnumerable { }
public static .<.<TItem>, TItem> HasSingleItem<TItem>(this .<.<TItem>> source) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -853,11 +853,12 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class HasSingleItemAssertion<TValue> : .<TValue>
where TValue : .IEnumerable
public class HasSingleItemAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public HasSingleItemAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
public HasSingleItemAssertion(.<TCollection> context) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
public new .<TItem> GetAwaiter() { }
protected override string GetExpectation() { }
}
public static class HttpStatusCodeAssertionExtensions
Expand Down Expand Up @@ -1692,8 +1693,7 @@ namespace .Extensions
where TEnum : struct, { }
public static ..HasSameValueAsAssertion<TEnum> HasSameValueAs<TEnum>(this .<TEnum> source, otherEnumValue, [.("otherEnumValue")] string? expression = null)
where TEnum : struct, { }
public static .<TValue> HasSingleItem<TValue>(this .<TValue> source)
where TValue : .IEnumerable { }
public static .<.<TItem>, TItem> HasSingleItem<TItem>(this .<.<TItem>> source) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -853,11 +853,12 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class HasSingleItemAssertion<TValue> : .<TValue>
where TValue : .IEnumerable
public class HasSingleItemAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public HasSingleItemAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
public HasSingleItemAssertion(.<TCollection> context) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
public new .<TItem> GetAwaiter() { }
protected override string GetExpectation() { }
}
public static class HttpStatusCodeAssertionExtensions
Expand Down Expand Up @@ -1692,8 +1693,7 @@ namespace .Extensions
where TEnum : struct, { }
public static ..HasSameValueAsAssertion<TEnum> HasSameValueAs<TEnum>(this .<TEnum> source, otherEnumValue, [.("otherEnumValue")] string? expression = null)
where TEnum : struct, { }
public static .<TValue> HasSingleItem<TValue>(this .<TValue> source)
where TValue : .IEnumerable { }
public static .<.<TItem>, TItem> HasSingleItem<TItem>(this .<.<TItem>> source) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -816,11 +816,12 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class HasSingleItemAssertion<TValue> : .<TValue>
where TValue : .IEnumerable
public class HasSingleItemAssertion<TCollection, TItem> : .<TCollection>
where TCollection : .<TItem>
{
public HasSingleItemAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
public HasSingleItemAssertion(.<TCollection> context) { }
protected override .<.> CheckAsync(.<TCollection> metadata) { }
public new .<TItem> GetAwaiter() { }
protected override string GetExpectation() { }
}
public static class HttpStatusCodeAssertionExtensions
Expand Down Expand Up @@ -1597,8 +1598,7 @@ namespace .Extensions
where TEnum : struct, { }
public static ..HasSameValueAsAssertion<TEnum> HasSameValueAs<TEnum>(this .<TEnum> source, otherEnumValue, [.("otherEnumValue")] string? expression = null)
where TEnum : struct, { }
public static .<TValue> HasSingleItem<TValue>(this .<TValue> source)
where TValue : .IEnumerable { }
public static .<.<TItem>, TItem> HasSingleItem<TItem>(this .<.<TItem>> source) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfter(this .<> source, expected, [.("expected")] string? expression = null) { }
public static .<> IsAfterOrEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { }
Expand Down
4 changes: 1 addition & 3 deletions TUnit.TestProject/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,7 @@ public async Task Single()
{
var list = new List<int> { 1 };
var item = await Assert.That(list).HasSingleItem();
await Assert.That(list).HasSingleItem();
// Fixed: HasSingleItem returns the collection, not the single item
// await Assert.That(item).IsEqualTo(1);
await Assert.That(item).IsEqualTo(1);
}

[Test]
Expand Down
Loading