fix: restore [Obsolete] members removed in v1.27 (#5539)#5605
fix: restore [Obsolete] members removed in v1.27 (#5539)#5605
Conversation
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
There was a problem hiding this comment.
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/RecordTimingis the right approach; it avoids pollutingTestContext's public surface with deprecated members. #pragma warning disable CS0618is correctly scoped to only the bridge implementation.ObjectBag => StateBagaliasing 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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 24 |
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
|
Thanks for the review! Pushed b116143 addressing all three:
|
There was a problem hiding this comment.
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/MapToCounthelpers inCountWrapperto eliminate the 6x duplication that existed in the earlier revision. Good. <inheritdoc cref="StateBag"/>on theObjectBagaliases avoids doc duplication and will stay in sync automatically.- Lock justification is clear: The comment in
TestContext.Output.csexplains exactly why_timingsLockis needed (user-facingRecordTimingmay 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.
There was a problem hiding this comment.
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/MapToCounthelpers inCountWrapperto eliminate the 6x duplication that existed in the earlier revision. <inheritdoc cref="StateBag"/>on theObjectBagaliases avoids doc duplication and will stay in sync automatically.- Lock justification is clear: The comment in
TestContext.Output.csexplains exactly why_timingsLockis needed (user-facingRecordTimingmay 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.
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> [](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>
Summary
[Obsolete]attributes so v1.x users can upgrade without compile errors.Fixes #5539.
Restored members
TUnit.Assertions
CountWrapper<TCollection, TItem>andLengthWrapper(recreated verbatim underTUnit.Assertions.Conditions.Wrappers)CollectionAssertionBase<TCollection, TItem>.HasCount()/.HasCount(int)AssertionExtensions.HasLength(IAssertionSource<string>)/.HasLength(IAssertionSource<string>, int)TUnit.Core
TestBuilderContext.ObjectBagandTestRegisteredContext.ObjectBag(alias=> StateBag)TimingrecordITestOutput.Timingsproperty +ITestOutput.RecordTiming(Timing)method, bridged inTestContext.Output.csto the internalTimingEntrystorage. Adds_timingsLockto make user-facing concurrentRecordTimingcalls 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— cleandotnet build TUnit.Assertions— cleandotnet build TUnit.Engine— cleanTUnit.PublicAPItests pass on net8.0, net9.0, net10.0, net472