Skip to content

fix(mocks): preserve nullability on event handler types (#5425)#5445

Merged
thomhurst merged 3 commits intomainfrom
fix/5425-nullable-event-args
Apr 7, 2026
Merged

fix(mocks): preserve nullability on event handler types (#5425)#5445
thomhurst merged 3 commits intomainfrom
fix/5425-nullable-event-args

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Fixes [Bug]: Nullability of event args is ignored in generated mocks #5425. CreateEventModel was using GetFullyQualifiedName() for the event delegate type, which strips nullable reference annotations. This caused generated mocks to emit EventHandler<string> for an EventHandler<string?> source event, producing CS8604 because the Raise_* method (built from a separate, nullability-aware path) correctly used string?.
  • Switched to GetFullyQualifiedNameWithNullability() so the wrapper, impl, and bridge builders all see the annotated type.

Test plan

  • Added Interface_With_Nullable_Event_Args snapshot test reproducing the issue scenario
  • All *Event* mock generator tests pass

@thomhurst thomhurst merged commit ad8ac1d into main Apr 7, 2026
13 checks passed
@thomhurst thomhurst deleted the fix/5425-nullable-event-args branch April 7, 2026 18:44
@codacy-production
Copy link
Copy Markdown

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

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 fixes a real nullability bug (#5425) where CreateEventModel was using GetFullyQualifiedName() (which strips nullable annotations) for the event handler type, causing the generated wrapper class to emit EventHandler<string> for a source EventHandler<string?> event, producing CS8604. The fix switches to GetFullyQualifiedNameWithNullability() for the top-level event type, which is the correct call.

What the fix does

CreateEventModel in MemberDiscovery.cs now calls evt.Type.GetFullyQualifiedNameWithNullability() to populate MockEventModel.EventHandlerType. This value flows into:

  • MockWrapperTypeBuilder — explicit interface event forwarding
  • MockImplBuilder — backing field and event declaration (EventHandlerTypeNonNullable + trailing ?)

The RaiseParameterList path already used GetFullyQualifiedNameWithNullability() on parameter types, so raise-method signatures were already correct. Only the top-level event handler type was broken.

Observations and minor issues

EventArgsType field appears to be dead code. MockEventModel has a property EventArgsType (populated from eventArgsType in CreateEventModel), and the builder comment in the model file says "use this instead of parsing RaiseParameters". However, after searching all eight builder files, EventArgsType is not consumed anywhere. The legacy raiseParameters string local and eventArgsType variable inside CreateEventModel still call GetFullyQualifiedName() (without nullability), but since nothing reads EventArgsType from the builders, this is harmless in practice. It would be cleaner to either remove the field or migrate those internal GetFullyQualifiedName() calls to GetFullyQualifiedNameWithNullability() for consistency and future safety.

#pragma warning error in Issue5425Tests.cs is valid (introduced in C# 9), and using it as a compile-time regression guard is a good technique. The file explicitly explains this intent in comments.

Snapshot test for Interface_With_Nullable_Event_Args: The backing field in the impl is emitted as private global::System.EventHandler<string?>? _backing_Something, with the trailing ? appended by EventHandlerTypeNonNullable + ?. For a non-nullable delegate like EventHandler<string?>, this results in a nullable backing field (always ?), which is the correct and intentional pattern for internal backing fields.

The two new snapshot files (Interface_With_Nullable_Event_Args and Interface_With_Nullable_Event_And_Nullable_Args) are identical except for the wrapper type's explicit interface event declaration (one emits EventHandler<string?>, the other EventHandler<string?>?). This is the exact difference expected and correctly verified.

Suggestion

The eventArgsType local variable in CreateEventModel (around line 679) still uses GetFullyQualifiedName():

eventArgsType = argsParams.Length == 1
    ? argsParams[0].Type.GetFullyQualifiedName()  // <-- strips nullable annotation
    : raiseParameters;

Likewise the raiseParameters local uses GetFullyQualifiedName() for parameter types. Since EventArgsType is currently dead in the builders, this is not a regression risk today. But if EventArgsType is ever wired up it could reintroduce the same category of bug. It is worth either removing EventArgsType from the model or switching those calls to GetFullyQualifiedNameWithNullability() proactively.

Summary

The core fix is correct and minimal. Tests are well-structured with both snapshot regression coverage and compile-time enforcement via #pragma warning error. The PR addresses the root cause cleanly. The only housekeeping item worth noting is the apparently unused EventArgsType field in MockEventModel, which could be removed or cleaned up in a follow-up.

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]: Nullability of event args is ignored in generated mocks

1 participant