Skip to content

Add single-item assertion chaining#6395

Merged
thomhurst merged 5 commits into
mainfrom
agent/has-single-item-chaining
Jul 12, 2026
Merged

Add single-item assertion chaining#6395
thomhurst merged 5 commits into
mainfrom
agent/has-single-item-chaining

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Summary

  • add .Item drill-in after HasSingleItem() and its predicate overload
  • preserve the captured item so one-shot enumerables are not enumerated twice
  • support collection-shaped items and memory assertions
  • document direct item chaining

Validation

  • dotnet test TUnit.Assertions.Tests/TUnit.Assertions.Tests.csproj — 6,541 passed across net8.0, net9.0, and net10.0
  • dotnet test TUnit.PublicAPI --no-build — 18 passed across net472, net8.0, net9.0, and net10.0

Closes #6221

@codacy-production

codacy-production Bot commented Jul 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 6 complexity

Metric Results
Complexity 6

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58b1b5eaf2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

get
{
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard .Item against existing Or chains

When .Item is reached after an Or chain (for example Assert.That(Array.Empty<int>()).IsEmpty().Or.HasSingleItem().Item.IsEqualTo(0)), this unconditionally wraps the existing OrAssertion in an And link. Because OrAssertion can short-circuit without executing HasSingleItem, _singleItem remains default, so the item assertion can pass even though no single item was captured. This should reject the mixed Or/drill-in case, like the normal .And path does, before installing the pending And link.

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: Add single-item assertion chaining (#6395)

Clean, well-scoped feature. The core mechanism — capturing _singleItem as a side effect of CheckAsync(), then reading it lazily via Context.Map<TItem>(_ => _singleItem) gated by Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And) — is correct: Assertion.ExecuteCoreAsync() always runs PendingPreWork (which executes the whole preceding .And/.Or chain, including wrapped executions) before it calls Context.GetAsync() on the new context, so _singleItem is guaranteed to be populated before .Item's value is read. This deliberately diverges from the existing DictionaryValueSource.Value drill-in (which re-derives the value from the raw parent value inside the Map callback rather than from a mutable field) — that's the right call here, since re-deriving via re-enumeration isn't possible for one-shot IEnumerable<T> sources. Good test coverage of that exact scenario (Test_List_HasSingleItem_Item_Does_Not_Reenumerate), plus predicate, chaining-preservation, and collection-shape cases. Snapshot and docs updates are in sync with the code change.

Finding: .Item silently evaluates against default(TItem) inside Assert.Multiple when the preceding HasSingleItem fails

Repro (verified locally, not in the diff):

using (Assert.Multiple())
{
    await Assert.That(new List<int>()).HasSingleItem().Item.IsEqualTo(999);
}

Outside Assert.Multiple, a failing HasSingleItem() throws before .Item's mapper ever runs (ThrowOrAccumulateFailure throws synchronously, so PendingPreWork propagates the exception and Context.GetAsync() on the new context is never reached) — this path is correctly tested. But inside Assert.Multiple, ThrowOrAccumulateFailure accumulates the failure into the AssertionScope instead of throwing, so ExecuteCoreAsync() returns normally. The .Item mapper (_ => _singleItem) then runs against the untouched field, which is default(TItem) (0/null/etc. — never reset in the failure path since it's just never assigned), and the downstream assertion (.IsEqualTo(999)) evaluates for real. In the repro above this produces two accumulated failures with IsEqualTo(999), and — more concerning — if the expected value happens to equal default(TItem) (e.g. .IsEqualTo(0) for an int), the item-level assertion silently passes, masking that no item was ever captured.

This is a narrow edge case (only surfaces inside Assert.Multiple), and it may be an existing limitation shared by other side-effect-captured drill-ins in this file (e.g. the GetAwaiter() "await for the item" pattern has the same shape), so it's not necessarily a blocker for this PR alone. But given .Item is the new public surface being added here, it'd be worth either: (a) short-circuiting .Item's evaluation when the preceding check failed (e.g. have the mapper reference the actual AssertionResult/failure state, not just the raw field), or at minimum (b) adding a regression test under Assert.Multiple so this doesn't quietly ship as an untested gap.

Minor: duplicated Item property (not blocking)

The Item get-accessor is duplicated verbatim across HasSingleItemAssertion, HasSingleItemPredicateAssertion, and MemoryHasSingleItemAssertion (4 identical lines × 3). Given the codebase already centralizes the analogous .And/.Or continuation wiring in shared base-class constructors (SetPendingLink is called once, in the continuation's base ctor, for every other assertion type), consider a small shared helper (e.g. a protected method on a common base, or a static helper taking Assertion<TValue>, AssertionContext, and the captured item) so a future change to this drill-in mechanism only needs to happen once. Not urgent given the current size, but worth avoiding a fourth copy if more drill-ins are added.

Everything else — naming, nullability, AOT/source-gen compatibility ([GenerateCollectionShapeAssertions] reuses the existing generator path), and the public API diff — looks consistent with the rest of the assertions library.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up review: fix commit cfde8e9

This addresses the finding from my previous review (.Item silently evaluating against default(TItem) inside Assert.Multiple when the preceding HasSingleItem failed).

Fix verified correct. Assertion.ExecuteCoreAsync() (TUnit.Assertions/Core/Assertion.cs:130-147) now snapshots AssertionScope.ExceptionCount before running Context.PendingPreWork, and short-circuits with return default; if it grew — i.e. the parent assertion accumulated a failure into the scope instead of throwing. This is the same exception-count-delta idiom already used in AndAssertion.AssertAsync() and OrAssertion.AssertAsync() (TUnit.Assertions/Chaining/{And,Or}Assertion.cs), so it's consistent with the rest of the codebase rather than a one-off special case. Because the guard lives in the shared PendingPreWork path (not SingleItemSource specifically), it also closes the same latent gap for other cross-type drill-ins built on PendingPreWork (e.g. WhenParsedInto<T>), which is a nice bonus beyond the immediate .Item case.

The new regression test Test_List_HasSingleItem_Item_Is_Not_Evaluated_After_Failure_In_Assert_Multiple (ListAssertionTests.cs) exercises exactly the reported scenario and asserts the downstream .Satisfies body never runs. Good coverage.

Still open (non-blocking, as noted previously): the Item get-accessor remains duplicated verbatim across HasSingleItemAssertion, HasSingleItemPredicateAssertion, and MemoryHasSingleItemAssertion. Not a blocker for this PR, but worth a shared helper if a fourth drill-in shows up.

No new issues found in this delta. Looks good to merge from my side.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cfde8e9933

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ThrowIfMixingCombiner<Chaining.OrAssertion<TCollection>>();
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);
return new SingleItemSource<TItem>(Context.Map<TItem>(_ => _singleItem));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ensure item Or chains cannot bypass HasSingleItem

When an item-level .Or is added, the parent HasSingleItem runs as PendingPreWork only for the first branch; its failure is caught by OrAssertion as if the first item assertion failed, then the prework is cleared and the second branch reads _singleItem's default value. For example, await Assert.That(Array.Empty<int>()).HasSingleItem().Item.IsEqualTo(1).Or.IsEqualTo(0) can pass even though there is no single item. The parent check needs to gate the whole item assertion chain rather than just one Or alternative.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff74d0054f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ThrowIfMixingCombiner<Chaining.OrAssertion<TCollection>>();
Context.ExpressionBuilder.Append(".Item");
Context.SetPendingLink(InternalWrappedExecution ?? this, CombinerType.And);
return new SingleItemSource<TItem>(Context.Map<TItem>(_ => _singleItem));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve HasSingleItem prework through Item mappings

The .Item source relies on PendingPreWork to run HasSingleItem, but many first operations on an IAssertionSource immediately map the context again (for example the documented .Item.Member(...), .Item.Length(), or .Item.IsTypeOf<T>() paths), and those maps do not carry this pending prework. In those cases the parent assertion never executes, so _singleItem is still its default value: the docs sample fails for a valid single User, and inputs such as Array.Empty<string>().HasSingleItem().Item.Length().IsEqualTo(0) can pass against the default item instead of failing the missing single-item check. Please make the item source preserve or execute the parent prework across subsequent mappings.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Follow-up review: fix commit ff74d005

This addresses the gap in the previous fix: .Item.IsEqualTo(1).Or.IsEqualTo(0) could silently pass when the preceding HasSingleItem() failed, because Assertion.AssertAsync() delegated straight to _wrappedExecution.AssertAsync() (the Or/And composite) without first running the shared Context.PendingPreWork gate. OrAssertion.AssertAsync() calls _first.ExecuteCoreAsync(), which does run the gate and clears PendingPreWork as a side effect (Context.PendingPreWork = null before await preWork()) — so by the time the second branch's ExecuteCoreAsync() ran, the gate was already consumed and _singleItem (still default(TItem) — never captured) was evaluated for real. With Or.IsEqualTo(0) on an int, that's a false pass.

Fix: ExecutePendingPreWorkAsync() is now called at the top of AssertAsync() itself, before the _wrappedExecution delegation, so the parent-failure gate always runs exactly once before entering And/Or composite logic — whether or not the assertion is wrapped. Outside Assert.Multiple, the parent failure now throws immediately (propagating the real HasSingleItem exception rather than a misleading combined Or message, which is correct here since there's no valid item to combine against). Inside Assert.Multiple, the gate returns false and AssertAsync() short-circuits with return default, so neither Or branch runs and only the genuine parent failure is accumulated.

Verified:

  • New regression test Test_List_HasSingleItem_Item_Or_Cannot_Bypass_Parent_Assertion covers exactly this scenario and passes.
  • Traced the double-invocation path (AssertAsync() → gate → ExecuteCoreAsync() → gate again): safe, since Context.PendingPreWork is nulled out on first execution, making the second call a no-op — no duplicate pre-work execution or duplicate exceptions.
  • Ran the full TUnit.Assertions.Tests suite locally (net10.0): 2185 passed, 0 failed, 4 skipped (OS-restricted). ListAssertionTests, AssertMultipleTests all green.

No new issues found in this delta. Looks good to merge from my side.

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review: Add single-item assertion chaining (#6395)

The .Item drill-in feature itself is solid: _singleItem is captured as a side effect of CheckAsync() and read lazily via SingleItemSource<TItem>.Create, gated by Context.SetPendingLink(...) so the parent HasSingleItem()/HasSingleItem(predicate)/MemoryHasSingleItemAssertion check always runs first. One-shot IEnumerable<T> sources are correctly protected from double enumeration (Test_List_HasSingleItem_Item_Does_Not_Reenumerate), Or-chain misuse is rejected (ThrowIfMixingCombiner), and the new Test_List_HasSingleItem_Item_Or_Cannot_Bypass_Parent_Assertion / Test_List_HasSingleItem_Item_Is_Not_Evaluated_After_Failure_In_Assert_Multiple tests close the gaps raised in the earlier review rounds on this PR. Snapshot and docs updates are in sync with the code change.

Finding: the Assert.Multiple pre-work gate is a behavior change for existing drill-ins, not just .Item — and it silently drops previously-captured failures

Assertion.ExecutePendingPreWorkAsync() (TUnit.Assertions/Core/Assertion.cs:160-175) is new shared plumbing: whenever Context.PendingPreWork fails inside Assert.Multiple (detected via AssertionScope.ExceptionCount growing), the entire rest of the current assertion is skipped (return default) rather than evaluated. This is exactly the right fix for .Item — continuing to compare .IsEqualTo(x) against an unpopulated _singleItem field after HasSingleItem() failed would be a phantom, possibly-silently-passing assertion.

But PendingPreWork isn't exclusive to .Item — it's also how WhenParsedInto<T>() (ParseAssertions.cs) and HasProperty(...) drill-ins (PropertyAssertion.cs) chain across type boundaries. For those, the downstream value is derived from the original source value, not from a mutable field that only the parent's success populates, so there's no "phantom comparison" hazard — evaluating them after a preceding failure is safe and was, before this PR, intentional: Assert.Multiple exists specifically to report every failure in a chain at once.

I verified this is an actual regression, not just a theoretical concern, by running the same repro against the PR branch and against main (097d3ad) in a separate worktree:

using (Assert.Multiple())
{
    await Assert.That("123")
        .Length(l => l.IsEqualTo(4))   // fails: length is 3
        .And
        .WhenParsedInto<int>()
        .IsEqualTo(456);               // fails: parses to 123
}
  • On main (pre-PR): both failures are accumulated — the thrown exception contains both "Expected to be 4 / but found 3" and "Expected to be 456 / but found 123".
  • On this PR branch: only the length failure is captured. WhenParsedInto<int>().IsEqualTo(456) is never evaluated at all — the pre-work gate sees the length assertion added an exception to the scope and short-circuits before reaching the parse/compare step.

The existing test WhenParsedInto_AssertMultiple_AllAssertionsChecked (TUnit.Assertions.Tests/ParseAssertionTests.cs:148, unmodified by this PR) is titled "In Assert.Multiple, both failing assertions should be captured" but only asserts the message .Contains("length") — it doesn't check for the second failure, so it still passes despite the regression, and there's no test anywhere guarding that WhenParsedInto/HasProperty chains keep evaluating downstream assertions after an earlier chain failure inside Assert.Multiple.

Suggested fix: scope the short-circuit to drill-ins where it's actually needed instead of making it a blanket policy for every PendingPreWork consumer. For example, gate on whether the specific mapped value is defined (e.g. SingleItemSource.Create could track "was the item actually captured" alongside _singleItem, and only that context type opts into skipping downstream evaluation), rather than keying the whole mechanism off "did any exception get added to the scope during pre-work." That keeps the .Item fix precise while preserving Assert.Multiple's "report everything" contract for WhenParsedInto/HasProperty/dictionary .Value drill-ins. At minimum, this should be called out explicitly as an intentional, accepted behavior change (with a test protecting the new truncated-chain behavior for WhenParsedInto), since right now it reads as an unintended side effect of a narrower fix.

Minor (non-blocking, raised in earlier review rounds and still true)

The Item get-accessor is duplicated verbatim across HasSingleItemAssertion, HasSingleItemPredicateAssertion, and MemoryHasSingleItemAssertion. Worth a shared helper if a fourth drill-in is added, but not urgent at the current size.


Everything else — naming, nullability, AOT/source-gen compatibility, and the public API diff — looks consistent with the rest of the assertions library. The Assert.Multiple truncation issue above is the one thing I'd want resolved or explicitly accepted before merge, since it silently changes behavior for two pre-existing, unrelated features (WhenParsedInto, HasProperty) as a side effect of this PR's fix.

@thomhurst thomhurst force-pushed the agent/has-single-item-chaining branch from e31e77a to 9846aef Compare July 12, 2026 22:15
This was referenced Jul 13, 2026
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.

[Feature]: HasSingleItem() add .Item to allow chaining on that item

1 participant