fix(assertions): prevent implicit-to-string op from NREing on null (#5692)#5696
fix(assertions): prevent implicit-to-string op from NREing on null (#5692)#5696
Conversation
…5692) Routing `Assert.That(value)` through the `string?` overload invoked a user-defined `implicit operator string` at the call site, producing a NullReferenceException when the receiver was null — before the assertion ever ran. Replace the overload's parameter with a `StringValue` wrapper struct that declares a single implicit conversion from `string?`. C# allows only one user-defined conversion per chain, so `Id → string → StringValue` is not applicable and the generic `Assert.That<TValue>` overload is chosen instead by exact-match betterness. Add 31 routing regression tests pinning every `Assert.That` dispatch path (string / collection / delegate / Task / Func / value types) and an engine-mode repro under `TUnit.TestProject/Bugs/5692`.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Performance | 1 medium |
🟢 Metrics 37 complexity
Metric Results Complexity 37
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.
There was a problem hiding this comment.
Code Review — PR #5696: StringValue wrapper to prevent NRE from implicit-to-string overload binding
Summary
This is a clever, minimal fix for a subtle and frustrating overload resolution trap. The approach is sound, the test coverage is comprehensive, and the documentation explains the rationale clearly. A few observations below.
What's Well Done
The StringValue trick is exactly right. Exploiting the C# single-user-defined-conversion rule to silently reroute non-string types away from the string overload is the canonical fix for this pattern. Priority reshuffling (via [OverloadResolutionPriority]) can't solve this because the three routing constraints — string → string overload, Id (implicit-to-string) → generic, IQueryable<T> → IEnumerable overload — form a cycle. StringValue is the only way to break it without source-gen rewriting.
readonly struct + private constructor + [AggressiveInlining] — All correct. Zero allocation overhead, only constructible via the implicit path, inlineable in JIT-hot paths.
Test breadth — 31 tests covering the full overload routing matrix is very thorough. Routing assertions (verifying the returned assertion type, not just absence of exceptions) are the right way to pin this.
[EditorBrowsable(EditorBrowsableState.Never)] — Good call for keeping this out of IntelliSense autocompletion.
Observations
1. Missing ToString() override
// StringValue.cs
public override string? ToString() => Value;As-is, inspecting a StringValue in a debugger or error message will show TUnit.Assertions.StringValue rather than the underlying string. Since StringValue can appear in assertion failure messages or stack traces during development, adding ToString() would make debugging friction-free. (A [DebuggerDisplay("{Value}")] attribute would achieve the same for the debugger pane.)
2. Value property visibility
Value must be public because Assert.cs (also public) reads it. That's fine — just worth noting that this is intentional infrastructure, not a deliberate public API. The [EditorBrowsable(EditorBrowsableState.Never)] on the type covers discoverability, but Value itself has no such suppression. If IntelliSense ever surfaces someStringValue.Value, it would be confusing to users. Consider adding [EditorBrowsable(EditorBrowsableState.Never)] on the property too, or at minimum a <inheritdoc> pointing at the type-level XML doc.
3. Public API snapshot — worth checking
The CLAUDE.md notes that changes to public APIs require running snapshot tests. StringValue is a new public readonly struct with a public property and public static implicit operator. If this project uses API approval tests (e.g. PublicApiAnalyzer/.approved.txt files), they'll need updating. Quick check:
find . -name "*.approved.txt" | head -5If those exist and haven't been updated, CI will fail.
4. Scope: only Assert.That is affected
The other string? parameters in Assert.cs (e.g., PropertyChanged(INotifyPropertyChanged, string, Action)) don't need the same treatment because they don't compete with a generic overload that could also accept types with implicit-to-string conversions. That's correct — no action needed.
Minor Nit
The // Cast to IEnumerable<object?> directly without calling Cast<>() comment in the non-generic IEnumerable overload is the one existing comment that's worth keeping — it explains a subtle correctness concern. No issues there.
Verdict
Approve — the design is solid and the fix is correct. The ToString() override is the one change I'd suggest before merging, since it directly improves the debugging experience for assertion failures involving strings. The API snapshot check is a build correctness concern to verify.
There was a problem hiding this comment.
Code Review — PR #5696
Overall Assessment
The core design is sound and the StringValue wrapper struct is the canonical solution for breaking the three-constraint cycle (string overload ≥ IEnumerable > generic ≥ string literal). The PR explanation and XML doc comments are unusually clear. The use of [MethodImpl(MethodImplOptions.AggressiveInlining)] on the implicit conversion is appropriate — the JIT should eliminate this pure data carrier entirely in release builds. Public API snapshot updates across all four target frameworks (net8.0, net9.0, net10.0, net4.7) are correct and consistent.
Previous Review Status
A prior Claude review noted three items:
- Missing
ToString()override onStringValue— the struct would display asTUnit.Assertions.StringValuein debugger output. Valueproperty visibility — suggested[EditorBrowsable(EditorBrowsableState.Never)]on the property itself.- Public API snapshot check — now addressed by the snapshot update commit.
Item 3 is addressed. Items 1 and 2 remain open (details below).
Remaining Concerns
1. Missing ToString() Override — Debugging Friction
StringValue has no ToString() override, so hovering over a variable in the debugger or watch window will show TUnit.Assertions.StringValue instead of the actual string content. If the framework's internal diagnostic paths ever call .ToString() on an unwrapped wrapper, failure messages will be opaque.
Suggested fix (1 line):
public override string? ToString() => Value;Or, without touching ToString() semantics:
[System.Diagnostics.DebuggerDisplay("{Value}")]
public readonly struct StringValue { ... }This is low-effort and eliminates a real friction point for anyone who steps through assertion code.
2. Value Property Missing [EditorBrowsable]
The [EditorBrowsable(EditorBrowsableState.Never)] attribute is correctly applied to the type, but not to the Value property. A user who obtains a StringValue reference (e.g., in a helper that captures an assertion chain) will see .Value in autocomplete. Since the type itself is hidden this is minor, but adding the attribute to the property is belt-and-suspenders:
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public string? Value { get; }Minor Observations (Non-Blocking)
3. No test for a type implementing both implicit operator string AND IEquatable<string>
The new tests in Issue5692/ImplicitStringOperatorOverloadTests.cs cover the implicit operator path in isolation. A type that does both (e.g., a DDD value object like UserId) would route correctly through the fix, but there's no regression test pinning that combination. Worth adding to prevent future regressions.
4. Broad #pragma warning disable in the test file
The file-wide suppression of TUnitAssertions0002 is legitimate (the captured assertion is inspected for its runtime type, not awaited), but the comment only echoes the warning name. A brief note like // assertion deliberately captured for type inspection, not awaited would help future maintainers avoid accidentally removing the pragma.
5. Issue2993 test gap (pre-existing)
ImplicitConversionTests.cs in Issue2993 tests StringRecord in a collection context but never via Assert.That(singleRecord). This pre-dates this PR, but a direct single-value test there would confirm the new overload routing is consistent across both issue scenarios.
Conclusion
Neither item 1 nor item 2 is blocking — the fix is architecturally correct and the bug scenario is thoroughly tested. The PR is ready to merge once the maintainer decides whether to add the ToString() override and/or the [EditorBrowsable] annotation on the Value property.
MemoryAssertion<T> and ReadOnlyMemoryAssertion<T> are only defined for NET5_0_OR_GREATER, so the two tests referencing them failed to compile on net472. Wrap those two tests in the matching preprocessor guard so net472 builds succeed while keeping full coverage on modern TFMs.
There was a problem hiding this comment.
Code Review — PR #5696: fix(assertions): prevent implicit-to-string op from NREing on null
Third pass. Two items from the previous review are still open; adding a couple of new observations. Overall this is a clean and well-reasoned fix.
What's good
The StringValue wrapper struct approach is the right call. The PR description correctly identifies why this is the only viable fix — any priority reordering among the existing overloads creates an unsatisfiable cycle (string ≥ IEnumerable > generic ≥ string). Using C#'s single-user-defined-conversion-per-chain rule as the discriminator is precise and doesn't disturb any other routing path.
The implementation details are solid:
readonly struct+ private constructor — good; prevents misuse, eliminates defensive copies.[MethodImpl(AggressiveInlining)]onop_Implicit— correct; zero-overhead wrapping.[EditorBrowsable(EditorBrowsableState.Never)]on the struct — correct infrastructure pattern.- XML docs clearly explain the why, not just the what — exactly what future maintainers need.
The regression test suite is exemplary. 31 tests pinning every overload routing path is exactly the kind of regression guard that prevents silent re-routing in the future. Engine-mode repro in TestProject adds an extra layer of confidence. The #pragma warning disable TUnitAssertions0002 on line 1 with its inline comment is clear enough.
Public API snapshots updated correctly across all four TFMs — the API change is properly surfaced and reviewable.
Still open from previous reviews
1. Missing ToString() override on StringValue
// Current — default struct ToString() returns "TUnit.Assertions.StringValue"
public readonly struct StringValue { ... }
// Suggested
public override string? ToString() => Value;This is a debugger ergonomics issue. When an assertion fails and the failure reporter calls ToString() somewhere in the chain (e.g. in an error message or watch window), the default struct representation leaks the infrastructure type into user-visible output. Adding => Value costs nothing and eliminates that friction.
2. Value property lacks [EditorBrowsable(EditorBrowsableState.Never)]
// Current
public string? Value { get; }
// Suggested
[EditorBrowsable(EditorBrowsableState.Never)]
public string? Value { get; }The struct-level attribute suppresses the type from top-level completion, but if a user navigates to its definition (e.g. "Go to definition" on StringValue), Value appears in completion without suppression. Annotating the property makes the "do not use this directly" intent consistent at every access point.
New observation
StringValue is public in the public API surface without an [Obsolete] or [EditorBrowsable] on Value
The API snapshots correctly reflect public string? Value { get; }. If users discover and consume Value directly (unlikely but possible), the fix above makes the intent explicit in tooling without requiring a breaking API change.
No test for a type with both implicit operator string AND implicit operator StringValue
If a user type declared both conversions, C# would have an ambiguous conversion to StringValue and a compiler error would occur at the call site — which is arguably acceptable. But it might be worth a quick sanity check or a comment in the docs that this edge case is a compile error, not an NRE.
Summary
| Item | Status |
|---|---|
Core StringValue fix |
✅ Correct and minimal |
| Overload routing tests (31 cases) | ✅ Comprehensive |
| Engine-mode repro test | ✅ Good |
| API snapshots updated | ✅ All 4 TFMs |
ToString() override |
|
[EditorBrowsable] on Value |
|
| Pragma comment clarity | ✅ Adequate |
The two open items are polish, not correctness. The fix itself is sound. Happy to approve once the maintainer makes a deliberate call on ToString() and [EditorBrowsable] on Value — even a "won't fix, reason X" is a valid resolution.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to 1.39.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 Commits viewable in [compare view](thomhurst/TUnit@v1.37.10...v1.39.0). </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
Assert.That(value)wherevalue's static type declaredimplicit operator stringrouted through theAssert.That(string?)overload, invoking the user'sop_Impliciton a null receiver at the call site — NRE inside user code before the assertion ran.StringValuewrapper struct that exposes a single implicit conversion fromstring?. C# allows only one user-defined conversion per chain, soId → string → StringValueis no longer applicable and the genericAssert.That<TValue>overload wins by exact-match betterness.Assert.Thatrouting path so future overload-set changes cannot silently re-route (string /IEnumerable<char>cast /char[]/List<T>/IList<T>/IReadOnlyList<T>/HashSet<T>/IEnumerable<T>/IQueryable<T>/ non-genericIEnumerable/Dictionary<K,V>/Memory<T>/ReadOnlyMemory<T>/Action/Func<Task>/Task/Task<T>/Func<T>/Func<IEnumerable<T>>/ value types / ref types without implicit-to-string).Why a wrapper struct (and not priority reshuffling)
The three required resolutions —
"hello"→ string overload,Id(implicit-to-string) → generic,IQueryable<T>→IEnumerableoverload — impose a cycle in any priority ordering (string ≥ IEnumerable > generic ≥ string is unsatisfiable). A wrapper struct is the only fix that keeps existing overload semantics intact. No source-gen call rewriting involved.Test plan
TUnit.TestProject/Bugs/5692engine-mode repro passes (previously failed with NRE inop_Implicit)ImplicitStringOperatorOverloadTests— 31/31 greenTUnit.Assertions.Testssuite — 2017/2017 green