Fix mock generator not preserving nullable annotations on reference types#5251
Fix mock generator not preserving nullable annotations on reference types#5251
Conversation
When generating mock implementations, the source generator now correctly preserves the `?` annotation on nullable reference type parameters. Previously, `object? baz` would become `object baz` in the generated code, causing CS8767 warnings. Added GetFullyQualifiedNameWithNullability() and GetMinimallyQualifiedNameWithNullability() helper methods that append `?` for nullable reference types. Updated all MockParameterModel creation sites in MemberDiscovery.cs to use these helpers. Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/c26ff668-902b-4b2f-a63c-526123f1340c
There was a problem hiding this comment.
Code Review: Fix mock generator not preserving nullable annotations on reference type parameters
The fix is correct and well-targeted. The root cause was correctly identified: SymbolDisplayFormat.FullyQualifiedFormat and SymbolDisplayFormat.MinimallyQualifiedFormat intentionally strip nullable annotations, so reference type ? annotations were lost in generated mock implementations.
What's Good
- The fix is minimal and surgical — only touches the places that need it.
- The condition
NullableAnnotation == Annotated && !IsValueTypeis correct: value types (int?/Nullable<T>) are already handled by Roslyn's display format and are value types, so the guard prevents double-annotating them. - Good snapshot test covering mixed nullable/non-nullable parameters in a single interface.
Issue: Potential for Double-? in Edge Cases
The appending approach assumes FullyQualifiedFormat never includes a trailing ?. This is true today for reference types, but for completeness, consider checking if the name already ends with ? before appending:
public static string GetFullyQualifiedNameWithNullability(this ITypeSymbol type)
{
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (type.NullableAnnotation == NullableAnnotation.Annotated && !type.IsValueType && !name.EndsWith(?))
{
name += ?;
}
return name;
}This is defensive but low-cost.
Issue: Return Type Nullability Not Fixed (Related Bug)
The snapshot reveals a related unfixed issue — the return type nullability is also stripped. In the test interface:
string? GetValue(string? key, int count);The generated mock is:
public string GetValue(string? key, int count) // return type lost its '?'This would produce the same class of CS8766 warning (return type nullability mismatch). The same GetFullyQualifiedNameWithNullability() helper should be applied wherever return types are formatted in the generator. This is a natural follow-up to this PR — worth tracking.
Minor: Discoverability Risk
GetFullyQualifiedName() and GetFullyQualifiedNameWithNullability() now coexist. Future contributors adding new parameter-handling code may reach for the old non-nullable-aware methods by mistake. Consider deprecating the old methods with [Obsolete] or consolidating them to make the nullable-aware versions the default:
// Old method delegates to new one for consistency
public static string GetFullyQualifiedName(this ITypeSymbol type) =>
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);(Or simply note in a comment that the WithNullability variants should be preferred for code generation.)
Summary
The core fix is correct and addresses the reported CS8767 warning. The two issues above (return type nullability and discoverability) are not blockers but would prevent a similar bug report from appearing for return types.
There was a problem hiding this comment.
Code Review
This PR correctly identifies and fixes the root cause: SymbolDisplayFormat.FullyQualifiedFormat does not include ? for nullable reference types. The fix is targeted and the snapshot test provides good coverage for the reported scenario.
Issue 1: Return type nullability is not fixed (gap in this PR)
The parameter fix is incomplete — return type nullability is also not preserved, which will produce CS8766 warnings.
In MemberDiscovery.cs, ReturnType is still populated via GetFullyQualifiedName() (without nullability):
// Line 310, 383, 446 — all unchanged
ReturnType = returnType.GetFullyQualifiedName(),The snapshot confirms this: string? GetValue(...) generates public string GetValue(...) (return type drops ?). CS8766 applies here: "Nullability of reference types in return type of ... doesn't match implicitly implemented member."
The fix needs to extend to ReturnType (and UnwrappedReturnType) assignments in MemberDiscovery.cs, and similarly for properties/indexers.
Issue 2: Use SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier instead of manual string append
The manual += "?" approach is fragile and can miss edge cases. Roslyn has a first-class API for this:
private static readonly SymbolDisplayFormat FullyQualifiedWithNullability =
SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
private static readonly SymbolDisplayFormat MinimallyQualifiedWithNullability =
SymbolDisplayFormat.MinimallyQualifiedFormat.AddMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
public static string GetFullyQualifiedNameWithNullability(this ITypeSymbol type)
=> type.ToDisplayString(FullyQualifiedWithNullability);
public static string GetMinimallyQualifiedNameWithNullability(this ITypeSymbol type)
=> type.ToDisplayString(MinimallyQualifiedWithNullability);Why this is better:
- Handles generic type parameters constrained as reference types (
T?whereT : class) — the current check!type.IsValueTypemay not behave correctly for unconstrained type params - Handles arrays of nullable types (
string?[]) — the current code only appends?to the outer type, not nested types - It's the canonical Roslyn-supported mechanism, not string manipulation on top of a format that wasn't designed to include nullability
Issue 3: Code duplication in the two new helpers
GetFullyQualifiedNameWithNullability and GetMinimallyQualifiedNameWithNullability are structurally identical — the same NullableAnnotation check duplicated. With the SymbolDisplayMiscellaneousOptions approach above (Issue 2), the duplication disappears naturally.
Test coverage gap
The snapshot test doesn't include a nullable return type (string?) case, which would expose the CS8766 gap. Adding string? GetValue(...) to the test interface and verifying the generated return type is string? would have caught this.
Summary
The core fix is correct and the parameter nullability is properly handled. The main gaps to address before merging:
- Apply
GetFullyQualifiedNameWithNullability()toReturnType(andUnwrappedReturnType) assignments inMemberDiscovery.cs - Consider replacing the manual
+= "?"approach withSymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifierfor correctness with edge cases - Add snapshot test coverage for nullable return types
|
@copilot address Claude's review on this pr |
…ier, fix return type nullability - Replace manual `+= "?"` with SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier for correct handling of edge cases (generic type params, arrays of nullable types) - Fix return type nullability in MemberDiscovery.cs (methods, properties, indexers) - Fix GetUnwrappedReturnType to preserve nullability for async inner types - Update test to include nullable property and verify nullable return types in snapshot Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/4ee1d018-5836-4f4f-a49c-0417206f80cc
Addressed all three items from Claude's review in commit 6287658:
All 16 snapshot tests pass. |
There was a problem hiding this comment.
Follow-up Review: Previous Concerns Addressed
This is an updated review of the current implementation.
Previous Concerns — Status
1. "Potential for Double-" — No longer applicable.
The current implementation does not use the manual approach I flagged before. It correctly uses Roslyn's built-in:
SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier)This is the idiomatic Roslyn approach and handles all edge cases (generic type parameters, oblivious context, etc.) without the risk of double-annotation. Good call.
2. "Return type nullability not fixed" — Now fixed.
The snapshot confirms string? GetValue(string? key, int count) — the ? on the return type is preserved. The changes to GetUnwrappedReturnType and CreateMethodModel cover this.
3. "Discoverability risk" — Still present but minor.
GetFullyQualifiedName() and GetFullyQualifiedNameWithNullability() still coexist. This is a low-risk issue but worth a follow-up. A comment near GetFullyQualifiedName() noting that the nullable-aware version should be preferred for code generation would help future contributors avoid regressing this fix.
Current Implementation Assessment
The implementation is clean and correct:
- Using
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifieris the right Roslyn API, not a workaround. - All 4
MockParameterModelcreation sites are consistently updated. - Return types, parameter types, and the
GetParameterListhelper all use the nullable-aware format. - The snapshot test exercises mixed nullable/non-nullable parameters across methods and properties.
One Remaining Consideration: Generic Return Types
The test covers string? and object? parameters and return types. It does not cover nullable generic return types like:
T? GetItem<T>(string? key) where T : class;
List<string?>? GetAll();These are edge cases where IncludeNullableReferenceTypeModifier should still work correctly, but it may be worth adding a test for List<string?>? style types to guard against regression.
Overall: this is a well-executed fix. The approach is correct, minimal, and consistent. Approve pending the optional test coverage note above.
There was a problem hiding this comment.
Follow-up Review: Previous Concerns Addressed
This is an updated review of the current implementation.
Previous Concerns — Status
1. "Potential for Double-?" — No longer applicable.
The current implementation does not use the manual name += "?" approach flagged previously. It correctly uses Roslyn's built-in:
SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier)This is the idiomatic Roslyn approach and handles all edge cases (generic type parameters, oblivious nullability context, etc.) without the risk of double-annotation. Good call.
2. "Return type nullability not fixed" — Now fixed.
The snapshot confirms string? GetValue(string? key, int count) — the ? on the return type is preserved. The changes to GetUnwrappedReturnType and CreateMethodModel cover this correctly.
3. "Discoverability risk" — Still present, but minor.
GetFullyQualifiedName() and GetFullyQualifiedNameWithNullability() still coexist. This is low-risk, but a comment near the old GetFullyQualifiedName() noting that the nullable-aware version should be preferred for code generation would help prevent future regressions.
Current Implementation Assessment
The implementation is clean and correct:
- Using
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifieris the right Roslyn API — not a workaround. - All 4
MockParameterModelcreation sites are consistently updated. - Return types, parameter types, and the
GetParameterListhelper all use the nullable-aware format. - The snapshot test exercises mixed nullable/non-nullable parameters across methods and properties.
One Remaining Consideration: Generic/Nested Nullable Return Types
The test covers string? and object? parameters and return types. It doesn't cover nullable generic types like:
List<string?>? GetAll();
Task<string?> GetAsync(string? key);These are edge cases where IncludeNullableReferenceTypeModifier should work correctly, but a test covering Task<string?> return types would be worth adding to guard against regression — especially since GetUnwrappedReturnType has special handling for Task/ValueTask unwrapping.
Overall: this is a well-executed fix. The approach is correct, minimal, and consistent. The previous concerns have been properly addressed.
|
@copilot there is a new review |
…verage - Add XML doc comment to GetFullyQualifiedName() directing future contributors to use GetFullyQualifiedNameWithNullability() for code generation - Add Task<string?> GetAsync(string? key) to nullable test interface to cover async unwrapping edge case in GetUnwrappedReturnType - Update snapshot with generated async method preserving string? nullability Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/78103138-e1f5-46d0-a1fb-9124fc5f8906
Addressed both remaining items from Claude's latest review in commit c117b61:
|
SymbolDisplayFormat.FullyQualifiedFormatdoes not include?for nullable reference types, soobject?parameters andstring?return types in interfaces becameobjectandstringin generated mock implementations, producing CS8767/CS8766 warnings.Changes
TypeSymbolExtensions.cs— AddedGetFullyQualifiedNameWithNullability()andGetMinimallyQualifiedNameWithNullability()using Roslyn'sSymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifierfor correct handling of edge cases (generic type params, arrays of nullable types)MemberDiscovery.cs— Updated all 4MockParameterModelcreation sites (methods, constructors, indexers, events) to use the nullable-aware helpers. Also fixedReturnTypeandUnwrappedReturnTypeassignments inCreateMethodModel,CreatePropertyModel, andCreateIndexerModelto preserve return type nullabilityTypeSymbolExtensions.cs(GetUnwrappedReturnType) — Fixed to preserve nullability for async inner types (e.g.Task<string?>→string?)MethodSymbolExtensions.cs— UpdatedGetParameterListto use nullable-aware type namesInterface_With_Nullable_Reference_Type_Parameterscovering mixed nullable/non-nullable parameters, nullable return types, and nullable propertiesBefore/After
Original prompt
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.