Support static abstract interface members in mock generation#5070
Support static abstract interface members in mock generation#5070
Conversation
Generate explicit interface implementation stubs for static abstract members (throwing NotSupportedException) so mock classes compile when the mocked interface has static abstract members (e.g. AWS SDK interfaces). Also skip transitive mock factory generation for interfaces that contain static abstract members, avoiding CS8920 when they're used as type args.
|
Code Review: Support static abstract interface members in mock generation Good fix for a real-world compilation blocker. The two-pronged approach is correct and pragmatic. What is well done:
Issue 1: Hardcoded indentation in GenerateStaticAbstractStub In MockImplBuilder.cs around line 352, the method expression body uses a hardcoded 4-space indent: The rest of the builder uses writer.Block(...) to manage indentation level. If CodeWriter's indentation model ever changes (tabs vs. spaces), this prefix will drift. Consider using a block body or a single expression-body line without additional indentation to stay consistent with the rest of the builder. Issue 2: Missing snapshot test for the direct mock case The added snapshot test covers the transitive case: IMyService (mockable) returns IConfigProvider (has static abstract) and the transitive mock is skipped. There is no test for the direct case: Mock.Of() where IConfigProvider has both instance members AND static abstract members. The generated impl should include regular method stubs plus the NotSupportedException stub. This is the actual code path through GenerateStaticAbstractStub -- the current test only exercises the skip-transitive path, not the stub-generation path itself. A direct test would provide a snapshot of the full output including the static stub, making regressions immediately visible. Issue 3: Unused using statements in MockStaticAbstractMemberModel.cs System.Collections.Immutable and System.Linq are imported but not referenced directly in this file. Minor cleanup opportunity. Minor / future work: No compile-time diagnostic for unmockable members When a user mocks an interface with static abstract members, the mock silently generates a stub that throws NotSupportedException at runtime. There is no compile-time signal that those members cannot be set up or verified. A separate analyzer diagnostic would make the limitation discoverable. Not blocking this PR, but worth tracking as a follow-up issue. Summary The core fix is correct and production-ready. Main actionable items:
The decision to skip transitive generation rather than attempting to generate and fail is the right architectural call for CS8920. Overall a solid, well-scoped fix. |
- Fix correctness bug: add ref-kind to static abstract member dedup key (consistent with GetMethodKey for regular members) - Extract FormatParameterList, FormatTypeParameterList, FormatConstraintClauses helpers to eliminate duplication with GenerateStaticAbstractStub - Extract GenerateStaticAbstractStubs to deduplicate triplicated loop - Remove unnecessary ToDisplayString call for unused Type field - Add TypeParameters to MockStaticAbstractMemberModel.GetHashCode
|
anything I can do to get this merged? This is currently blocking tests for me |
- PackTUnitFilesModule: rebuild solution with pack version before packing to prevent CS1705 assembly version mismatches on PR branches where MinVer computes a different version than GenerateVersionModule - TestNugetPackageModule: add [NotInParallel] to prevent concurrent builds of shared TUnit.NugetTester.Library causing CS2012 file lock errors on Windows
- Use writer.OpenBrace/CloseBrace instead of hardcoded spaces in GenerateStaticAbstractStub (MockImplBuilder.cs) - Remove unused System.Collections.Immutable and System.Linq imports from MockStaticAbstractMemberModel.cs
Follow-up Code ReviewThanks for the additional commits addressing the previous review. Here's a status on the outstanding items and one new finding. Previous Review: Status Check✅ Fixed: Hardcoded indentation in ✅ Fixed: Unused ❌ Still missing: Snapshot test for direct mock of static-abstract interface var mock = Mock.Of<IConfigProvider>(); // IConfigProvider has static abstract membersThe stub-generation path in New Finding:
|
| Item | Status |
|---|---|
| Hardcoded indentation | ✅ Fixed |
| Unused imports | ✅ Fixed |
| Direct-mock snapshot test | ❌ Still missing |
GetHashCode incomplete |
🆕 New issue |
The core fix is solid and the transitive-skip approach remains the right architectural call. Resolving the two remaining items above — particularly the GetHashCode bug which could cause silent correctness issues — would make this production-ready.
…interface Static abstract members now support setup (Returns/Throws/Callback), verification (WasCalled), and property getter/setter configuration using the same fluent API as instance members. Key changes: - Bridge interface (_Mockable) with DIMs resolves CS8920 for Mock<T> usage - AsyncLocal<IMockEngineAccess> bridges static context to per-test engines - [GenerateMock(typeof(T))] attribute for discovery (typeof avoids CS8920) - Static abstracts promoted to full MockMemberModel (IsStaticAbstract flag) - Removed separate MockStaticAbstractMemberModel in favor of unified model
There was a problem hiding this comment.
Code Review: Support Static Abstract Interface Members in Mock Generation
This PR tackles a genuinely hard problem — CS8920 prevents interfaces with unresolved static abstract members from being used as generic type arguments, which breaks the core Mock<T> / MockEngine<T> architecture. The bridge interface + DIM + AsyncLocal approach is clever and shows deep understanding of the constraints involved. Good comprehensive test coverage with snapshot tests and integration tests.
Architecture Overview
The solution introduces three interacting pieces:
_Mockablebridge interface with DIM implementations routing calls through the static engine_StaticEnginestatic class usingAsyncLocal<IMockEngineAccess?>to hold the per-test engine[assembly: GenerateMock(typeof(T))]attribute as the discovery mechanism (avoiding CS8920 viatypeof)
This is architecturally sound for the problem space. The AsyncLocal choice is correct for test isolation in TUnit's async-per-test model.
Concern: Multiple Mock Instances of Same Type in One Test
The most significant correctness issue: _StaticEngine.Engine is set in the constructor on every mock creation:
// MockImplBuilder.cs — constructor body
if (model.HasStaticAbstractMembers)
writer.AppendLine($"{safeName}_StaticEngine.Engine = engine;");If a test creates two mocks of the same bridge type, the second mock's engine silently overwrites the first. Static member calls on the first mock will route to the second mock's engine:
var mock1 = Mock.Of<TUnit_Mocks_Tests_IAmazonService_Mockable>();
mock1.CreateDefaultConfig().Returns(config1);
var mock2 = Mock.Of<TUnit_Mocks_Tests_IAmazonService_Mockable>();
// Now _StaticEngine.Engine points to mock2's engine
// CallStaticAbstract<...>() will consult mock2, not mock1This is a fundamental constraint of the static-dispatch model, but it should be documented prominently. A diagnostic or warning when a second mock of a static-abstract interface is created in the same context would prevent silent test breakage.
UX Friction: Verbose Generated Type Names
The bridge type name (TUnit_Mocks_Tests_IAmazonService_Mockable) is derived by replacing dots in the FQN with underscores. This creates real usability pain:
- Users must know the mangled name before the generator has run
- IDEs won't autocomplete it initially
- The documentation example in
GenerateMockAttributeshows this verbose name
A using alias or a helper method (e.g., Mock.Of(typeof(IAmazonService)) returning a non-generic Mock) could improve ergonomics. Even just a // ReSharper disable once or IDE annotation to help discoverability would help. The [EditorBrowsable(Never)] on the bridge interface also means it won't appear in completion lists, which is correct but adds to the friction.
_StaticEngine Not Reset After Test
After a test completes, _StaticEngine.Engine retains the engine reference for the lifetime of the async execution context. Since TUnit gives each test its own async context, this should be fine — the value flows with the context and is GC'd appropriately. Worth explicitly documenting that this relies on TUnit's per-test async context isolation.
Minor: HasStaticAbstractMembers Computed Each Time
// MockTypeModel.cs
public bool HasStaticAbstractMembers =>
Methods.Any(m => m.IsStaticAbstract) || Properties.Any(p => p.IsStaticAbstract);This is called multiple times during code generation (at least 3 times per model in the constructor paths alone). Since EquatableArray is essentially immutable, this could be cached. Not a hot-path concern for a source generator, but noting it for completeness.
CI Fix Commits
The two CI fix commits (PackTUnitFilesModule rebuild-before-pack, TestNugetPackageModule [NotInParallel]) are clearly explained and address real CI race conditions. They're unrelated to the feature but are reasonable inclusions given the PR context.
What's Done Well
HasStaticAbstractMemberstransitive-type skip inMockTypeDiscoverycorrectly prevents CS8920 when auto-mocking interfaces that return static-abstract-bearing types- Fallback to
CandidateSymbolsforOverloadResolutionFailureinTransformToModelsis the right Roslyn pattern for this scenario IsStaticAbstractflag onMockMemberModelcleanly integrates static abstract members into the existing model rather than a parallel hierarchy (the earlierMockStaticAbstractMemberModelapproach was correctly abandoned)- Snapshot tests cover the three key cases: direct static abstract members, inherited static abstract members, and transitive return types
- The
#if NET7_0_OR_GREATERguard in the integration test is correct since static abstract interface members are a C# 11 / .NET 7 feature
Summary
The core design is solid. The primary actionable items are:
- Document (or ideally detect) the multiple-mock-same-type footgun
- Consider a more ergonomic discovery path for the generated bridge type (even just a better naming convention or a generated
const string MockableTypeNamesomewhere)
The CS8920 constraint means there's no perfect solution here — the bridge pattern is probably the best available approach given the language limitations.
- Delete 3 unreachable private methods in MockImplBuilder (~140 lines) - Delete unused IsGenerateMockAttribute syntax predicate - MockMembersBuilder: reuse MockImplBuilder's type parameter helpers - MockTypeModel.HasStaticAbstractMembers: stored init property instead of LINQ scan
Follow-up Code ReviewSolid rework. The shift from NotSupportedException stubs + factory-skip to the bridge interface with DIM implementations is a significantly better architecture — static abstract members are now actually mockable and verifiable, not just silently failing at runtime. Previous review items are addressed; two items remain. Previous review: status check
Issue 1 (Critical):
|
| Item | Severity | Status |
|---|---|---|
HasStaticAbstractMembers missing from Equals/GetHashCode |
🔴 Critical | New issue |
No diagnostic for Mock.Of<T>() with static-abstract T |
🟡 UX / follow-up | New finding |
| AsyncLocal lifetime undocumented | ⚪ Minor | Suggestion |
The bridge interface approach is the right long-term design. Fixing the MockTypeModel equality bug is the only blocker before this is production-ready.
…ality - Add runtime guard in generated constructors that throws InvalidOperationException if a second mock of the same static-abstract interface type is created in the same test context (AsyncLocal footgun) - Extract EmitStaticEngineAssignment helper to centralize the check - Add HasStaticAbstractMembers to MockTypeModel.Equals and GetHashCode - Update snapshot files for the new constructor guard
There was a problem hiding this comment.
Code Review: Support Static Abstract Interface Members in Mock Generation (Follow-up)
Previous review concerns have been well addressed in the latest commits:
- ✅ Multiple-mock detection: constructor now throws
InvalidOperationExceptionwith a clear message - ✅
HasStaticAbstractMembersis now a stored property onMockTypeModel, not recomputed on every access - ✅ Hardcoded indentation replaced with
writer.OpenBrace/CloseBrace - ✅ Unused imports removed; dead code deleted
The overall architecture (bridge interface + DIMs + AsyncLocal dispatch) remains solid and is the right approach given the CS8920 constraint.
Issue: IMockEngineAccess is a Breaking Change
Two new methods were added to a public interface:
// IMockEngineAccess.cs
void HandleCall(int memberId, string memberName, object?[] args);
TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue);Any code outside TUnit implementing IMockEngineAccess (e.g., for testing, custom engines, or stubs) will now fail to compile. These methods appear to have already existed on MockEngine<T> as internal/concrete members — consider whether they need to be on the interface at all, or whether the bridge DIM code can be refactored to call through MockEngine<T> directly (since the static engine _engine field already has that concrete type at generation time).
If they must be on the interface, this is a breaking change that warrants a note in the changelog / semver bump.
Subtle Gap: HasStaticAbstractMembers Misses Static Abstract Events
The model-level flag is populated from:
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract)However, the transitive-skip guard in MockTypeDiscovery.HasStaticAbstractMembers(INamedTypeSymbol) correctly checks all members:
if (member.IsStatic && member.IsAbstract) // catches events too
return true;If an interface has only static abstract events (rare but valid C# 11), the MockTypeModel.HasStaticAbstractMembers flag would be false, no bridge would be generated, and CS8920 would reappear. Aligning both checks would close this gap.
Minor: AsyncLocal Guard and [Before(Test)] Interaction
The guard is correct and the reasoning is sound — AsyncLocal provides per-test isolation via TUnit's execution context model. One edge case worth documenting: if a user creates the mock in a [Before(Test)] hook and also attempts to create one in the test body itself, the guard will correctly throw. This is expected behavior but could surprise users who don't realize the engine flows into the test context. A brief note in the GenerateMockAttribute XML doc or README would help.
Acknowledged But Not Changed: Verbose Bridge Type Name
The doc example still shows:
var mock = Mock.Of<TUnit_Mocks_Tests_IAmazonService_Mockable>();This was flagged previously and is acknowledged as a language-imposed constraint. A possible mitigation would be generating a file-scoped or internal type alias in the same namespace as the original interface (e.g., IAmazonService_Mockable without the namespace prefix), but this may conflict with multiple assemblies. Not blocking for this PR — worth a follow-up issue.
What's Done Well
The approach of integrating static abstract members into the existing MockMemberModel via the IsStaticAbstract flag (rather than a parallel model hierarchy) is clean and avoids duplication. The CandidateSymbols fallback for OverloadResolutionFailure is the correct Roslyn pattern. The three snapshot tests covering direct, inherited, and transitive cases give good confidence in the output. The [NotInParallel] CI fix and the pack-version-rebuild fix are sensible and well-explained.
Overall this is a significant improvement over the initial submission and addresses the hardest parts of the problem correctly. The IMockEngineAccess interface change is the most actionable item before merging.
There was a problem hiding this comment.
Code Review: Support Static Abstract Interface Members in Mock Generation (Follow-up)
Previous review concerns have been well addressed in the latest commits:
- ✅ Multiple-mock detection: constructor now throws
InvalidOperationExceptionwith a clear message - ✅
HasStaticAbstractMembersis now a stored property onMockTypeModel, not recomputed on every access - ✅ Hardcoded indentation replaced with
writer.OpenBrace/CloseBrace - ✅ Unused imports removed; dead code deleted
The overall architecture (bridge interface + DIMs + AsyncLocal dispatch) remains solid and is the right approach given the CS8920 constraint.
Issue: IMockEngineAccess Interface Expansion (Potential Breaking Change)
Two new methods were added to a public interface:
// IMockEngineAccess.cs
void HandleCall(int memberId, string memberName, object?[] args);
TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue);Any code implementing IMockEngineAccess outside TUnit (e.g., test doubles, custom engines) will now fail to compile. These methods already exist as concrete members on MockEngine<T> — it is worth considering whether the bridge DIM dispatch code could call through MockEngine<T> directly instead of widening the interface. If the interface expansion is intentional, this is a semver-significant change.
Subtle Gap: HasStaticAbstractMembers Does Not Cover Static Abstract Events
The model-level flag is populated as:
HasStaticAbstractMembers = methods.Any(m => m.IsStaticAbstract) || properties.Any(p => p.IsStaticAbstract)But the transitive-skip guard in MockTypeDiscovery correctly covers all members:
if (member.IsStatic && member.IsAbstract) // catches events too
return true;If an interface declares only static abstract events (valid C# 11), the model flag would remain false, no bridge would be generated, and CS8920 would reappear. Aligning these two checks would close the gap.
Minor: AsyncLocal Guard and [Before(Test)] Interaction
The guard logic is correct — AsyncLocal provides per-test-context isolation. One pattern worth documenting: a mock created in a [Before(Test)] hook flows into the test body via AsyncLocal, so creating another mock of the same bridge type in the test body will correctly throw. This is expected and safe, but could surprise users. A sentence in the GenerateMockAttribute XML doc about "create once per test context" would prevent confusion.
Acknowledged: Verbose Bridge Type Name
The doc example still shows:
var mock = Mock.Of<TUnit_Mocks_Tests_IAmazonService_Mockable>();This is a language-imposed constraint and was flagged in the prior review. Not blocking, but a follow-up issue to explore shorter naming conventions (e.g., dropping namespace segments, or a generated type alias) would improve DX.
What's Done Well
- Integrating static abstract members into
MockMemberModelviaIsStaticAbstractrather than a parallel model class is the right design — clean and non-duplicative. - The
CandidateSymbolsfallback forOverloadResolutionFailureis the correct Roslyn pattern for this scenario. - Three snapshot tests covering direct, inherited, and transitive cases give solid confidence in generator output.
- The
[NotInParallel]CI fix and pack-version-rebuild fix are well-explained and address real race conditions.
Overall this is in good shape. The IMockEngineAccess interface change is the primary item to decide on before merging.
HasStaticAbstractMembers now covers events (not just methods/properties), closing a gap where an interface with only static abstract events would not trigger bridge generation, causing CS8920. - Add IsStaticAbstract to MockEventModel - Collect static abstract events in MemberDiscovery - Include events in HasStaticAbstractMembers check - Generate no-op DIM stubs for static abstract events in bridge - Filter static abstract events from impl, raise dispatch, and extensions
There was a problem hiding this comment.
Code Review: Support Static Abstract Interface Members in Mock Generation (Follow-up #5)
Previous Review: Status Check
The two most recent commits are merge conflict resolutions for MockGeneratorTests.cs (keeping both the keyword-parameter-names test from main and the static abstract tests from this branch). No functional changes to the mock generation code. All prior review items from Follow-up #4 carry over unchanged:
✅ Fully resolved from earlier reviews:
- Multiple-mock guard (throws
InvalidOperationExceptionviaEmitStaticEngineAssignment) HasStaticAbstractMemberscovers events, methods, and propertiesHasStaticAbstractMembersis a storedinitproperty, not a LINQ scan- Dead code removed, indentation fixed, unused imports removed
MockTypeModel.Equals/GetHashCodeincludesHasStaticAbstractMembers
Still Unresolved: IMockEngineAccess Public Interface Expansion
This item has been flagged in every prior review and remains the one blocker before merging:
// IMockEngineAccess.cs — two new public API members
void HandleCall(int memberId, string memberName, object?[] args);
TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue);IMockEngineAccess is public. [EditorBrowsable(Never)] affects IDE autocomplete only — it provides zero protection against compile-time breakage. Any downstream code implementing this interface will fail to compile on upgrade.
Recommended fix: Since the static engine field is always a MockEngine<T> at generation time, the generated DIM code could cast directly rather than going through the interface:
// In GenerateStaticEngineDispatchBody — instead of:
__engine.HandleCall(...)
// Use a cast that avoids the interface:
((global::TUnit.Mocks.MockEngine<{mockableType}>)__engine).HandleCall(...)Or, keep the methods on the interface but add default interface implementations that throw NotSupportedException, giving existing implementors a compile-time-safe migration path without removing the new functionality.
Still Minor: Two Documentation Gaps
These are non-blocking but would prevent user confusion:
-
AsyncLocal+[Before(Class)]footgun —GenerateMockAttribute's XML doc should note that mocks must be created per-test, not per-class. The existing guard (throwsInvalidOperationExceptionwhen engine is already set) means a class-scoped mock will make every test in the class throw on its own mock creation, which is surprising behavior with no obvious cause. -
Static abstract events are no-op —
GenerateStaticEventDimemits emptyadd { } remove { }with no engine dispatch, no setup support, and no verification. A comment in the builder and/or a note in the attribute doc would prevent future bug reports from users who expect the same configurability as instance events.
What's Done Well
The overall solution is solid and well-implemented. The bridge interface + DIM + AsyncLocal approach correctly handles the CS8920 constraint, inherited static abstracts are resolved via CandidateSymbols fallback, the IsStaticAbstract flag unifies the model hierarchy cleanly, and the three snapshot tests plus StaticAbstractMemberTests.cs provide good coverage. The iterative refinement across commits is exemplary.
The IMockEngineAccess expansion is the last substantive decision before this can merge.
|
@dealloc New version should be out in the next hour |
|
@thomhurst appreciate the fast resolution! |
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.18.21 to 1.18.37. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.18.37 <!-- Release notes generated using configuration in .github/release.yml at v1.18.37 --> ## What's Changed ### Other Changes * Improve HTML report perf by @slang25 in thomhurst/TUnit#5077 * perf: remove `Action` allocation for `token.Register` by @TimothyMakkison in thomhurst/TUnit#5075 * fix(mocks): escape C# keyword parameter names in generated mock code by @thomhurst in thomhurst/TUnit#5091 * Support static abstract interface members in mock generation by @thomhurst in thomhurst/TUnit#5070 ### Dependencies * chore(deps): update tunit to 1.18.21 by @thomhurst in thomhurst/TUnit#5076 * chore(deps): update dependency polyfill to 9.16.0 by @thomhurst in thomhurst/TUnit#5080 * chore(deps): update dependency polyfill to 9.16.0 by @thomhurst in thomhurst/TUnit#5079 * chore(deps): update dependency polyfill to 9.17.0 by @thomhurst in thomhurst/TUnit#5082 * chore(deps): update dependency polyfill to 9.17.0 by @thomhurst in thomhurst/TUnit#5081 * chore(deps): update dependency polly to 8.6.6 by @thomhurst in thomhurst/TUnit#5084 * chore(deps): update dependency humanizer to 3.0.8 by @thomhurst in thomhurst/TUnit#5087 * chore(deps): update dependency polyfill to 9.18.0 by @thomhurst in thomhurst/TUnit#5090 * chore(deps): update dependency polyfill to 9.18.0 by @thomhurst in thomhurst/TUnit#5089 **Full Changelog**: thomhurst/TUnit@v1.18.21...v1.18.37 Commits viewable in [compare view](thomhurst/TUnit@v1.18.21...v1.18.37). </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
Fixes #5069
NotSupportedException) forstatic abstractmembers so generated mock classes compile when the mocked interface has static abstract members (e.g. AWS SDK'sIAmazonService)Mock<T>/MockEngine<T>MockStaticAbstractMemberModelto capture static abstract method/property signatures for code generationTest plan
Interface_With_Static_Abstract_Transitive_Return_Typesimulating the AWS SDK scenario