Always emit TUnit.Mocks.Generated namespace from source generator#5282
Always emit TUnit.Mocks.Generated namespace from source generator#5282
Conversation
…hen no mocks are generated Fixes the bug where installing TUnit.Mocks without using any mocks causes a CS0234 build error because the `global using TUnit.Mocks.Generated;` directive references a namespace that doesn't exist yet. The fix uses RegisterPostInitializationOutput to always emit a minimal file declaring the TUnit.Mocks.Generated namespace. Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/95943b6a-aa9a-4365-afdd-16d5f96e0f31 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
There was a problem hiding this comment.
Review: Always emit TUnit.Mocks.Generated namespace from source generator
Summary
This PR fixes a real bug: TUnit.Mocks.targets unconditionally injects global using TUnit.Mocks.Generated;, but the namespace only exists when the source generator has emitted at least one mock. Installing the package without any Mock.Of<T>() usage causes a CS0234 build error. The fix is correct and the approach is sound.
What Works Well
- Using
RegisterPostInitializationOutputis exactly the right Roslyn API for this purpose. It runs unconditionally before any syntax-driven pipeline steps and its output is guaranteed to always be emitted, which is precisely the invariant needed here. - The generated file is minimal — just the namespace declaration — so there's no unnecessary code in projects that don't use mocks.
- Snapshot tests were updated to reflect the new output, following the project's snapshot-testing conventions.
Concerns and Suggestions
1. Missing snapshot test for the zero-mock scenario (the actual bug case)
The existing snapshot tests all exercise projects with at least one Mock.Of<T>() call. The root cause of the bug is what happens with no mock usage at all — yet there's no test for that path. A test like No_Mock_Usage_Still_Emits_Namespace that compiles a source file referencing TUnit.Mocks but containing no Mock.Of<T>() call would directly capture the regression. Without it, a future change that inadvertently removes the RegisterPostInitializationOutput call would not be caught by the snapshot suite.
2. Order of the generated file in snapshots is surprising
Because RegisterPostInitializationOutput runs before the syntax-driven pipeline, and SnapshotTestBase sorts generated files by file path using StringComparer.Ordinal, the namespace stub (TUnit.Mocks.Generated.Namespace.g.cs) will sort after all the type-specific files in the current snapshots (e.g. it appears last in every .verified.txt). This is not a bug, but it is counterintuitive — readers scanning a snapshot may be confused by a bare namespace declaration appended at the end after a full mock implementation. A comment in SnapshotTestBase.RunGeneratorAndFormat or a note in the file itself would clarify why it always appears.
3. The namespace stub could declare the file-scoped namespace more explicitly
The current generated content is:
// <auto-generated/>
#nullable enable
namespace TUnit.Mocks.Generated;This is syntactically valid and will compile, but it declares an empty file-scoped namespace with no members. There is a tiny risk that some downstream tool (e.g. a documentation generator or an overzealous analyzer) emits a warning about an empty namespace. A common defensive pattern is to add a dummy internal marker type:
namespace TUnit.Mocks.Generated
{
// Marker type emitted unconditionally to ensure the namespace always exists.
internal static class __Namespace__ { }
}This is optional — the empty file-scoped namespace compiles cleanly — but it makes the intent unambiguous and sidesteps any analyzer noise about empty namespaces.
4. Consider guarding the global using with a build property instead
An alternative (arguably more robust) design would be to make the .targets injection conditional on the source generator having something to emit, or to use the existing TUnitMockImplicitUsings escape hatch to let users opt out. However, emitting a guaranteed namespace stub from the generator is simpler and more transparent than a conditional MSBuild property, so the chosen approach is reasonable. This is purely a note, not a blocker.
Bottom Line
The fix is correct and the API used is appropriate. The main actionable gap is a dedicated snapshot test for the zero-mock scenario that reproduces the exact bug described in the PR. Adding that test would give confidence the fix is verified and prevent future regressions.
…on mismatch RegisterPostInitializationOutput sources need the same language version as the test compilation. Without forwarding parseOptions, the driver parses post-init sources with the default version, causing "Inconsistent language versions" errors in all 16 snapshot tests.
There was a problem hiding this comment.
Follow-up Review
Picking up from the previous review — a new commit was added after that review: d6d2c1ef fix: pass parseOptions to CSharpGeneratorDriver to fix language version mismatch.
New Commit Analysis
TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
-GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+GeneratorDriver driver = CSharpGeneratorDriver.Create([generator.AsSourceGenerator()], parseOptions: parseOptions);This is a meaningful fix, not cosmetic. Two things were wrong before:
-
Missing
parseOptions: Without forwarding the parse options, theCSharpGeneratorDriverwas running against a mismatched language version. This matters because the post-initialization output uses file-scoped namespace syntax (namespace TUnit.Mocks.Generated;), which requires C# 10+. If tests were compiled against an older parse option, the generated stub would fail to round-trip through the compilation correctly. Passing the options through ensures the test harness and the compilation are aligned. -
generator.AsSourceGenerator():CSharpGeneratorDriver.CreateacceptsISourceGenerator[], notIIncrementalGenerator[]. Passing theIIncrementalGeneratordirectly worked via an implicit adaptation, but calling.AsSourceGenerator()is the explicit, documented API to wrap an incremental generator. Using it is clearer and avoids any ambiguity about which overload is resolved.
Both of these are good improvements to the test infrastructure.
Outstanding Concern from Previous Review
The main gap identified previously is still present: there is no snapshot test covering the zero-mock scenario (i.e., a project that references TUnit.Mocks but contains no Mock.Of<T>() calls). This is precisely the regression case described in the PR description. All 16 existing snapshot tests exercise the generator with at least one mock interface, so a future removal of RegisterPostInitializationOutput would go undetected by the test suite.
A test like this would close the gap:
[Test]
public Task No_Mock_Usage_Emits_Namespace_File()
{
// Source with no Mock.Of<T>() calls
var source = "public class Foo { }";
var results = RunGenerator(source);
// Should still contain TUnit.Mocks.Generated.Namespace.g.cs
return Verify(results);
}Bottom Line
The new commit is a solid improvement to the test harness and correctly addresses the language version alignment issue. The core fix (RegisterPostInitializationOutput) remains correct. The one actionable gap is still the missing zero-mock snapshot test to explicitly verify the bug scenario reported in the PR description.
…tput The namespace file emitted by RegisterPostInitializationOutput produces an extra blank line before the FILE SEPARATOR and no trailing newline, which differed from the manually-written snapshots.
There was a problem hiding this comment.
Code Review
Fix is correct and well-targeted. The root cause (global using targeting a namespace that only exists after code generation) and the solution (always-emit via RegisterPostInitializationOutput) are both sound.
What's good
- Using
RegisterPostInitializationOutputis the idiomatic Roslyn incremental generator way to emit files unconditionally. It's efficient (runs once at startup, not on every re-generation), correct, and clean. - The
SnapshotTestBase.cschange is an important fix alongside the main one: passingparseOptionstoCSharpGeneratorDriver.Createensures the generator sees the correct language version (notablyLanguageVersion.Preview) which the old call silently dropped. - All 16 snapshot files are updated consistently.
Issue: Missing regression test for the actual bug scenario
The change that motivated this fix — installing TUnit.Mocks without any Mock.Of<T>() usage causing CS0234 — has no dedicated test. Every existing snapshot test exercises a project with mock usage, so they don't cover the empty case.
A test like this would directly validate the fix and prevent regression:
[Test]
public Task No_Mock_Usages_Still_Emits_Namespace_File()
{
// Reproduces: CS0234 when TUnit.Mocks installed but no Mock.Of<T>() used
var source = """
// No Mock.Of<T>() calls — namespace file must still be emitted
public class SomeClass { }
""";
return VerifyGeneratorOutput(source);
}This would produce a snapshot containing only the namespace file, confirming the fix works for the exact scenario described in the PR description. Without it, a future refactor could accidentally revert to conditional emission and the test suite would still pass.
Minor: missing trailing newline in emitted file
The generated file ends without a newline:
namespace TUnit.Mocks.Generated;
(confirmed by \ No newline at end of file in every snapshot diff)
This is cosmetically inconsistent with typical generated file conventions and shows up in every snapshot. Adding a trailing newline to the raw string in RegisterPostInitializationOutput would clean this up:
ctx.AddSource("TUnit.Mocks.Generated.Namespace.g.cs", """
// <auto-generated/>
#nullable enable
namespace TUnit.Mocks.Generated;
"""); // <-- add a blank line before the closing triple-quoteSummary
The core fix is correct and the approach is idiomatic. The two items above are worth addressing — the missing test in particular, since this is exactly the kind of edge case (no user code triggering the generator) that's easy to overlook in future changes.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.21.30 to 1.23.7. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.23.7 <!-- Release notes generated using configuration in .github/release.yml at v1.23.7 --> ## What's Changed ### Other Changes * feat: use results directory provided by Microsoft Testing Platform in HtmlReporter by @DavidZidar in thomhurst/TUnit#5294 * feat: add benchmarks for Imposter and Mockolate mocking frameworks by @vbreuss in thomhurst/TUnit#5295 * feat: add TUnit0080 analyzer for missing polyfill types by @thomhurst in thomhurst/TUnit#5292 * fix: respect user-set TUnitImplicitUsings from Directory.Build.props by @thomhurst in thomhurst/TUnit#5280 * perf: optimize TUnit.Mocks hot paths by @thomhurst in thomhurst/TUnit#5300 ### Dependencies * chore(deps): update tunit to 1.22.19 by @thomhurst in thomhurst/TUnit#5296 ## New Contributors * @DavidZidar made their first contribution in thomhurst/TUnit#5294 **Full Changelog**: thomhurst/TUnit@v1.22.19...v1.23.7 ## 1.22.19 <!-- Release notes generated using configuration in .github/release.yml at v1.22.19 --> ## What's Changed ### Other Changes * Add mock library benchmarks: TUnit.Mocks vs Moq, NSubstitute, FakeItEasy by @Copilot in thomhurst/TUnit#5284 * perf: lazily initialize optional MockEngine collections by @thomhurst in thomhurst/TUnit#5289 * Always emit TUnit.Mocks.Generated namespace from source generator by @Copilot in thomhurst/TUnit#5282 ### Dependencies * chore(deps): update tunit to 1.22.6 by @thomhurst in thomhurst/TUnit#5285 **Full Changelog**: thomhurst/TUnit@v1.22.6...v1.22.19 ## 1.22.6 <!-- Release notes generated using configuration in .github/release.yml at v1.22.6 --> ## What's Changed ### Other Changes * fix: use IComputeResource to filter waitable Aspire resources by @thomhurst in thomhurst/TUnit#5278 * fix: preserve StateBag when creating per-test TestBuilderContext by @thomhurst in thomhurst/TUnit#5279 ### Dependencies * chore(deps): update tunit to 1.22.3 by @thomhurst in thomhurst/TUnit#5275 **Full Changelog**: thomhurst/TUnit@v1.22.3...v1.22.6 ## 1.22.3 <!-- Release notes generated using configuration in .github/release.yml at v1.22.3 --> ## What's Changed ### Other Changes * fix: pass assembly version properties to dotnet pack by @thomhurst in thomhurst/TUnit#5274 ### Dependencies * chore(deps): update tunit to 1.22.0 by @thomhurst in thomhurst/TUnit#5272 **Full Changelog**: thomhurst/TUnit@v1.22.0...v1.22.3 ## 1.22.0 <!-- Release notes generated using configuration in .github/release.yml at v1.22.0 --> ## What's Changed ### Other Changes * perf: run GitVersion once in CI instead of per-project by @slang25 in thomhurst/TUnit#5259 * perf: disable GitVersion MSBuild task globally by @thomhurst in thomhurst/TUnit#5266 * fix: skip IResourceWithoutLifetime resources in Aspire fixture wait logic by @thomhurst in thomhurst/TUnit#5268 * fix: relax docs site Node.js engine constraint to >=24 by @thomhurst in thomhurst/TUnit#5269 * fix: catch unhandled exceptions in ExecuteRequestAsync to prevent IDE RPC crashes by @thomhurst in thomhurst/TUnit#5271 * feat: register HTML report as MTP session artifact by @thomhurst in thomhurst/TUnit#5270 ### Dependencies * chore(deps): update tunit to 1.21.30 by @thomhurst in thomhurst/TUnit#5254 * chore(deps): update opentelemetry to 1.15.1 by @thomhurst in thomhurst/TUnit#5258 * chore(deps): bump node-forge from 1.3.1 to 1.4.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5255 * chore(deps): bump picomatch from 2.3.1 to 2.3.2 in /docs by @dependabot[bot] in thomhurst/TUnit#5256 * chore(deps): update react by @thomhurst in thomhurst/TUnit#5261 * chore(deps): update node.js to >=18.20.8 by @thomhurst in thomhurst/TUnit#5262 * chore(deps): update node.js to v24 by @thomhurst in thomhurst/TUnit#5264 **Full Changelog**: thomhurst/TUnit@v1.21.30...v1.22.0 Commits viewable in [compare view](thomhurst/TUnit@v1.21.30...v1.23.7). </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>
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.21.6 to 1.23.7. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.23.7 <!-- Release notes generated using configuration in .github/release.yml at v1.23.7 --> ## What's Changed ### Other Changes * feat: use results directory provided by Microsoft Testing Platform in HtmlReporter by @DavidZidar in thomhurst/TUnit#5294 * feat: add benchmarks for Imposter and Mockolate mocking frameworks by @vbreuss in thomhurst/TUnit#5295 * feat: add TUnit0080 analyzer for missing polyfill types by @thomhurst in thomhurst/TUnit#5292 * fix: respect user-set TUnitImplicitUsings from Directory.Build.props by @thomhurst in thomhurst/TUnit#5280 * perf: optimize TUnit.Mocks hot paths by @thomhurst in thomhurst/TUnit#5300 ### Dependencies * chore(deps): update tunit to 1.22.19 by @thomhurst in thomhurst/TUnit#5296 ## New Contributors * @DavidZidar made their first contribution in thomhurst/TUnit#5294 **Full Changelog**: thomhurst/TUnit@v1.22.19...v1.23.7 ## 1.22.19 <!-- Release notes generated using configuration in .github/release.yml at v1.22.19 --> ## What's Changed ### Other Changes * Add mock library benchmarks: TUnit.Mocks vs Moq, NSubstitute, FakeItEasy by @Copilot in thomhurst/TUnit#5284 * perf: lazily initialize optional MockEngine collections by @thomhurst in thomhurst/TUnit#5289 * Always emit TUnit.Mocks.Generated namespace from source generator by @Copilot in thomhurst/TUnit#5282 ### Dependencies * chore(deps): update tunit to 1.22.6 by @thomhurst in thomhurst/TUnit#5285 **Full Changelog**: thomhurst/TUnit@v1.22.6...v1.22.19 ## 1.22.6 <!-- Release notes generated using configuration in .github/release.yml at v1.22.6 --> ## What's Changed ### Other Changes * fix: use IComputeResource to filter waitable Aspire resources by @thomhurst in thomhurst/TUnit#5278 * fix: preserve StateBag when creating per-test TestBuilderContext by @thomhurst in thomhurst/TUnit#5279 ### Dependencies * chore(deps): update tunit to 1.22.3 by @thomhurst in thomhurst/TUnit#5275 **Full Changelog**: thomhurst/TUnit@v1.22.3...v1.22.6 ## 1.22.3 <!-- Release notes generated using configuration in .github/release.yml at v1.22.3 --> ## What's Changed ### Other Changes * fix: pass assembly version properties to dotnet pack by @thomhurst in thomhurst/TUnit#5274 ### Dependencies * chore(deps): update tunit to 1.22.0 by @thomhurst in thomhurst/TUnit#5272 **Full Changelog**: thomhurst/TUnit@v1.22.0...v1.22.3 ## 1.22.0 <!-- Release notes generated using configuration in .github/release.yml at v1.22.0 --> ## What's Changed ### Other Changes * perf: run GitVersion once in CI instead of per-project by @slang25 in thomhurst/TUnit#5259 * perf: disable GitVersion MSBuild task globally by @thomhurst in thomhurst/TUnit#5266 * fix: skip IResourceWithoutLifetime resources in Aspire fixture wait logic by @thomhurst in thomhurst/TUnit#5268 * fix: relax docs site Node.js engine constraint to >=24 by @thomhurst in thomhurst/TUnit#5269 * fix: catch unhandled exceptions in ExecuteRequestAsync to prevent IDE RPC crashes by @thomhurst in thomhurst/TUnit#5271 * feat: register HTML report as MTP session artifact by @thomhurst in thomhurst/TUnit#5270 ### Dependencies * chore(deps): update tunit to 1.21.30 by @thomhurst in thomhurst/TUnit#5254 * chore(deps): update opentelemetry to 1.15.1 by @thomhurst in thomhurst/TUnit#5258 * chore(deps): bump node-forge from 1.3.1 to 1.4.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5255 * chore(deps): bump picomatch from 2.3.1 to 2.3.2 in /docs by @dependabot[bot] in thomhurst/TUnit#5256 * chore(deps): update react by @thomhurst in thomhurst/TUnit#5261 * chore(deps): update node.js to >=18.20.8 by @thomhurst in thomhurst/TUnit#5262 * chore(deps): update node.js to v24 by @thomhurst in thomhurst/TUnit#5264 **Full Changelog**: thomhurst/TUnit@v1.21.30...v1.22.0 ## 1.21.30 <!-- Release notes generated using configuration in .github/release.yml at v1.21.30 --> ## What's Changed ### Other Changes * feat: add test discovery Activity span for tracing by @thomhurst in thomhurst/TUnit#5246 * Fix mock generator not preserving nullable annotations on reference types by @Copilot in thomhurst/TUnit#5251 * Fix ITestSkippedEventReceiver not firing for [Skip]-attributed tests by @thomhurst in thomhurst/TUnit#5253 * Use CallerArgumentExpression for TestDataRow by default. by @m-gasser in thomhurst/TUnit#5135 ### Dependencies * chore(deps): update tunit to 1.21.24 by @thomhurst in thomhurst/TUnit#5247 **Full Changelog**: thomhurst/TUnit@v1.21.24...v1.21.30 ## 1.21.24 <!-- Release notes generated using configuration in .github/release.yml at v1.21.24 --> ## What's Changed ### Other Changes * Fix OpenTelemetry missing root span by reordering session activity lifecycle by @Copilot in thomhurst/TUnit#5245 ### Dependencies * chore(deps): update tunit to 1.21.20 by @thomhurst in thomhurst/TUnit#5241 * chore(deps): update dependency stackexchange.redis to 2.12.8 by @thomhurst in thomhurst/TUnit#5243 **Full Changelog**: thomhurst/TUnit@v1.21.20...v1.21.24 ## 1.21.20 <!-- Release notes generated using configuration in .github/release.yml at v1.21.20 --> ## What's Changed ### Other Changes * fix: respect TUnitImplicitUsings set in Directory.Build.props by @thomhurst in thomhurst/TUnit#5225 * feat: covariant assertions for interfaces and non-sealed classes by @thomhurst in thomhurst/TUnit#5226 * feat: support string-to-parseable type conversions in [Arguments] by @thomhurst in thomhurst/TUnit#5227 * feat: add string length range assertions by @thomhurst in thomhurst/TUnit#4935 * Fix BeforeEvery/AfterEvery hooks for Class and Assembly not being executed by @Copilot in thomhurst/TUnit#5239 ### Dependencies * chore(deps): update tunit to 1.21.6 by @thomhurst in thomhurst/TUnit#5228 * chore(deps): update dependency gitversion.msbuild to 6.7.0 by @thomhurst in thomhurst/TUnit#5229 * chore(deps): update dependency gitversion.tool to v6.7.0 by @thomhurst in thomhurst/TUnit#5230 * chore(deps): update aspire to 13.2.0 - autoclosed by @thomhurst in thomhurst/TUnit#5232 * chore(deps): update dependency typescript to v6 by @thomhurst in thomhurst/TUnit#5233 * chore(deps): update dependency polyfill to 9.23.0 by @thomhurst in thomhurst/TUnit#5235 * chore(deps): update dependency polyfill to 9.23.0 by @thomhurst in thomhurst/TUnit#5236 **Full Changelog**: thomhurst/TUnit@v1.21.6...v1.21.20 Commits viewable in [compare view](thomhurst/TUnit@v1.21.6...v1.23.7). </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>
TUnit.Mocks.targetsunconditionally addsglobal using TUnit.Mocks.Generated;to consuming projects, but the namespace only exists when the source generator has produced mock code. Installing TUnit.Mocks without anyMock.Of<T>()usage causes CS0234 at build time.Changes
MockGenerator.cs: AddedRegisterPostInitializationOutputto always emit a minimal file declaring theTUnit.Mocks.Generatednamespace, regardless of whether any mocks are discovered:.verified.txtfiles to include the new namespace file in expected output✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.