Generated mocks live in the same namespace as the mocked type#5870
Conversation
After Task 6 made GetMockNamespace return "" for global-namespace types
in non-fallback mode, three builder sites that interpolated
$"global::{mockNamespace}.{TypeName}" started emitting invalid
`global::.IGreeterMock` syntax. Snapshot tests captured the broken
output, so 168 tests still passed while real consumers hit
CS1001/CS0246 at compile time.
Add MockImplBuilder.GetGlobalMockNamespacePrefix(model) which yields
"global::" for the global namespace and "global::{ns}." otherwise,
mirroring the existing nsPrefix guard in MemberDiscovery.
GetAutoMockFactoryMethod. Apply it in:
- MockImplBuilder.GetMockableTypeName (bridge-interface FQN)
- MockMembersBuilder.GenerateGenericMethodExtensionBlock (wrapper FQN)
- MockStaticExtensionBuilder.Build (Mock() factory return type,
generated mock cast, factory FQN)
Re-verify the 31 snapshots whose generated output changed and add a
regression guard test (Global_Namespace_Mock_Does_Not_Emit_Empty_Namespace_Prefix)
that asserts no `global::.` substring appears in generator output for a
globally-namespaced interface. The substring check sidesteps the
test-pinned Roslyn's inability to parse C# 14 `extension(...)` blocks
while still catching every site that re-introduces the bug.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 30 |
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
This is a well-structured PR. The core design — capturing UseFallbackNamespace at transform time and carrying it through the model — is the right approach for an incremental source generator. The new abstractions (OptionalNamespaceBlock, GetGlobalMockNamespacePrefix, MockNamespaceConflictDetector) are clean and focused. The global-namespace regression guard test is particularly thoughtful given Roslyn's inability to parse C# 14 extension(...) blocks.
Issue 1: Duplicate conflict detection in TypeSymbolExtensions.GetGeneratedMockNamespace (architecture concern)
GetGeneratedMockNamespace recomputes the conflict independently of the UseFallbackNamespace flag already on the model:
// TypeSymbolExtensions.cs — recomputes from scratch
public static string GetGeneratedMockNamespace(this INamedTypeSymbol type, Compilation compilation)
{
var hasEvents = type.GetMembers().OfType<IEventSymbol>().Any(); // all events
var fallback = MockNamespaceConflictDetector.HasConflict(compilation, type, hasEvents);
...
}This diverges from the model's decision in BuildSingleTypeModel:
UseFallbackNamespace = MockNamespaceConflictDetector.HasConflict(
compilation, namedType, hasEvents: events.Length > 0) // filtered eventsThe hasEvents inputs differ: the extension method uses raw symbol members (all events), while the model uses filtered/discovered events (e.g. inaccessible events from external assemblies could be excluded). In edge cases — mocking an external type that has internal events — these two paths could reach different conclusions and produce mismatched FQNs.
Better approach: GetAutoMockFactoryMethod is called while building member models, which occurs before the model is finalized, so passing UseFallbackNamespace directly isn't possible there. However, the method could accept the fallback bool as a parameter derived from the same events.Length > 0 computation, eliminating the re-derivation:
// MemberDiscovery.cs
private static string? GetAutoMockFactoryMethod(ITypeSymbol returnType, Compilation compilation, bool useFallback)This keeps the computation in one place and makes the dependency explicit.
Issue 2: Framework namespace pollution (design concern)
From the PR description:
Mocks for external-assembly types (e.g.
System.IO.Stream) now emit into the original namespace (StreamMocklives inSystem.IO). Legal C# and uniform with user-authored types.
While legal, placing generated types into System.IO (or any BCL namespace the user doesn't own) is surprising. IDE intellisense will show System.IO.StreamMock alongside System.IO.Stream; some analyzers and tools treat System.* namespaces specially. If a future .NET release adds a System.IO.StreamMock type, consumers get a compile error with no obvious cause.
Consider always using the fallback namespace for types from external assemblies not under a user-controlled root. This could be a simple guard in MockNamespaceConflictDetector.HasConflict or a separate check in BuildSingleTypeModel:
// Always fall back for types from referenced assemblies
if (!SymbolEqualityComparer.Default.Equals(namedType.ContainingAssembly, compilationAssembly))
return true; // use fallbackThis is also more conservative: if a user explicitly wants to mock IDisposable, the generated IDisposableMock living in System is confusing rather than helpful.
Issue 3: Breaking change scope — sub-namespace using directives
The safety stub preserves TUnit.Mocks.Generated so using TUnit.Mocks.Generated; keeps compiling. But users with using TUnit.Mocks.Generated.MyApp.Services; will get a compile error after this change, because those mocks now live in MyApp.Services instead.
This is a real breaking change for any consumer who had a qualified using for a generated namespace. It should be called out more explicitly in the PR description and ideally surfaced in migration documentation or a changelog entry.
Minor: ResolveNamespaceInCompilation linear scan per segment
The namespace resolution walks GetNamespaceMembers() with a linear scan per dotted segment. For deep namespaces with many siblings this is O(depth × breadth). This is unlikely to be a bottleneck in practice, but compilation.GetTypeByMetadataName can't help here; the existing approach is fine for now — just noting it for future profiling if conflict detection shows up in generator timing.
What works well
OptionalNamespaceBlockwith the no-op disposable is a clean abstraction that removes theif (ns != "")scatter from every builder.GetGlobalMockNamespacePrefixcorrectly consolidates the guard that caused theglobal::.TypeNamebug.MockNamespaceConflictDetectoris isolated, well-documented, and uses the consumer compilation for cross-assembly checks — the remarks section correctly explains why.UseFallbackNamespaceis included inEquals/GetHashCode, which is required for Roslyn's incremental generator caching to work correctly.- Test coverage is solid: global namespace, nested namespace, generics, events-conditional flag, and cross-assembly absence all covered.
- The safety stub backward-compat preservation is a considerate touch.
…t detector - Extract FallbackNamespaceRoot const + SelectMockNamespace helper shared by GetMockNamespace and GetGeneratedMockNamespace. - Reuse ToGlobalPrefix helper between MockImplBuilder.GetGlobalMockNamespacePrefix and MemberDiscovery.GetAutoMockFactoryMethod. - Replace per-call HashSet+interpolation in MockNamespaceConflictDetector with span-based suffix matching (zero alloc on no-conflict path). - Mark MockNamespaceConflictDetector.HasConflict internal (class is internal). - Replace LINQ Any() with foreach in GetGeneratedMockNamespace event check.
There was a problem hiding this comment.
Follow-up Review (round 2)
This review checks whether the three issues raised in round 1 have been addressed.
Issue 1: Duplicate conflict detection — still open
The root concern from round 1 remains unchanged. TypeSymbolExtensions.GetGeneratedMockNamespace (called from MemberDiscovery.GetAutoMockFactoryMethod at line 813) independently re-derives the fallback decision using type.GetMembers() to find events:
// TypeSymbolExtensions.cs L90-104
foreach (var member in type.GetMembers()) // all symbol members
{
if (member is IEventSymbol) { hasEvents = true; break; }
}
var fallback = MockNamespaceConflictDetector.HasConflict(compilation, type, hasEvents);This diverges from the model path in BuildSingleTypeModel:
// MockTypeDiscovery.cs L387-388
UseFallbackNamespace = MockNamespaceConflictDetector.HasConflict(
compilation, namedType, hasEvents: events.Length > 0) // filtered/discovered eventsThe GetMembers() path returns all raw symbol members, including inaccessible events from external assemblies (e.g. internal events not visible to the consumer assembly) and events on inherited interfaces. The events.Length path uses the result of MemberDiscovery.DiscoverMembers, which applies accessibility and deduplication filters. For a type from a referenced assembly that has internal events, these two paths can reach different conclusions, causing GetAutoMockFactoryMethod to emit an AutoMockFactoryMethod reference in one namespace while the factory itself is generated in the other.
Suggested fix (as proposed in round 1): accept useFallback as a parameter instead of recomputing it:
// MemberDiscovery.cs
private static string? GetAutoMockFactoryMethod(
ITypeSymbol returnType, Compilation compilation, bool useFallback)
{
// ... existing guards ...
var factoryNamespace = MockImplBuilder.SelectMockNamespace(namespaceName, useFallback);
var globalPrefix = MockImplBuilder.ToGlobalPrefix(factoryNamespace);
// ...
}Then GetGeneratedMockNamespace in TypeSymbolExtensions could be removed entirely — it is only called from GetAutoMockFactoryMethod, so eliminating the re-derivation also eliminates the divergence surface.
Issue 2: Framework namespace pollution — still open
The PR description states: "Mocks for external-assembly types (e.g. System.IO.Stream) now emit into the original namespace (StreamMock lives in System.IO)."
The IsFrameworkNamespace guard prevents transitive auto-mocking of BCL interfaces but it does not affect direct mocking. A user calling Mock.Of<System.IO.Stream>() still gets StreamMock placed in System.IO. This is surprising from both a discoverability standpoint (IDE completion in System.IO now shows StreamMock) and a future-compatibility standpoint (a future System.IO.StreamMock in the BCL would cause a compile error with no obvious cause).
The most conservative and correct fix is to always use the fallback namespace when the target type comes from an assembly that is not the consumer's own compilation assembly. This is a one-liner in BuildSingleTypeModel and BuildDelegateTypeModel:
UseFallbackNamespace = !SymbolEqualityComparer.Default.Equals(
namedType.ContainingAssembly, compilationAssembly)
|| MockNamespaceConflictDetector.HasConflict(compilation, namedType, hasEvents: events.Length > 0)This is also more consistent with what IsFrameworkNamespace does for transitive types — if a BCL return type is not worth auto-mocking into the target namespace, it is also not worth polluting System.* with user-generated mocks.
Issue 3: Breaking change for sub-namespace using directives
The safety stub preserves TUnit.Mocks.Generated (the root), so using TUnit.Mocks.Generated; still compiles. But any consumer that had using TUnit.Mocks.Generated.MyApp.Services; will get a compile error after this change, because mocks that previously lived in TUnit.Mocks.Generated.MyApp.Services now live in MyApp.Services.
This breaking change was noted in round 1 but is still not called out in the PR description or in any migration notes. For a library that auto-generates user-facing code, this is meaningful: it will break CI for anyone who upgrades without auditing their using directives.
Minimum ask: add a note in the PR description / changelog entry documenting the breaking change and what affected consumers need to do (remove the TUnit.Mocks.Generated.{namespace} using directives and replace with the natural namespace).
What's good
The core implementation is solid. The snapshot tests are comprehensive, the OptionalNamespaceBlock abstraction is clean, the global-namespace regression test is well-designed, and UseFallbackNamespace correctly participates in Equals/GetHashCode for incremental caching. No regressions were introduced.
The three issues above are the same ones from round 1 and represent genuine correctness risk (issue 1), surprising user-visible behaviour (issue 2), and an undocumented break (issue 3). Issue 3 is documentation-only; issues 1 and 2 are code changes.
…all sites Reviewer flagged a real divergence: BuildSingleTypeModel passed events.Length > 0 (filtered by accessibility), GetGeneratedMockNamespace passed type.GetMembers().OfType<IEventSymbol>().Any() (raw symbol). Edge case (external type with inaccessible events + a user-declared T_MockEvents class) yielded different fallback decisions, producing a model that emits to the original namespace while the auto-mock factory reference points at the fallback namespace. CS0246 at consumer compile time. Fix: detector always checks all seven emitted suffixes. Slightly more conservative (a colliding T_MockEvents flips fallback even for event-less targets), but always safe and eliminates the divergence.
|
Thanks for the review @claude. Addressed point-by-point: 1. Divergent conflict detection — fixed in 2. Framework namespace pollution — keeping current behaviour. The risk is real but the source generator re-evaluates conflict detection on every build, so if .NET (or any reference) later adds a colliding type the next compile automatically flips the affected mocks to the fallback namespace. The "no obvious cause" failure mode the reviewer worries about would manifest as a normal compile error pointing at the colliding type — same UX as any other namespace conflict. The user explicitly chose this trade-off when designing the change; the auto-recovery property makes the choice safe rather than fragile. 3. Breaking-change scope — accurate but acceptable for the same reason as (2): existing Minor — namespace resolution scan: agreed, not a hot path. Leaving as-is. |
|
Claude finished @thomhurst's task in 1m 22s —— View job Round 3 ReviewTasks
Issue 1: Duplicate conflict detection — FIXED ✓The fix is clean and correct.
Since The choice to unconditionally check all seven suffixes (including
Issue 2: Framework namespace pollution — accepted as designedThe auto-recovery argument holds: the detector re-runs on every incremental build, so any future BCL collision auto-corrects on next compile with a normal, diagnosable error. This is a reasonable trade-off and I accept the author's reasoning. Issue 3: Breaking change scope — note promisedThe breaking change (removal of VerdictAll three issues from rounds 1 and 2 are resolved: Issue 1 by a code fix, Issues 2 and 3 by deliberate design choices with clear reasoning. The implementation is correct. Pending the PR description note for the breaking change, this is ready to merge. |
There was a problem hiding this comment.
Round 4 Review — Final
All three issues from rounds 1–3 have been resolved. Here is the final assessment.
Issue 1: Duplicate conflict detection — FIXED ✓
The hasEvents parameter was removed from HasConflict entirely. The detector now unconditionally checks all seven emitted suffixes, giving both call sites — BuildSingleTypeModel and TypeSymbolExtensions.GetGeneratedMockNamespace — identical inputs and outputs for any (compilation, namedType) pair. The divergence that could cause mismatched FQNs is fully eliminated. The EventsExtensionsCollision_AlwaysFlagged test correctly documents the deliberate conservatism.
Issue 2: Framework namespace pollution — accepted as designed ✓
The auto-recovery argument is sound: HasConflict runs at every incremental build, so any future BCL collision auto-corrects on next compile with a diagnosable error pointing at the colliding type. This is a reasonable trade-off. The Notes section in the PR description now makes this explicit.
Issue 3: Breaking change for sub-namespace using directives — DOCUMENTED ✓
The PR description now has a dedicated Breaking change section that:
- Names the exact pattern that breaks (
using TUnit.Mocks.Generated.MyApp.Services;) - Explains why (mocks now live in the natural namespace)
- Gives the fix (drop the qualified
using) - Clarifies that the root
using TUnit.Mocks.Generated;is preserved - Explains the auto-recovery behaviour for future collisions
This is exactly what was requested in round 2.
Overall verdict
All issues are resolved — one by code fix (Issue 1), one by design acceptance with documented reasoning (Issue 2), and one by PR description update (Issue 3). The implementation is correct, the incremental-generator model is sound (UseFallbackNamespace participates in Equals/GetHashCode), test coverage is comprehensive, and the breaking change is properly called out. Ready to merge.
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.43.11 to 1.44.0. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.44.0 <!-- Release notes generated using configuration in .github/release.yml at v1.44.0 --> ## What's Changed ### Other Changes * Generated mocks live in the same namespace as the mocked type by @thomhurst in thomhurst/TUnit#5870 * Show multi-step test spans in class timeline, align report ordering with execution, and correlate linked OTel activities by @Copilot in thomhurst/TUnit#5847 * fix: don't leak RUC onto Should-style comparer overloads (#5857) by @thomhurst in thomhurst/TUnit#5873 * Fix culture-dependent timestamp in HTML test report (#5868) by @thomhurst in thomhurst/TUnit#5872 * fix(mocks-http): auto-prepend `/` to partial endpoint paths (#5838) by @thomhurst in thomhurst/TUnit#5874 * Replace Report.ExpandClassTimeline with [ClassTimeline] attribute by @thomhurst in thomhurst/TUnit#5875 * feat(assertions): make ShouldAssertion<T> implement IAssertion (#5824) by @thomhurst in thomhurst/TUnit#5876 * feat(mocks): support non-span ref struct out/ref params by @thomhurst in thomhurst/TUnit#5878 * fix(core): fill optional params when invoking MethodDataSource via reflection by @thomhurst in thomhurst/TUnit#5880 * Mocks: structural fix for Mock<T> / mocked-member name collisions by @thomhurst in thomhurst/TUnit#5881 * chore(mocks): promote TUnit.Mocks packages to stable by @thomhurst in thomhurst/TUnit#5877 ### Dependencies * chore(deps): update tunit to 1.43.41 by @thomhurst in thomhurst/TUnit#5863 * chore(deps): update dependency tunit.assertions.fsharp to 1.43.41 by @thomhurst in thomhurst/TUnit#5865 * chore(deps): bump @babel/plugin-transform-modules-systemjs from 7.28.5 to 7.29.4 in /docs by @dependabot[bot] in thomhurst/TUnit#5867 * chore(deps): bump fast-uri from 3.1.0 to 3.1.2 in /docs by @dependabot[bot] in thomhurst/TUnit#5862 **Full Changelog**: thomhurst/TUnit@v1.43.41...v1.44.0 ## 1.43.41 <!-- Release notes generated using configuration in .github/release.yml at v1.43.41 --> ## What's Changed ### Other Changes * feat(playwright): expose default Context/Launch options on settings by @thomhurst in thomhurst/TUnit#5861 * fix(hooks): resolve TestDiscovery hook context type by attribute kind, not method name by @thomhurst in thomhurst/TUnit#5860 ### Dependencies * chore(deps): update tunit to 1.43.38 by @thomhurst in thomhurst/TUnit#5858 **Full Changelog**: thomhurst/TUnit@v1.43.38...v1.43.41 ## 1.43.38 <!-- Release notes generated using configuration in .github/release.yml at v1.43.38 --> ## What's Changed ### Other Changes * feat(playwright): add TUnitPlaywrightSettings defaults by @thomhurst in thomhurst/TUnit#5859 **Full Changelog**: thomhurst/TUnit@v1.43.37...v1.43.38 ## 1.43.37 <!-- Release notes generated using configuration in .github/release.yml at v1.43.37 --> ## What's Changed ### Other Changes * docs: clarify MethodDataSourceAttribute.Factory is source-generator-managed by @Copilot in thomhurst/TUnit#5835 * fix(assertions): skip ref-struct members in IsEquivalentTo (#5841) by @thomhurst in thomhurst/TUnit#5842 * feat(playwright): add composition-based fixtures by @thomhurst in thomhurst/TUnit#5840 ### Dependencies * chore(deps): update tunit to 1.43.11 by @thomhurst in thomhurst/TUnit#5821 * chore(deps): update dependency polyfill to 10.4.0 by @thomhurst in thomhurst/TUnit#5830 * chore(deps): update dependency polyfill to 10.4.0 by @thomhurst in thomhurst/TUnit#5829 * chore(deps): update react to ^19.2.6 by @thomhurst in thomhurst/TUnit#5839 * chore(deps): update dependency polyfill to 10.5.0 by @thomhurst in thomhurst/TUnit#5848 * chore(deps): update dependency polyfill to 10.5.0 by @thomhurst in thomhurst/TUnit#5849 * chore(deps): update aspire to 13.3.0 by @thomhurst in thomhurst/TUnit#5851 * chore(deps): update dependency brace-expansion to v5.0.6 by @thomhurst in thomhurst/TUnit#5853 * chore(deps): update dependency polyfill to 10.5.1 by @thomhurst in thomhurst/TUnit#5854 * chore(deps): update dependency polyfill to 10.5.1 by @thomhurst in thomhurst/TUnit#5855 * chore(deps): update verify to 31.16.3 by @thomhurst in thomhurst/TUnit#5856 **Full Changelog**: thomhurst/TUnit@v1.43.11...v1.43.37 Commits viewable in [compare view](thomhurst/TUnit@v1.43.11...v1.44.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
{Type}Mock,{Type}Mockable,{Type}MockFactory,{Type}_MockStaticExtension,{Type}_MockMemberExtensions, plus events extensions) now emit into the same namespace as the mocked type instead ofTUnit.Mocks.Generated.{Namespace}.TUnit.Mocks.Generated[.{ns}]— the existing scheme — so mocks never overwrite user code.MockEventsBuilderandMockMembersBuilderalways emitted intoTUnit.Mocks.Generated, regardless of the mocked type's namespace.MockNamespaceConflictDetectorwhich walks the consumer's compilation symbol table during the semantic transform phase. Cost is negligible — runs once perMockTypeModel, not per syntax node.Breaking change
Code that imports a generated sub-namespace such as
using TUnit.Mocks.Generated.MyApp.Services;will no longer compile, because mocks now live inMyApp.Servicesdirectly. The fix is to drop the qualifiedusing(the file's own namespace usually already imports it). The post-initnamespace TUnit.Mocks.Generated;safety stub is preserved, so plainusing TUnit.Mocks.Generated;continues to compile.If a future framework version introduces a type whose name collides with one of the generator's suffixes, the source generator automatically detects the new collision on the next build and flips the affected mocks back to the
TUnit.Mocks.Generated.*namespace — no manual migration required.Test plan
TUnit.Mocks.SourceGenerator.Tests— 174/174 pass across net8.0, net9.0, net10.0 (snapshots refreshed for the new namespace placement).TUnit.Mocks.Tests— 968/968 pass on net10.0 (one staleusing TUnit.Mocks.Generated.TUnit.Mocks.Tests;removed).TUnit.Mocks.Analyzers.Tests— 30/30 pass on net10.0.Global_Namespace_Mock_Does_Not_Emit_Empty_Namespace_Prefixregression test asserts the generator does not emit invalidglobal::.TypeNamesyntax for globally-namespaced mocks.Conflict_With_Existing_Type_Falls_Back_To_Generated_Namespacesnapshot exercises the fallback path.Notes
System.IO.Stream) emit into the original namespace (StreamMocklives inSystem.IO). Legal C# and uniform with user-authored types; conflict detection on the next build automatically reroutes if a collision later appears.