Skip to content

feat(mocks): shorter, more readable generated mock type names#5334

Merged
thomhurst merged 2 commits intomainfrom
feat/mocks-shorter-type-names
Apr 1, 2026
Merged

feat(mocks): shorter, more readable generated mock type names#5334
thomhurst merged 2 commits intomainfrom
feat/mocks-shorter-type-names

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • IGreeter.Mock() now returns IGreeterMock instead of IGreeter_Mock — cleaner for typed field/variable declarations
  • Namespaced types use sub-namespaces for disambiguation instead of flattening namespace into the type name (e.g. ExternalServiceMock in TUnit.Mocks.Generated.ExternalLib instead of ExternalLib_ExternalService_Mock in TUnit.Mocks.Generated)
  • Generic types produce cleaner names: IRepository_string_Mock instead of IRepository_string__Mock (collapsed double underscores)

Naming changes

Type Before After
Wrapper MyApp_IGreeter_Mock IGreeterMock
Impl MyApp_IGreeter_MockImpl IGreeterMockImpl
Factory MyApp_IGreeter_MockFactory IGreeterMockFactory
Bridge MyApp_IFoo_Mockable IFooMockable
Generic IRepo_string__Mock IRepo_string_Mock

Namespace strategy

  • Global namespace typesTUnit.Mocks.Generated (unchanged)
  • Namespaced typesTUnit.Mocks.Generated.{OriginalNamespace}
  • Member/event extensions → stay in TUnit.Mocks.Generated with full FQN-safe names (not user-facing)
  • Static .Mock() extension class → stays in TUnit.Mocks with full FQN-safe name (not user-facing)

Test plan

  • All 24 source generator snapshot tests pass
  • All 739 TUnit.Mocks.Tests integration tests pass
  • Multi-interface mocks disambiguated via GetCompositeShortSafeName
  • Generic types produce clean names with collapsed underscores
  • Namespaced types correctly placed in sub-namespaces

IGreeter.Mock() now returns IGreeterMock instead of IGreeter_Mock.
Namespaced types use sub-namespaces for disambiguation instead of
flattening the namespace into the type name (e.g. ExternalServiceMock
in TUnit.Mocks.Generated.ExternalLib instead of
ExternalLib_ExternalService_Mock in TUnit.Mocks.Generated).
@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 1, 2026

Not up to standards ⛔

🔴 Issues 6 minor

Alerts:
⚠ 6 issues (≤ 0 issues of at least minor severity)

Results:
6 new issues

Category Results
CodeStyle 6 minor

View in Codacy

🟢 Metrics 13 complexity

Metric Results
Complexity 13

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

@thomhurst thomhurst linked an issue Apr 1, 2026 that may be closed by this pull request
1 task
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: feat(mocks): shorter, more readable generated mock type names

This is a well-executed DX improvement. The naming changes make generated types feel much more like natural C# code.

What's Good

Naming strategy is correct. Moving from MyApp_IGreeter_MockIGreeterMock aligns with how users would naturally name these types. The sub-namespace approach for namespaced types (TUnit.Mocks.Generated.MyApp.IGreeterMock) preserves scoping semantics cleanly instead of the old namespace-flattening that polluted TUnit.Mocks.Generated with noise.

Extension class naming split is right. Using the full FQN-safe name for the static extension holder (MyApp_IGreeter_MockStaticExtension) while exposing the short name via the return type is the correct trade-off — the extension class lives in the global TUnit.Mocks namespace and must be unique across all mocked types, while the wrapper type lives in a scoped namespace.

Generic encoding is improved. Collapsing IRepository_string__MockIRepository_string_Mock by eliminating redundant underscores from adjacent brackets is a nice cleanup.

Issues / Concerns

1. Trailing underscore in generic type names (minor inconsistency)

For non-generic types: IGreeterMock (clean)
For generic types: IRepository_string_Mock (trailing _ before suffix)

This comes from GetShortSafeName not stripping trailing underscores after sanitization. The name IRepository<string> → sanitize brackets → IRepository_string_ → suffix appended → IRepository_string_Mock.

A simple fix would be to trim trailing underscores from the result in GetShortSafeName:

// After the while loop that collapses __
result = result.Trim('_');
return result;

This would yield IRepository_string_MockIRepository_stringMock. Though the PR description seems to accept the current output, so this may be intentional.

2. Code duplication: GetShortSafeName and StripNamespaceFromFqn

Both methods do essentially the same sanitization (strip namespace, replace <>/,.[] with _, collapse __). The only difference is that GetShortSafeName uses model.Namespace to know what to strip, while StripNamespaceFromFqn does it by finding the last . outside angle brackets.

These could be unified into a single helper, reducing the maintenance surface:

private static string GetSanitizedTypeName(string fqn, string? knownNamespace = null)
{
    // shared stripping + sanitization logic
}

Not critical, but the duplication is a maintenance risk if the sanitization rules need to change (e.g., adding support for a new generic syntax).

3. Breaking change — documented but worth flagging

Any user who holds a generated mock in a field/variable with the explicit type name (e.g. IFoo_Mock myMock = ...) will need to update to IFooMock myMock = .... Since these types are in TUnit.Mocks.Generated and the .Mock() extension returns the concrete typed wrapper, most users should be shielded by var inference. Still worth a note in the release/changelog.

Summary

The design is sound and the implementation is clean. The snapshot tests cover the full range of cases (generics, events, static abstracts, multi-interface, partial/wrap). The two code concerns above are minor — the trailing-underscore issue in generic names is the only thing that creates a minor inconsistency in the public type names, but it may be an intentional stylistic choice. Overall this is a good improvement to the mocking DX.

…pers

Deduplicate the sanitization logic that was copy-pasted between
GetShortSafeName and StripNamespaceFromFqn. Also use string.Join
instead of += in a loop for GetCompositeShortSafeName.
@thomhurst thomhurst enabled auto-merge (squash) April 1, 2026 07:41
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.

Review: feat(mocks): shorter, more readable generated mock type names

The goal here is clearly a good one — stripping namespace prefixes from generated type names significantly improves the user-facing DX for typed fields/variables and error messages. The snapshot tests and integration tests passing gives good confidence the mechanical changes are correct. Below are a few architectural observations and concerns worth discussing before merge.


Name collision risk: the core concern

The old strategy encoded the full FQN into the type name (e.g. MyApp_IGreeter_Mock) within a single flat namespace, making collisions structurally impossible. The new strategy drops to short names and relies on sub-namespaces for disambiguation. This trades one guarantee for another and introduces a subtle collision surface.

Scenario 1 — Two types with the same simple name in different namespaces, same test assembly:

namespace Company.Billing { interface IService { ... } }
namespace Company.Shipping { interface IService { ... } }

Both would generate IServiceMock in TUnit.Mocks.Generated.Company.Billing and TUnit.Mocks.Generated.Company.Shipping respectively. That works fine. But the MockStaticExtensionBuilder intentionally keeps using GetCompositeSafeName for the extension class (noted with a comment), placing it in the flat TUnit.Mocks namespace — so the extension classes still avoid collision. That asymmetry is correct but worth documenting more prominently.

Scenario 2 — Type whose namespace IS a prefix of another type's namespace:

If someone has namespace Foo with IBar AND namespace Foo.IBar (an unusual but legal namespace that matches a type name), the sub-namespace strategy could theoretically route both to TUnit.Mocks.Generated.Foo.IBar. This is an extreme edge case, but the old strategy was immune to it by construction.

Scenario 3 — GetCompositeShortSafeName for multi-interface mocks still uses _ separator:

name += "_" + string.Join("_", model.AdditionalInterfaceNames.Select(StripNamespaceFromFqn));

So a multi-interface mock like IFoo + IBar becomes IFoo_IBar (with a leading underscore separator). This is inconsistent with the single-type rename which removes all underscores (e.g. IGreeterMock not IGreeter_Mock). The multi-interface combined names would produce IFoo_IBarMultiMockFactory — mixing styles. Consider IFooIBarMultiMockFactory or introducing a different separator like And (IFooAndIBarMock).


SanitizeIdentifier uses a while loop that could be slow

while (result.Contains("__"))
    result = result.Replace("__", "_");

For most names this runs 0–1 iterations, so it's fine in practice. But for deeply nested generics like IDictionary<string, List<IDictionary<string, object>>> this could iterate several times. A single Regex.Replace(@"_{2,}", "_", result) or a StringBuilder-based approach would handle it in one pass. Minor, but worth noting for correctness at extremes.


Awkward generated namespace in StaticAbstractMemberTests.cs

using TUnit.Mocks.Generated.TUnit.Mocks.Tests;

This is a direct consequence of GetMockNamespace appending model.Namespace verbatim:

$"TUnit.Mocks.Generated.{model.Namespace}"

When the original namespace is TUnit.Mocks.Tests, the generated namespace becomes TUnit.Mocks.Generated.TUnit.Mocks.Tests, which is redundant and looks like a nesting mistake to a reader of the test file. A consumer encountering this for the first time would reasonably assume it's a bug.

Possible mitigations:

  • Strip common TUnit.Mocks.* prefixes (project-specific, fragile).
  • Use only the last segment of the namespace rather than the full namespace (e.g. TUnit.Mocks.Generated.Tests instead of TUnit.Mocks.Generated.TUnit.Mocks.Tests). This reintroduces some collision risk but is much more readable.
  • Keep the full sub-namespace but accept it as a necessary evil and document it.

The current behaviour is a regression in readability specifically for types that live in namespaces beginning with TUnit.Mocks.*, which is exactly where the test utilities live.


GetShortSafeName uses string prefix matching, not proper namespace segmentation

if (!IsGlobalNamespace(model.Namespace) && name.StartsWith(model.Namespace + "."))
    name = name.Substring(model.Namespace.Length + 1);

This is correct for the normal case but would silently produce an unexpected result if model.FullyQualifiedName (after stripping global::) doesn't actually start with model.Namespace + "." (e.g. if the model is constructed in an unusual way). A debug assertion or a guard here would help catch such issues during development.


Positive aspects

  • The public XxxMock wrapper type in a sub-namespace is a real UX win — typed variables in test code become IGreeterMock mock = IGreeter.Mock() which is idiomatic and auto-complete-friendly.
  • The SanitizeIdentifier helper is a clean extraction that consolidates what was previously scattered replace chains.
  • Keeping the extension class name FQN-based (in MockStaticExtensionBuilder) shows good awareness of the global-uniqueness constraint for TUnit.Mocks-namespace types.
  • Snapshot tests cover the changed output correctly and the ___ collapse for generics is a nice polish.

Summary

The direction is right. The two things I'd recommend addressing before merge are:

  1. The TUnit.Mocks.Generated.TUnit.Mocks.Tests namespace — it's the most visible readability regression, visible directly in user-facing test files, and directly contradicts the stated goal of the PR.
  2. The mixed separator style in multi-interface names (IFoo_IBarMock vs IFooMock) — either go underscore-free everywhere or be consistent about when underscores appear.

The collision concern is real but manageable; the existing test coverage mitigates the most common cases.

github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Apr 6, 2026
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.23.7 to
1.28.7.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.28.7

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.7 -->

## What's Changed
### Other Changes
* fix: prevent StringBuilder race in console interceptor during parallel
tests by @​thomhurst in thomhurst/TUnit#5414
### Dependencies
* chore(deps): update tunit to 1.28.5 by @​thomhurst in
thomhurst/TUnit#5415


**Full Changelog**:
thomhurst/TUnit@v1.28.5...v1.28.7

## 1.28.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.5 -->

## What's Changed
### Other Changes
* perf: eliminate redundant builds in CI pipeline by @​thomhurst in
thomhurst/TUnit#5405
* perf: eliminate store.ToArray() allocation on mock behavior execution
hot path by @​thomhurst in thomhurst/TUnit#5409
* fix: omit non-class/struct constraints on explicit interface mock
implementations by @​thomhurst in
thomhurst/TUnit#5413
### Dependencies
* chore(deps): update tunit to 1.28.0 by @​thomhurst in
thomhurst/TUnit#5406


**Full Changelog**:
thomhurst/TUnit@v1.28.0...v1.28.5

## 1.28.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.0 -->

## What's Changed
### Other Changes
* fix: resolve build warnings in solution by @​thomhurst in
thomhurst/TUnit#5386
* Perf: Optimize MockEngine hot paths (~30-42% faster) by @​thomhurst in
thomhurst/TUnit#5391
* Move Playwright install into pipeline module by @​thomhurst in
thomhurst/TUnit#5390
* perf: optimize solution build performance by @​thomhurst in
thomhurst/TUnit#5393
* perf: defer per-class JIT via lazy test registration + parallel
resolution by @​thomhurst in
thomhurst/TUnit#5395
* Perf: Generate typed HandleCall<T1,...> overloads to eliminate
argument boxing by @​thomhurst in
thomhurst/TUnit#5399
* perf: filter generated attributes to TUnit-related types only by
@​thomhurst in thomhurst/TUnit#5402
* fix: generate valid mock class names for generic interfaces with
non-built-in type args by @​thomhurst in
thomhurst/TUnit#5404
### Dependencies
* chore(deps): update tunit to 1.27.0 by @​thomhurst in
thomhurst/TUnit#5392
* chore(deps): update dependency path-to-regexp to v8 by @​thomhurst in
thomhurst/TUnit#5378


**Full Changelog**:
thomhurst/TUnit@v1.27.0...v1.28.0

## 1.27.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.27.0 -->

## What's Changed
### Other Changes
* Fix Dependabot security vulnerabilities in docs site by @​thomhurst in
thomhurst/TUnit#5372
* fix: use 0.0.0-scrubbed sentinel version in snapshot scrubber to avoid
false Dependabot alerts by @​thomhurst in
thomhurst/TUnit#5374
* Speed up Engine.Tests by removing ProcessorCount parallelism cap by
@​thomhurst in thomhurst/TUnit#5379
* ci: add concurrency groups to cancel redundant workflow runs by
@​thomhurst in thomhurst/TUnit#5373
* Add scope-aware initialization and disposal OpenTelemetry spans to
trace timeline and HTML report by @​Copilot in
thomhurst/TUnit#5339
* Add WithInnerExceptions() for fluent AggregateException assertion
chaining by @​thomhurst in thomhurst/TUnit#5380
* Drop net6.0 and net7.0 TFMs, keep net8.0+ and netstandard2.x by
@​thomhurst in thomhurst/TUnit#5387
* Remove all [Obsolete] members and migrate callers by @​thomhurst in
thomhurst/TUnit#5384
* Add AssertionResult.Failed overload that accepts an Exception by
@​thomhurst in thomhurst/TUnit#5388
### Dependencies
* chore(deps): update dependency mockolate to 2.3.0 by @​thomhurst in
thomhurst/TUnit#5370
* chore(deps): update tunit to 1.25.0 by @​thomhurst in
thomhurst/TUnit#5371
* chore(deps): update dependency minimatch to v9.0.9 by @​thomhurst in
thomhurst/TUnit#5375
* chore(deps): update dependency path-to-regexp to v0.2.5 by @​thomhurst
in thomhurst/TUnit#5376
* chore(deps): update dependency minimatch to v10 by @​thomhurst in
thomhurst/TUnit#5377
* chore(deps): update dependency picomatch to v4 by @​thomhurst in
thomhurst/TUnit#5382
* chore(deps): update dependency svgo to v4 by @​thomhurst in
thomhurst/TUnit#5383
* chore(deps): update dependency path-to-regexp to v1 [security] by
@​thomhurst in thomhurst/TUnit#5385


**Full Changelog**:
thomhurst/TUnit@v1.25.0...v1.27.0

## 1.25.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.25.0 -->

## What's Changed
### Other Changes
* Fix missing `default` constraint on explicit interface implementations
with unconstrained generics by @​thomhurst in
thomhurst/TUnit#5363
* feat(mocks): add ReturnsAsync typed factory overload with method
parameters by @​thomhurst in
thomhurst/TUnit#5367
* Fix Arg.IsNull<T> and Arg.IsNotNull<T> to support nullable value types
by @​thomhurst in thomhurst/TUnit#5366
* refactor(mocks): use file-scoped types for generated implementation
details by @​thomhurst in thomhurst/TUnit#5369
* Compress HTML report JSON data and minify CSS by @​thomhurst in
thomhurst/TUnit#5368
### Dependencies
* chore(deps): update tunit to 1.24.31 by @​thomhurst in
thomhurst/TUnit#5356
* chore(deps): update dependency mockolate to 2.2.0 by @​thomhurst in
thomhurst/TUnit#5357
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5365
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5364


**Full Changelog**:
thomhurst/TUnit@v1.24.31...v1.25.0

## 1.24.31

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.31 -->

## What's Changed
### Other Changes
* Fix Aspire 13.2.0+ timeout caused by ProjectRebuilderResource being
awaited by @​Copilot in thomhurst/TUnit#5335
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5349
* Fix nullable IParsable type recognition in source generator and
analyzer by @​Copilot in thomhurst/TUnit#5354
* fix: resolve race condition in HookExecutionOrderTests by @​thomhurst
in thomhurst/TUnit#5355
* Fix MaxExternalSpansPerTest cap bypass when Activity.Parent chain is
broken by @​Copilot in thomhurst/TUnit#5352
### Dependencies
* chore(deps): update tunit to 1.24.18 by @​thomhurst in
thomhurst/TUnit#5340
* chore(deps): update dependency stackexchange.redis to 2.12.14 by
@​thomhurst in thomhurst/TUnit#5343
* chore(deps): update verify to 31.15.0 by @​thomhurst in
thomhurst/TUnit#5346
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5348


**Full Changelog**:
thomhurst/TUnit@v1.24.18...v1.24.31

## 1.24.18

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.18 -->

## What's Changed
### Other Changes
* feat(mocks): shorter, more readable generated mock type names by
@​thomhurst in thomhurst/TUnit#5334
* Fix DisposeAsync() ordering for nested property injection by @​Copilot
in thomhurst/TUnit#5337
### Dependencies
* chore(deps): update tunit to 1.24.13 by @​thomhurst in
thomhurst/TUnit#5331


**Full Changelog**:
thomhurst/TUnit@v1.24.13...v1.24.18

## 1.24.13

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.13 -->

## What's Changed
### Other Changes
* perf(mocks): optimize MockEngine for lower allocation and faster
verification by @​thomhurst in
thomhurst/TUnit#5319
* Remove defunct `UseTestingPlatformProtocol` reference for vscode by
@​erwinkramer in thomhurst/TUnit#5328
* perf(aspnetcore): prevent thread pool starvation during parallel
WebApplicationTest server init by @​thomhurst in
thomhurst/TUnit#5329
* fix TUnit0073 for when type from from another assembly by @​SimonCropp
in thomhurst/TUnit#5322
* Fix implicit conversion operators bypassed in property injection casts
by @​Copilot in thomhurst/TUnit#5317
* fix(mocks): skip non-virtual 'new' methods when discovering mockable
members by @​thomhurst in thomhurst/TUnit#5330
* feat(mocks): IFoo.Mock() discovery with generic fallback and ORP
resolution by @​thomhurst in
thomhurst/TUnit#5327
### Dependencies
* chore(deps): update tunit to 1.24.0 by @​thomhurst in
thomhurst/TUnit#5315
* chore(deps): update aspire to 13.2.1 by @​thomhurst in
thomhurst/TUnit#5323
* chore(deps): update verify to 31.14.0 by @​thomhurst in
thomhurst/TUnit#5325

## New Contributors
* @​erwinkramer made their first contribution in
thomhurst/TUnit#5328

**Full Changelog**:
thomhurst/TUnit@v1.24.0...v1.24.13

## 1.24.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.0 -->

## What's Changed
### Other Changes
* perf: optimize TUnit.Mocks hot paths by @​thomhurst in
thomhurst/TUnit#5304
* fix: resolve System.Memory version conflict on .NET Framework (net462)
by @​thomhurst in thomhurst/TUnit#5303
* fix: resolve CS0460/CS0122/CS0115 when mocking concrete classes from
external assemblies by @​thomhurst in
thomhurst/TUnit#5310
* feat(mocks): parameterless Returns() and ReturnsAsync() for async
methods by @​thomhurst in thomhurst/TUnit#5309
* Fix typo in NUnit manual migration guide by @​aa-ko in
thomhurst/TUnit#5312
* refactor(mocks): unify Mock.Of<T>() and Mock.OfPartial<T>() into
single API by @​thomhurst in
thomhurst/TUnit#5311
* refactor(mocks): clean up Mock API surface by @​thomhurst in
thomhurst/TUnit#5314
* refactor(mocks): remove generic/untyped overloads from public API by
@​thomhurst in thomhurst/TUnit#5313
### Dependencies
* chore(deps): update tunit to 1.23.7 by @​thomhurst in
thomhurst/TUnit#5305
* chore(deps): update dependency mockolate to 2.1.1 by @​thomhurst in
thomhurst/TUnit#5307

## New Contributors
* @​aa-ko made their first contribution in
thomhurst/TUnit#5312

**Full Changelog**:
thomhurst/TUnit@v1.23.7...v1.24.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.23.7...v1.28.7).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.23.7&new-version=1.28.7)](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>
This was referenced Apr 6, 2026
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]: Implicit operator on Mock<T> does not work when T is an interface

1 participant