Skip to content

fix: restore [Obsolete] members removed in v1.27 (#5539)#5605

Merged
thomhurst merged 2 commits intomainfrom
restore-obsolete-members-5539
Apr 17, 2026
Merged

fix: restore [Obsolete] members removed in v1.27 (#5539)#5605
thomhurst merged 2 commits intomainfrom
restore-obsolete-members-5539

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Fixes #5539.

Restored members

TUnit.Assertions

  • CountWrapper<TCollection, TItem> and LengthWrapper (recreated verbatim under TUnit.Assertions.Conditions.Wrappers)
  • CollectionAssertionBase<TCollection, TItem>.HasCount() / .HasCount(int)
  • AssertionExtensions.HasLength(IAssertionSource<string>) / .HasLength(IAssertionSource<string>, int)

TUnit.Core

  • TestBuilderContext.ObjectBag and TestRegisteredContext.ObjectBag (alias => StateBag)
  • Timing record
  • ITestOutput.Timings property + ITestOutput.RecordTiming(Timing) method, bridged in TestContext.Output.cs to the internal TimingEntry storage. Adds _timingsLock to make user-facing concurrent RecordTiming calls safe (engine no longer writes to this list — OTel handles timing internally).

PublicAPI snapshots regenerated for net8.0 / net9.0 / net10.0 / net472.

Test plan

  • dotnet build TUnit.Core — clean
  • dotnet build TUnit.Assertions — clean
  • dotnet build TUnit.Engine — clean
  • TUnit.PublicAPI tests pass on net8.0, net9.0, net10.0, net472
  • CI green

PR #5384 deleted previously [Obsolete]-marked public APIs in a minor
release, breaking semver. Restore them with [Obsolete] reapplied so v1.x
consumers can upgrade without compile errors. Actual deletion is
deferred to the v2 major bump (tracked in #5604).

Restored:
- TUnit.Assertions: CountWrapper, LengthWrapper, HasCount/HasLength
  overloads on CollectionAssertionBase / AssertionExtensions
- TUnit.Core: ObjectBag on TestBuilderContext + TestRegisteredContext,
  Timing record, ITestOutput.Timings + RecordTiming bridged to internal
  TimingEntry storage with new _timingsLock for user-facing concurrent
  RecordTiming calls
- PublicAPI snapshots regenerated for net8/9/10/472
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This PR is a well-executed semver hotfix — restoring accidentally-deleted public APIs with [Obsolete] attributes is exactly the right approach before a major bump. The overall strategy and execution are sound.

Issues Found

1. Duplicated count-extraction logic in CountWrapper (minor, but worth a note)

The following pattern is copy-pasted across 6 separate methods in CountWrapper.cs:

if (value == null) return 0;
if (value is ICollection collection) return collection.Count;
return value.Cast<object>().Count();

Even for [Obsolete] code that will be deleted at v2, this creates 6 separate maintenance points until deletion and adds ~60 lines of noise. A single private helper removes all that:

private static int GetCount(TCollection? value) =>
    value is null ? 0
    : value is ICollection c ? c.Count
    : value.Cast<object>().Count();

Then every method becomes a one-liner that maps to it. This is especially worth doing since this code will live in the codebase for potentially months before v2.

2. Thread-safety story around Timings / _timingsLock needs clarification

The PR adds _timingsLock to protect user-facing RecordTiming calls, and the PR description states "engine no longer writes to this list". However, the existing comment directly above the field says the opposite:

// Timings are written sequentially by the framework during test execution, never by user code.
internal List<TimingEntry> Timings { get; } = [];
private readonly Lock _timingsLock = new();

These two statements directly contradict each other. If the engine truly stopped writing to Timings, the old comment is stale and should be removed — if it's still written to by the engine, then _timingsLock alone doesn't protect against races between engine writes (unguarded) and user RecordTiming calls (guarded). Either way, the comment and the PR description can't both be right; please resolve that inconsistency to avoid confusion for future readers.

3. LengthWrapper restores only EqualTo, unlike CountWrapper

CountWrapper restores a full API surface (9 methods: EqualTo, GreaterThan, GreaterThanOrEqualTo, LessThan, LessThanOrEqualTo, Between, Zero, NotEqualTo, Positive), but LengthWrapper only restores EqualTo. If any v1.x user was calling HasLength().GreaterThan(5) they'll still get a compile error after upgrading, defeating the purpose of the fix.

If the original LengthWrapper had only EqualTo, this is fine — but it's worth confirming against the v1.26 git history to make sure the restore is complete and consistent.

What's Working Well

  • [Obsolete] messages are clear and actionable — they tell users exactly what to use instead.
  • The explicit interface implementation for ITestOutput.Timings / RecordTiming is the right approach; it avoids polluting TestContext's public surface with deprecated members.
  • #pragma warning disable CS0618 is correctly scoped to only the bridge implementation.
  • ObjectBag => StateBag aliasing is minimal and correct.
  • PublicAPI snapshots are updated for all four targets (net8/9/10/472) — good hygiene.
  • Tracking the actual deletion in #5604 is the right way to ensure these don't linger past v2.

Overall this is ready to merge pending the comment stale-ness fix and confirmation that LengthWrapper is fully restored.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 24 complexity

Metric Results
Complexity 24

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

- Extract GetCount/MapToCount helpers in CountWrapper to remove 6x duplication
- Use <inheritdoc cref="StateBag"/> on ObjectBag aliases (drop duplicated XML)
- Clarify _timingsLock comment: engine writes are sequential, lock guards user-facing obsolete RecordTiming
@thomhurst
Copy link
Copy Markdown
Owner Author

Thanks for the review! Pushed b116143 addressing all three:

  1. CountWrapper duplication — extracted GetCount + MapToCount helpers, removed ~100 lines of copy-paste.
  2. Stale Timings comment — clarified: engine writes are sequential per-test (lifecycle-ordered), _timingsLock only guards the obsolete user-facing ITestOutput.RecordTiming bridge.
  3. LengthWrapper API surface — verified against df31e91^: original only had EqualTo. Restored verbatim, no GreaterThan etc. ever existed on it. Users wanting full numeric API have always needed Length() (the new one). No additional restoration needed.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review: Restore [Obsolete] members removed in v1.27 (#5539)

This is a well-executed semver-fix PR. The approach is correct — restore with [Obsolete] rather than hard-delete in a minor release — and the implementation is clean.

What's Good

  • Correct strategy: Restoring with [Obsolete] + clear migration messages ("Use Count().IsEqualTo(expectedCount) instead.") gives users a smooth upgrade path to v2.
  • Feedback addressed: The refactor commit extracted GetCount/MapToCount helpers in CountWrapper to eliminate the 6x duplication that existed in the earlier revision. Good.
  • <inheritdoc cref="StateBag"/> on the ObjectBag aliases avoids doc duplication and will stay in sync automatically.
  • Lock justification is clear: The comment in TestContext.Output.cs explains exactly why _timingsLock is needed (user-facing RecordTiming may be concurrent) and why engine writes don't need it (sequential, lifecycle-ordered), and the description reinforces that the engine no longer writes to this list at all.
  • PublicAPI snapshots are updated consistently across all four TFMs.

Minor Observations

GetCount returns 0 for null
CountWrapper.GetCount silently maps a null collection to count 0. This means Assert.That((List<int>?)null).HasCount(0) would pass rather than fail/throw. Since this matches the original pre-deletion behavior and the API is obsolete, this is acceptable — but worth noting in case the original also had this quirk.

LengthWrapper only exposes EqualTo
CountWrapper exposes 8 assertion methods (GreaterThan, LessThan, Between, Zero, etc.) while LengthWrapper only exposes EqualTo. If this is a faithful restoration of the original API, it's correct — just worth confirming that HasLength().GreaterThan(...) was never supported, to avoid silently dropping functionality.

CountWrapper throws NotSupportedException at runtime for IAssertionSource<TCollection> interface methods
Methods like IsTypeOf<TExpected>() throw at runtime rather than failing at compile time. This is unavoidable given the interface contract, and the error messages are excellent ("Use: Assert.That(value).IsTypeOf<List>().HasCount()..."). No change needed — just noting it as a design consequence.

Conclusion

The PR correctly fixes the semver regression from #5384 and the implementation is solid. Previous review feedback has been addressed. Ready to merge.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review: Restore [Obsolete] members removed in v1.27 (#5539)

This is a well-executed semver-fix PR. The approach is correct — restore with [Obsolete] rather than hard-delete in a minor release — and the implementation is clean.

What's Good

  • Correct strategy: Restoring with [Obsolete] + clear migration messages ("Use Count().IsEqualTo(expectedCount) instead.") gives users a smooth upgrade path to v2.
  • Feedback addressed: The refactor commit extracted GetCount/MapToCount helpers in CountWrapper to eliminate the 6x duplication that existed in the earlier revision.
  • <inheritdoc cref="StateBag"/> on the ObjectBag aliases avoids doc duplication and will stay in sync automatically.
  • Lock justification is clear: The comment in TestContext.Output.cs explains exactly why _timingsLock is needed (user-facing RecordTiming may be concurrent) and why engine writes don't need it (sequential, lifecycle-ordered, no longer writing to this list).
  • PublicAPI snapshots are updated consistently across all four TFMs.

Minor Observations

GetCount returns 0 for null: CountWrapper.GetCount silently maps a null collection to count 0. Since this matches the original pre-deletion behavior and the API is obsolete, this is acceptable — worth noting in case the original also had this quirk.

LengthWrapper only exposes EqualTo while CountWrapper exposes 8 assertion methods. If this faithfully restores the original API, it's correct. (Author confirmed this matches the original — HasLength().GreaterThan(...) was never supported.)

CountWrapper throws NotSupportedException at runtime for IAssertionSource<TCollection> interface methods like IsTypeOf. This is unavoidable given the interface contract, and the error messages are excellent with clear guidance.

Conclusion

The PR correctly fixes the semver regression from #5384. Implementation is solid, thread-safety is properly handled, and previous review feedback has been fully addressed. Ready to merge.

@thomhurst thomhurst enabled auto-merge (squash) April 17, 2026 16:52
@thomhurst thomhurst merged commit 02d2c96 into main Apr 17, 2026
15 of 16 checks passed
@thomhurst thomhurst deleted the restore-obsolete-members-5539 branch April 17, 2026 17:24
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 21, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to
1.37.10.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.37.10

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.10 -->

## What's Changed
### Other Changes
* docs(test-filters): add migration callout for --filter →
--treenode-filter by @​johnkattenhorn in
thomhurst/TUnit#5628
* fix: re-enable RPC tests and modernize harness (#​5540) by @​thomhurst
in thomhurst/TUnit#5632
* fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch
(#​5626) by @​JohnVerheij in
thomhurst/TUnit#5631
* ci: use setup-dotnet built-in NuGet cache by @​thomhurst in
thomhurst/TUnit#5635
* feat(playwright): propagate W3C trace context into browser contexts by
@​thomhurst in thomhurst/TUnit#5636
### Dependencies
* chore(deps): update tunit to 1.37.0 by @​thomhurst in
thomhurst/TUnit#5625

## New Contributors
* @​johnkattenhorn made their first contribution in
thomhurst/TUnit#5628
* @​JohnVerheij made their first contribution in
thomhurst/TUnit#5631

**Full Changelog**:
thomhurst/TUnit@v1.37.0...v1.37.10

## 1.37.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.0 -->

## What's Changed
### Other Changes
* fix: stabilize flaky tests across analyzer, OTel, and engine suites by
@​thomhurst in thomhurst/TUnit#5609
* perf: engine hot-path allocation wins (#​5528 B) by @​thomhurst in
thomhurst/TUnit#5610
* feat(analyzers): detect collection IsEqualTo reference equality
(TUnitAssertions0016) by @​thomhurst in
thomhurst/TUnit#5615
* perf: consolidate test dedup + hook register guards (#​5528 A) by
@​thomhurst in thomhurst/TUnit#5612
* perf: engine discovery/init path cleanup (#​5528 C) by @​thomhurst in
thomhurst/TUnit#5611
* fix(assertions): render collection contents in IsEqualTo failure
messages (#​5613 B) by @​thomhurst in
thomhurst/TUnit#5619
* feat(analyzers): code-fix for TUnit0015 to insert CancellationToken
(#​5613 D) by @​thomhurst in
thomhurst/TUnit#5621
* fix(assertions): add Task reference forwarders on
AsyncDelegateAssertion by @​thomhurst in
thomhurst/TUnit#5618
* test(asp-net): fix race in FactoryMethodOrderTests by @​thomhurst in
thomhurst/TUnit#5623
* feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource]
(#​5613 C) by @​thomhurst in
thomhurst/TUnit#5620
* fix(pipeline): isolate AOT publish outputs to stop clobbering pack
DLLs (#​5622) by @​thomhurst in
thomhurst/TUnit#5624
### Dependencies
* chore(deps): update tunit to 1.36.0 by @​thomhurst in
thomhurst/TUnit#5608
* chore(deps): update modularpipelines to 3.2.8 by @​thomhurst in
thomhurst/TUnit#5614


**Full Changelog**:
thomhurst/TUnit@v1.36.0...v1.37.0

## 1.36.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.36.0 -->

## What's Changed
### Other Changes
* fix: don't render test's own trace as Linked Trace in HTML report by
@​thomhurst in thomhurst/TUnit#5580
* fix(docs): benchmark index links 404 by @​thomhurst in
thomhurst/TUnit#5587
* docs: replace repeated benchmark link suffix with per-test
descriptions by @​thomhurst in
thomhurst/TUnit#5588
* docs: clearer distributed tracing setup and troubleshooting by
@​thomhurst in thomhurst/TUnit#5597
* fix: auto-suppress ExecutionContext flow for hosted services (#​5589)
by @​thomhurst in thomhurst/TUnit#5598
* feat: auto-align DistributedContextPropagator to W3C by @​thomhurst in
thomhurst/TUnit#5599
* feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory
inheritance by @​thomhurst in
thomhurst/TUnit#5601
* feat: auto-propagate test trace context through IHttpClientFactory by
@​thomhurst in thomhurst/TUnit#5603
* feat: TUnit.OpenTelemetry zero-config tracing package by @​thomhurst
in thomhurst/TUnit#5602
* fix: restore [Obsolete] members removed in v1.27 (#​5539) by
@​thomhurst in thomhurst/TUnit#5605
* feat: generalize OTLP receiver for use outside TUnit.Aspire by
@​thomhurst in thomhurst/TUnit#5606
* feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by
@​thomhurst in thomhurst/TUnit#5607
### Dependencies
* chore(deps): update tunit to 1.35.2 by @​thomhurst in
thomhurst/TUnit#5581
* chore(deps): update dependency typescript to ~6.0.3 by @​thomhurst in
thomhurst/TUnit#5582
* chore(deps): update dependency coverlet.collector to v10 by
@​thomhurst in thomhurst/TUnit#5600


**Full Changelog**:
thomhurst/TUnit@v1.35.2...v1.36.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.35.2...v1.37.10).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.35.2&new-version=1.37.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.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.

[Bug]: Removal of obsolete members is a breaking change and should cause a semver-major version bump

1 participant