Skip to content

feat: add HasSingleItem(predicate) overload#4832

Open
thomhurst wants to merge 2 commits intomainfrom
feat/has-single-item-predicate
Open

feat: add HasSingleItem(predicate) overload#4832
thomhurst wants to merge 2 commits intomainfrom
feat/has-single-item-predicate

Conversation

@thomhurst
Copy link
Owner

Summary

  • Adds HasSingleItem(Func<TItem, bool> predicate) overload so users can write Assert.That(collection).HasSingleItem(x => x.Prop == "value") instead of Assert.That(collection.Where(x => x.Prop == "value")).HasSingleItem()
  • Updates both xUnit code fixers (XUnitAssertionCodeFixProvider and XUnitTwoPhaseAnalyzer) to emit the new overload when converting Assert.Single(collection, predicate)
  • Adds HasSingleItemPredicateAssertion class, CheckHasSingleItemPredicate in CollectionChecks, and corresponding tests

Test plan

  • ListAssertionTests - 4 HasSingleItem tests pass (1 existing + 3 new: predicate match, no-match failure, multiple-match failure)
  • XUnitAssertionCodeFixProviderTests - 13 tests pass including updated Xunit_Single_With_Predicate_Preserves_Lambda
  • XUnitMigrationAnalyzerTests - 67 tests pass
  • Public API snapshots updated for all 4 TFMs (net472, net8.0, net9.0, net10.0)

🤖 Generated with Claude Code

…iltering

Instead of requiring `.Where(predicate).HasSingleItem()`, users can now
write `.HasSingleItem(predicate)` directly. Updates xUnit code fixers to
emit the new overload when converting `Assert.Single(collection, predicate)`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

return (AssertionConversionKind.Single, $"await Assert.That({collection}).HasSingleItem()", true, null);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: Code fixer doesn't distinguish between xUnit's predicate and expected-value overloads

Both code fixers assume any second argument to Assert.Single is a predicate, but xUnit has a non-generic overload Assert.Single(IEnumerable collection, object? expected) where the second argument is an expected value, not a predicate.

When users migrate code like:

Assert.Single(myCollection, expectedObject);

The fixer converts it to:

Assert.That(myCollection).HasSingleItem(expectedObject);

This fails to compile because HasSingleItem expects Func<TItem, bool>, not an object value.

Why this matters:
xUnit provides four Assert.Single overloads (see xUnit source):

  1. Assert.Single(IEnumerable)
  2. Assert.Single(IEnumerable, object?) ← problematic overload
  3. Assert.Single<T>(IEnumerable<T>)
  4. Assert.Single<T>(IEnumerable<T>, Predicate<T>)

Suggested approach:
Use semantic analysis to distinguish between these overloads, similar to how ConvertContains handles parameter type checking (XUnitTwoPhaseAnalyzer.cs:369-377):

var symbol = SemanticModel.GetSymbolInfo(memberAccess).Symbol;
if (symbol is IMethodSymbol { Parameters.Length: 2 } methodSymbol)
{
    // Check if second parameter is Predicate<T> vs object
    if (methodSymbol.Parameters[1].Type is INamedTypeSymbol { DelegateInvokeMethod: not null })
    {
        // This is the predicate overload - safe to convert
    }
    else
    {
        // This is the object expected overload - skip or handle differently
    }
}

Why semantic analysis is better:

  • Accurately distinguishes overloads based on actual types, not syntax
  • Prevents compilation errors in migrated code
  • Consistent with the defensive pattern already used elsewhere in this codebase

See

if (args.Count >= 2)
{
var predicate = args[1].Expression.ToString();
return (AssertionConversionKind.Single, $"await Assert.That({collection}).HasSingleItem({predicate})", true, null);
}

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed. Both code fixers now use semantic analysis to check if the second parameter is a delegate type (DelegateInvokeMethod: not null) before converting to HasSingleItem(predicate). For the object? expected-value overload, the two-phase analyzer falls back to HasSingleItem() with a TODO comment, and the code fix provider falls back to plain HasSingleItem().


"Single" => SyntaxFactory.ParseExpression($"Assert.That({actual}).HasSingleItem()"),
"Single" => argumentListArguments.Count >= 2
? SyntaxFactory.ParseExpression($"Assert.That({argumentListArguments[0]}).HasSingleItem({argumentListArguments[1]})")
Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: Same overload ambiguity exists here

This code fixer has the same issue as XUnitTwoPhaseAnalyzer.cs - it assumes any second argument to Assert.Single is a predicate.

xUnit's Assert.Single(IEnumerable, object?) overload would be incorrectly converted, causing compilation errors because HasSingleItem expects Func<TItem, bool>, not an object value.

Suggested fix:
Add semantic analysis before line 191 to check the parameter type:

"Single" => argumentListArguments.Count >= 2 && IsPredicateOverload(invocation, 1)
    ? SyntaxFactory.ParseExpression($"Assert.That({argumentListArguments[0]}).HasSingleItem({argumentListArguments[1]})")
    : SyntaxFactory.ParseExpression($"Assert.That({argumentListArguments[0]}).HasSingleItem()"),

Where IsPredicateOverload uses SemanticModel to verify the second parameter is actually a delegate type, not object?.

See the detailed explanation and suggested implementation approach in the comment on XUnitTwoPhaseAnalyzer.cs.

See

"Single" => argumentListArguments.Count >= 2
? SyntaxFactory.ParseExpression($"Assert.That({argumentListArguments[0]}).HasSingleItem({argumentListArguments[1]})")
: SyntaxFactory.ParseExpression($"Assert.That({argumentListArguments[0]}).HasSingleItem()"),

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed in the same commit - extracted to an async Single method matching the pattern used by Contains.

…loads

Use semantic analysis to check if the second parameter is a delegate type
before converting to HasSingleItem(predicate). The object? expected-value
overload now falls back to HasSingleItem() instead of misusing the predicate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant