fix(mocks): preserve nullable annotations on generated event implementations#5444
fix(mocks): preserve nullable annotations on generated event implementations#5444
Conversation
…tations (#5424) The generated explicit interface event implementation used the non-nullable form of the event handler type, producing CS8615 when the interface declared the event as nullable (e.g. `event EventHandler<string>? Something`). Add `EventHandlerTypeWithNullability` to MockEventModel and use it from MockWrapperTypeBuilder and MockBridgeBuilder when emitting explicit interface event implementations. The existing `EventHandlerType` field is retained because MockImplBuilder uses it as the base for the always-nullable backing delegate field (appending `?`).
There was a problem hiding this comment.
Code Review
This is a clean, targeted fix for CS8615 (nullability mismatch on explicit interface event implementations). The approach is sound and the regression test + snapshot coverage is good. A few observations below.
Design observation: two fields for the same type with different purposes
The PR introduces EventHandlerTypeWithNullability alongside the existing EventHandlerType, with the distinction being:
EventHandlerType= no nullable annotation (used inMockImplBuilderbecause it appends?itself)EventHandlerTypeWithNullability= nullable annotation preserved (used inMockBridgeBuilderandMockWrapperTypeBuilderfor explicit interface implementations)
The PR description explains the reason: MockImplBuilder appends ? explicitly for the backing field/event declaration, and using EventHandlerTypeWithNullability directly there would produce ?? for nullable events.
This is a pragmatic split that works, but it introduces a subtle semantic trap: two callers that look nearly identical but must use different fields. If someone adds a new builder in the future that emits an explicit interface event, they might naturally reach for EventHandlerType (the first-declared, "primary" field) and silently reintroduce CS8615.
Suggestion: Make the intent encode-safe by inverting which field is the "primary" one, or by renaming to make the distinction obvious at the call site:
// Option A – rename to be explicit about context
public string EventHandlerTypeForBackingField { get; init; } = ""; // always without `?` (caller appends it)
public string EventHandlerTypeForInterfaceImpl { get; init; } = ""; // preserves source nullability
// Option B – keep a single WithNullability field and strip in MockImplBuilder
// Since GetFullyQualifiedName() strips the `?`, and GetFullyQualifiedNameWithNullability()
// keeps it, you could expose only the nullability-preserving form and have MockImplBuilder
// call TrimEnd('?') when it needs the non-annotated base:
writer.AppendLine($"private {evt.EventHandlerType.TrimEnd('?')}? _backing_{evt.Name};");Option B would let you remove the EventHandlerType field entirely — one source of truth instead of two — and the intent becomes self-documenting. The TrimEnd('?') is safe because GetFullyQualifiedName() currently produces the identical result (without ?), so it is strictly equivalent.
Minor: Equals / GetHashCode redundancy now that both fields usually differ only by ?
For a non-nullable event (e.g. event Action Foo), EventHandlerType and EventHandlerTypeWithNullability will be identical strings. Including both in GetHashCode adds unnecessary hash churn without any real collision-avoidance benefit. If Option B above is adopted (single field), this goes away naturally.
Snapshot file: trailing newline
Interface_With_Nullable_Event.verified.txt ends without a trailing newline (\ No newline at end of file). That's consistent with the diff output, but worth confirming this matches what the snapshot test framework expects — some snapshot frameworks are sensitive to trailing newlines across platforms.
Overall
The fix is correct and well-motivated. The regression test exercising the exact CS8615 scenario is exactly the right approach. The main actionable improvement is simplifying the model to a single field (Option B above), which would make it harder to introduce the same class of bug in future builders.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
TIP This summary will be updated as you push new changes. Give us feedback
…eserving field Per review feedback on #5444: replace the EventHandlerType + EventHandlerTypeWithNullability pair with a single EventHandlerType that always carries nullable annotations from the declaring interface. MockImplBuilder now calls TrimEnd('?') before appending its own '?' for the always-nullable backing delegate field, eliminating the two-fields-with-similar-names footgun.
There was a problem hiding this comment.
Follow-up Review
The previous review raised three concerns. Here's how each was addressed:
✅ Previous concern: two stored fields for the same type with different nullability semantics
The suggestion was to adopt Option B — make EventHandlerType the single source of truth (with nullability preserved) and have MockImplBuilder derive the non-nullable form. This has been implemented cleanly:
MemberDiscovery.csnow callsGetFullyQualifiedNameWithNullability(), soEventHandlerTypealways carries the source nullability annotation.EventHandlerTypeNonNullableis a computed property (EventHandlerType.TrimEnd('?')), not a stored field — single source of truth, derived form only where needed.MockImplBuildernow usesEventHandlerTypeNonNullablewhen appending?for the backing field and event declaration.
This is exactly what was asked for and is better than the earlier approach: any future builder that emits an explicit interface event will naturally reach for EventHandlerType and get correct nullability for free.
One small note: the PR description mentions an EventHandlerTypeWithNullability property that doesn't appear in the actual diff — looks like the description reflects an earlier iteration. The final implementation (changing the semantics of the existing EventHandlerType) is simpler and preferred.
✅ Previous concern: Equals/GetHashCode churn from two near-identical fields
Resolved naturally — EventHandlerTypeNonNullable is a computed property and not part of the record's auto-generated equality, so there is no redundancy.
⚠️ Previous concern: snapshot file trailing newline — unchanged
Interface_With_Nullable_Event.verified.txt still ends without a trailing newline. This is a low-priority observation; if the existing snapshot tests pass consistently on CI it's fine to leave as-is.
Overall
All substantive concerns from the previous review have been addressed. The fix is correct, the model is now cleanly structured with a single stored field, and the regression test + snapshot provide good coverage of the exact failure mode. No blocking issues.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.28.7 to 1.29.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.29.0 <!-- Release notes generated using configuration in .github/release.yml at v1.29.0 --> ## What's Changed ### Other Changes * 🤖 Update Mock Benchmark Results by @thomhurst in thomhurst/TUnit#5420 * fix(mocks): resolve build errors when mocking Azure SDK clients by @thomhurst in thomhurst/TUnit#5440 * fix: deduplicate virtual hook overrides across class hierarchy (#5428) by @thomhurst in thomhurst/TUnit#5441 * fix(mocks): unique __argArray locals per event in RaiseEvent dispatch (#5423) by @thomhurst in thomhurst/TUnit#5442 * refactor(mocks): extract MockTypeModel.Visibility helper by @thomhurst in thomhurst/TUnit#5443 * fix(mocks): preserve nullable annotations on generated event implementations by @thomhurst in thomhurst/TUnit#5444 * fix(mocks): preserve nullability on event handler types (#5425) by @thomhurst in thomhurst/TUnit#5445 ### Dependencies * chore(deps): update tunit to 1.28.7 by @thomhurst in thomhurst/TUnit#5416 * chore(deps): update dependency polyfill to v10 by @thomhurst in thomhurst/TUnit#5417 * chore(deps): update dependency polyfill to v10 by @thomhurst in thomhurst/TUnit#5418 * chore(deps): update dependency mockolate to 2.4.0 by @thomhurst in thomhurst/TUnit#5431 * chore(deps): update mstest to 4.2.1 by @thomhurst in thomhurst/TUnit#5433 * chore(deps): update dependency microsoft.net.test.sdk to 18.4.0 by @thomhurst in thomhurst/TUnit#5435 * chore(deps): update microsoft.testing to 2.2.1 by @thomhurst in thomhurst/TUnit#5432 * chore(deps): update dependency microsoft.testing.extensions.codecoverage to 18.6.2 by @thomhurst in thomhurst/TUnit#5437 * chore(deps): update dependency @docusaurus/theme-mermaid to ^3.10.0 by @thomhurst in thomhurst/TUnit#5438 * chore(deps): update docusaurus to v3.10.0 by @thomhurst in thomhurst/TUnit#5439 **Full Changelog**: thomhurst/TUnit@v1.28.7...v1.29.0 Commits viewable in [compare view](thomhurst/TUnit@v1.28.7...v1.29.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
event EventHandler<string>? Something), because the explicit interface event implementation dropped the?annotation.EventHandlerTypeWithNullabilitytoMockEventModeland use it fromMockWrapperTypeBuilderandMockBridgeBuilderwhen emitting explicit interface event implementations. The existingEventHandlerTypefield is retained becauseMockImplBuilderuses it as the base for the always-nullable backing delegate field (appending?) — using the nullability-preserving form there would produce??.Test plan
Interface_With_Nullable_EventinTUnit.Mocks.SourceGenerator.Testswith verified snapshotTUnit.Mocks.SourceGenerator.Testssuite passes (33/33)