Skip to content

fix(mocks): preserve nullable annotations on generated event implementations#5444

Merged
thomhurst merged 3 commits intomainfrom
fix/mocks-nullable-event-cs8615
Apr 7, 2026
Merged

fix(mocks): preserve nullable annotations on generated event implementations#5444
thomhurst merged 3 commits intomainfrom
fix/mocks-nullable-event-cs8615

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Fixes [Bug]: CS8615 for generated nullable event mocks #5424: generated mocks emitted CS8615 when an interface declared an event as nullable (e.g. event EventHandler<string>? Something), because the explicit interface event implementation dropped the ? annotation.
  • Added 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 ?) — using the nullability-preserving form there would produce ??.

Test plan

  • New regression test Interface_With_Nullable_Event in TUnit.Mocks.SourceGenerator.Tests with verified snapshot
  • Full TUnit.Mocks.SourceGenerator.Tests suite passes (33/33)

…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 `?`).
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 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 in MockImplBuilder because it appends ? itself)
  • EventHandlerTypeWithNullability = nullable annotation preserved (used in MockBridgeBuilder and MockWrapperTypeBuilder for 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.

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 7, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

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.
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.

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.cs now calls GetFullyQualifiedNameWithNullability(), so EventHandlerType always carries the source nullability annotation.
  • EventHandlerTypeNonNullable is a computed property (EventHandlerType.TrimEnd('?')), not a stored field — single source of truth, derived form only where needed.
  • MockImplBuilder now uses EventHandlerTypeNonNullable when 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.

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 8, 2026
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.28.7&new-version=1.29.0)](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]: CS8615 for generated nullable event mocks

1 participant