feat(mocks): unified Mock<T> API — remove Setup/Verify/Raise surfaces#4991
feat(mocks): unified Mock<T> API — remove Setup/Verify/Raise surfaces#4991
Conversation
…ed types These runtime types serve as unified return types that support both setup and verification on a single object. They use lazy setup registration -- the MethodSetup is only created and registered in the engine when a setup method (Returns, Callback, etc.) is called, not when the mock method itself is called.
…ll<TProperty> unified types Merges PropertySetupAccessor<TProperty> and PropertyVerifyAccessor<TProperty> into a single PropertyMockCall<TProperty> that exposes both setup and verify surfaces. Adds PropertySetterMockCall<TProperty> for setter-specific setup and verification with argument matchers. Old types remain for now and will be deleted in a later task.
Remove the IMockSetup<T>, IMockVerify<T>, IMockRaise<T> marker interfaces and the Setup/Verify/Raise properties from Mock<T>. Replace the two constructors with a single (T, MockEngine<T>) constructor. Update InState to accept Action<Mock<T>> instead of Action<IMockSetup<T>>. Delete PropertySetupAccessor and PropertyVerifyAccessor, which are replaced by the new PropertyMockCall unified type. Source-generated extension methods will target Mock<T> directly instead of going through marker interface indirection.
…d MockMembersBuilder The new MockMembersBuilder generates a single _MockMembers.g.cs file per mocked type, replacing the separate _MockSetup.g.cs and _MockVerify.g.cs files. Key changes: - Extension methods target Mock<T> directly instead of IMockSetup<T> - No more holder classes (_MockSetup, _MockVerify) — engine accessed via mock.Engine - Typed wrappers are now sealed classes (not structs) with lazy setup registration - Each wrapper implements ICallVerification for unified setup + verify - Non-qualifying methods return MockMethodCall<T> or VoidMockMethodCall base types - Member name conflict detection appends '_' for Mock<T> built-in names - Property extensions use PropertyMockCall<T> targeting Mock<T>
…k<T> constructor - MockRaiseBuilder: Remove _MockRaise holder class and IMockRaise<T> marker interface. Extension methods now target Mock<T> directly with Raise prefix (e.g., RaiseOnMessage). Event raising goes through IRaisable interface. - MockFactoryBuilder: Remove creation of setup/verify/raise intermediary objects from all three factory methods. Use simplified Mock<T>(impl, engine) constructor. - MockDelegateFactoryBuilder: Same simplification - remove setup/verify creation, use Mock<T>(del, engine) constructor.
Remove .Setup., .Verify., and .Raise. accessor patterns from all 43 test files in TUnit.Mocks.Tests/ to match the new unified API where mock members are accessed directly on Mock<T>. Also update InState lambda parameters from 'setup' to 'm'.
Void methods and property setters are commonly used without chaining (e.g., mock.Log(Arg.Any<string>()) to "allow" the call in strict mode). Eagerly call EnsureSetup() in constructors so the setup is registered even when no chain method is called. Also fix PropertySetterMockCall to properly delegate to VoidMethodSetupBuilder and update all snapshot tests to reflect the new generated output.
There was a problem hiding this comment.
Code Review: Unified Mock API — Remove Setup/Verify/Raise Surfaces
This is a well-motivated refactor. The before/after in the description shows a real ergonomic improvement, and the architecture is cleaner overall. A few issues worth discussing:
Issue 1 (Design): Thread-Safety Race in EnsureSetup() for Non-Void Methods
MockMethodCall.cs and all generated *_MockCall classes for non-void methods:
private MethodSetupBuilder<TReturn> EnsureSetup()
{
if (_builder is null) // Thread A and Thread B can both see null here
{
var setup = new MethodSetup(_memberId, _matchers, _memberName);
_engine.AddSetup(setup); // Called twice — double-registration
_builder = new MethodSetupBuilder<TReturn>(setup);
}
return _builder;
}This check-then-act pattern is a data race. Under concurrent setup calls, two threads can both pass the null check, both call _engine.AddSetup(), and both assign _builder. The result is a double-registered MethodSetup for the same mock member, with one orphaned in the engine.
A safer approach would be Lazy<T> initialization or Interlocked.CompareExchange. Since TUnit has ThreadSafetyTests.cs, this may already be tested — but in that case, the test should be validating the engine-side behavior as well.
Why this matters: Even if tests are sequential, the
VoidMockMethodCallis the standard pattern for "allow in strict mode" where the same mock object might be shared across parallel test fixtures via[ClassDataSource].
Issue 2 (Design): Setup Registration Asymmetry Between Void and Non-Void
VoidMockMethodCall calls EnsureSetup() eagerly in the constructor, while MockMethodCall<TReturn> defers. The comment explains the intent, but it creates a subtle inconsistency:
// This DOES register a setup (void — eager):
mock.Log(Arg.Any<string>());
// This does NOT register a setup (non-void — lazy):
mock.GetValue(Arg.Any<string>());A user who writes mock.GetValue(Arg.Any<string>()) without chaining .Returns() gets a no-op MockMethodCall<string> that's immediately garbage collected without side effects. That's actually the correct behavior in loose mode — but in strict mode it could produce a confusing "unexpected call" failure that doesn't match the mental model of "I called mock.GetValue to allow it."
Consider at minimum adding an XML doc note on MockMethodCall<TReturn> that explains this distinction. Or, if the intent is specifically to match void-style usage in strict mode, providing a .Allow() method that eagerly registers a permissive setup (similar to how VoidMockMethodCall works) would be more explicit than relying on the void/non-void asymmetry.
Issue 3 (Design): WasCalled() Creates a New Verification Object on Every Invocation
public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled();
public void WasCalled(Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times);
// ... repeated for every overloadEach WasCalled() call instantiates a fresh CallVerificationBuilder that's used once and discarded. This is a one-shot pattern and is functionally fine, but it means the matchers array is passed through the allocation on each call. Since the matchers are already stored on the MockMethodCall, consider caching the verification builder on first creation (similar to EnsureSetup()) rather than recreating it each time. This would also reduce object graph depth during verification failures.
Issue 4 (Maintenance): MockMemberNames Hardcoded List in MockMembersBuilder
private static readonly HashSet<string> MockMemberNames = new(System.StringComparer.Ordinal)
{
"Object", "Engine", "Behavior", "Invocations", "DefaultValueProvider",
"SetupAllProperties", "Reset", "VerifyAll", "VerifyNoOtherCalls",
"GetAutoMock", "GetDiagnostics", "SetState", "InState",
"GetHashCode", "GetType", "ToString", "Equals"
};This is fragile — it must be manually kept in sync with Mock<T>'s public API. If a new method is added to Mock<T>, it won't be added here, silently generating a collision. A better approach would be to derive this list from the Mock<T> type at generator initialization time using symbol analysis (the generator already has access to the compilation, so typeof(Mock<T>) members can be enumerated at source-gen time). This would make it impossible for the list to go stale.
Issue 5 (Minor): PropertyMockCall<T>.Getter Creates a New Setup on Every Access
public IPropertySetup<TProperty> Getter
{
get
{
var methodSetup = new MethodSetup(_getterMemberId, matchers, $"{_propertyName} (get)");
_engine.AddSetup(methodSetup); // New registration each access
return new PropertySetupBuilder<TProperty>(methodSetup);
}
}This was the same behavior in PropertySetupAccessor<T>, so it's not a regression. But since PropertyMockCall<T> now also owns verification, the multiple-access concern is more visible. Worth a comment explaining the deliberate re-registration behavior (or raising it as a separate follow-up).
What's Good
- The API simplification is genuinely better.
mock.Greet(Arg.Any<string>()).Returns("Hello")is more natural thanmock.Setup.Greet(Arg.Any<string>()).Returns("Hello"). - Removing the
IMockSetup<T>/IMockVerify<T>marker interfaces removes an entire layer of indirection including the unsafe downcasts ((ISomethingMockSetup)setup) that existed in every generated extension method. MockOfT.csis notably simpler — removingSetup,Verify, andRaiseproperties reduces the public surface ofMock<T>.MockMembersBuilder.csreplacing two separate builders reduces generated file count (one_MockMembers.g.csinstead of_MockSetup.g.cs+_MockVerify.g.cs).- The
GetSafeMemberNameescape hatch for name collisions is a necessary correctness fix. - Snapshot tests are updated and committed — good hygiene per project rules.
Remove all references to the old .Setup., .Verify., and .Raise. intermediate properties. Update all code examples to use the new unified API where methods are called directly on Mock<T> and the chain method disambiguates between setup and verification. Also update XML doc comments in PropertyMockCall and MockAssertionExtensions to reference current type names.
There was a problem hiding this comment.
Code Review: feat(mocks): unified Mock API — remove Setup/Verify/Raise surfaces
Summary
This is a solid API simplification. Removing the .Setup., .Verify., and .Raise. intermediate properties reduces ceremony significantly and the fluent chaining approach is much more ergonomic. The overall design direction is correct. That said, there are several issues worth addressing.
Issues Found
1. PropertyMockCall<T>.Getter registers a new setup on every access — potential bug
In PropertyMockCall.cs, the Getter property creates and registers a brand-new MethodSetup on every single access:
public IPropertySetup<TProperty> Getter
{
get
{
var methodSetup = new MethodSetup(_getterMemberId, matchers, $"{_propertyName} (get)");
_engine.AddSetup(methodSetup); // New setup registered every time!
return new PropertySetupBuilder<TProperty>(methodSetup);
}
}This means:
mock.Name.Returns("Alice"); // Accesses Getter internally → registers setup #1
mock.Name.Returns("Bob"); // Accesses Getter again → registers setup #2 (!!)It also means VerifyAll() tracks two separate setups and both must be invoked. The lazy EnsureSetup() pattern used in MockMethodCall<T> and VoidMockMethodCall is the right approach here too. The PropertyMockCall<T> should lazily cache its getter setup builder rather than registering fresh on each access.
2. Void verification has a hidden setup side effect — breaks VerifyAll() semantics
VoidMockMethodCall eagerly registers a setup in its constructor. This means calling a method for the purpose of verification also registers a setup:
// Old (verify only, no setup registered):
mock.Verify.Log(Arg.Any<string>()).WasCalled();
// New (verification + inadvertent setup registration):
mock.Log(Arg.Any<string>()).WasCalled();After migrating to the new API, code that previously called VerifyAll() after doing verification-only calls will now see those verification calls counted as uninvoked setups and fail. The PR description mentions the void eager-registration is for strict mode compatibility (mock.Log(Arg.Any<string>()) as an "allow" statement), but the side effect on VerifyAll() is a behavioral regression. Consider either:
- Not eagerly registering until a setup method (
.Callback(),.Throws()) is explicitly called - Tracking setups vs. verifications separately in the engine
3. Allocation regression: readonly struct → sealed class
The previous *_TypedSetup types were readonly struct, meaning zero heap allocations for the setup-chain wrapper. All the new unified types (MockMethodCall<T>, VoidMockMethodCall, and every generated *_MockCall) are sealed class — one heap allocation per mock method invocation.
In tight loops or assertion-heavy tests, this creates measurable GC pressure. The CLAUDE.md "Performance First" principle flags this. The original structs were allocation-free for the common case; the new design trades that away for API simplicity. This may be acceptable, but it should be a deliberate decision rather than an implicit one.
If allocation matters here, consider a different split: keep the generated per-method wrapper types as readonly struct for the common path (setup+chain), and only allocate a verify object on-demand when .WasCalled() is called.
4. Member name mismatch between setup and verification in PropertyMockCall<T>
Setup registers with name $"{_propertyName} (get)" (e.g., "Name (get)"), but verification uses $"get_{_propertyName}" (e.g., "get_Name"). If the engine uses member names in error messages or to correlate setup/call records, these will be inconsistent:
// PropertyMockCall.cs — setup:
var methodSetup = new MethodSetup(_getterMemberId, matchers, $"{_propertyName} (get)");
// PropertyMockCall.cs — verify:
_engine.CreateVerification(_getterMemberId, $"get_{_propertyName}", ...).WasCalled(times);These names should be unified. Since matching is probably done by _getterMemberId, this may only affect error messages, but even that inconsistency is confusing when debugging test failures.
5. Non-void standalone calls are silently a no-op
The asymmetry between void and non-void is non-obvious:
mock.Log("x"); // void → setup is registered (strict mode compatible)
mock.GetUser(Arg.Any<int>()); // non-void → nothing happens (no setup, no verify)There is no compiler warning or runtime diagnostic when mock.GetUser(Arg.Any<int>()) appears alone. This could silently mask developer mistakes (e.g., forgetting to chain .WasCalled() or .Returns()). A Roslyn analyzer that warns on unconsumed MockMethodCall<T> return values (similar to [MustUseReturnValue]) would significantly improve discoverability. It would be worth either adding that analyzer or documenting the asymmetry prominently.
Minor Notes
-
The
Raisesmethod onISetupChain<TReturn>takes a string event name ("ItemAdded"), which is stringly typed. The old typed raise extensions (mock.Raise.ItemAdded(e)) were compile-time safe. The newmock.RaisesItemAdded(e)generated overloads restore type safety for theRaisesshortcut on the setup chain, which is good. -
The removal of
IMockSetup<T>andIMockVerify<T>as separate extension targets simplifies the source generator considerably. TheMockMembersBuilderreplacing two separate builders is cleaner. -
MockOfT.csconstructor simplification (removingsetup/verify/raiseconstructor parameters) is clean and correct. -
The snapshot
.verified.txtfiles are appropriately updated and confirm the generator output is working correctly.
Overall
The API direction is right — unified call objects that support both setup and verification are more ergonomic. The Getter-side-effect bug and the VerifyAll() behavioral regression are the highest-priority issues to address before merging.
…d calls Address code review Issue 1: the check-then-act pattern in EnsureSetup() for non-void MockMethodCall<TReturn> and generated non-void sealed classes was a data race under concurrent access. Replace the nullable field with Lazy<T> which guarantees single initialization. Void methods (VoidMockMethodCall, generated void classes) and property setters (PropertySetterMockCall) are unaffected — they eagerly initialize in the constructor before the object is visible to other threads. Also add clarifying doc comments for: - Issue 2: void vs non-void registration asymmetry - Issue 5: PropertyMockCall.Getter deliberate re-registration
|
Addressed in f225a2c: Issue 1 — Thread-safety race in Issue 2 — Void vs non-void asymmetry: Added XML doc comment on Issue 3 — Verification object per call: Skipping. These Issue 4 — Hardcoded Issue 5 — All 475 runtime tests and 10 snapshot tests pass. |
There was a problem hiding this comment.
Code Review: Follow-up on feat(mocks): unified Mock API
This review follows the two previous Claude reviews submitted before commit f225a2c9. Good news: the thread-safety fix in that last commit (Lazy<T> for non-void setup registration) is the right approach and directly addresses the previous concern.
What Was Addressed Since Last Review
Thread safety (non-void path) — fixed correctly. MockMethodCall<T> and the generated *_MockCall types for methods with return values now use Lazy<MethodSetupBuilder<T>> for deferred, thread-safe setup creation. This is consistent and correct.
Remaining Concerns
1. PropertyMockCall.Setter has an uncontrolled multiple-registration bug (higher severity than Getter)
The Getter property registering a new setup on each access was flagged previously and has since been documented as intentional (supports sequential behavior chaining). That reasoning is defensible. However, the Setter property has the same pattern but with eager registration via VoidMockMethodCall, which makes it a genuine bug rather than a feature:
// In PropertyMockCall<T>:
public VoidMockMethodCall Setter
{
get => new VoidMockMethodCall(_engine, _setterMemberId, $"{_propertyName} (set)",
Array.Empty<IArgumentMatcher>());
// ↑ Every access constructs a new VoidMockMethodCall, which eagerly calls EnsureSetup()
// and registers a NEW MethodSetup in the engine
}This means:
mock.Count.Setter.WasCalled(Times.Exactly(3)); // ← registers setup #1 as side effect
mock.Count.Setter.WasCalled(Times.Exactly(3)); // ← registers setup #2 as side effect
mock.VerifyAll(); // ← fails: setup #2 was never invokedUnlike Getter, Setter eagerly registers, so accessing it once purely for verification still produces an uninvoked setup in the engine. The Getter case at least requires calling .Returns() before it registers.
The fix is the same as the Getter comment suggests for the lazy case: make Setter return a cached instance per PropertyMockCall, or defer registration until a setup chain method is explicitly called. Since PropertyMockCall<T> is a readonly struct recreated on each property access, the caching would need to live elsewhere (e.g., in the engine keyed by member ID, or via a different design for Setter).
2. Member name inconsistency between setup registration and verification in PropertyMockCall<T>
The setup side registers names with one convention and the verification side uses a different one:
// Setup (via Getter):
new MethodSetup(_getterMemberId, matchers, $"{_propertyName} (get)") // → "Name (get)"
// Verification (via WasCalled):
_engine.CreateVerification(_getterMemberId, $"get_{_propertyName}", ...) // → "get_Name"
// Setup (via PropertySetterMockCall):
new MethodSetup(_setterMemberId, matchers, $"{_propertyName} (set)") // → "Name (set)"
// Verification (via PropertySetterMockCall.WasCalled):
_engine.CreateVerification(_setterMemberId, $"set_{_propertyName}", ...) // → "set_Name"The mock implementation itself uses "get_Name" / "set_Name" in call records (consistent with the C# convention). Error messages during setup failures will show "Name (get)" while error messages from verification failures will show "get_Name" — these two names appearing in the same diagnostic context when debugging a test failure is confusing. Unify to "get_{_propertyName}" / "set_{_propertyName}" throughout.
3. VoidMockMethodCall thread safety in EnsureSetup (minor)
The void path uses a manual null-check pattern:
private VoidMethodSetupBuilder EnsureSetup()
{
if (_builder is null) // ← not thread-safe (no Interlocked/volatile)
{
var setup = new MethodSetup(...);
_engine.AddSetup(setup);
_builder = new VoidMethodSetupBuilder(setup);
}
return _builder;
}In practice this is harmless because the constructor calls EnsureSetup() eagerly and _builder is never null by the time any external caller invokes it. But it's inconsistent with the non-void path's Lazy<T> approach and the null-check will confuse readers who wonder if there's a valid uninitialized state. Consider replacing with Lazy<VoidMethodSetupBuilder> for consistency, or remove the null check and initialize _builder directly in the constructor.
Observations (no action required)
- C# 14 extension blocks for properties — using
extension(Mock<T> mock)blocks to generate extension properties is clean and future-proof. Worth documenting that this requiresLangVersion = preview/ net10.0 for anyone reading the generated code. PropertyMockCall<T>stays asreadonly struct— good. The struct is stack-allocated per access and the reference to the engine is stable. The allocation concern from the previous review applies mainly toMockMethodCall<T>andVoidMockMethodCall, which are nowsealed classby design.MockOfT.csconstructor cleanup — the removal ofsetup/verify/raiseparameters and the simplification toMock(T mockObject, MockEngine<T> engine)is clean and correct.
Summary
The last commit (f225a2c9) correctly resolved the non-void thread-safety concern. The highest-priority remaining issue is the Setter property registering a new setup on every access — this is behaviorally different from the intentional Getter behavior because of eager registration, and will cause VerifyAll() failures for any test that accesses Setter more than once. The member name inconsistency is a secondary concern that will create confusing error messages in failure scenarios.
…on, Lazy<T> 1. Unify property member names between setup and verification paths: setup now uses "get_Name"/"set_Name" (was "Name (get)"/"Name (set)") to match verification, which already used "get_Name"/"set_Name". 2. PropertyMockCall.Setter now creates VoidMockMethodCall with eagerRegister: false, avoiding unwanted setup registration when accessed purely for verification (.WasCalled()). Setup methods (.Callback(), .Throws()) still trigger registration on demand. 3. VoidMockMethodCall now uses Lazy<T> for consistency with the non-void MockMethodCall<TReturn> pattern, with an internal eagerRegister parameter that forces evaluation in the constructor when true (default for extension methods on Mock<T>).
|
Addressed in f5f06f4 (responding to reviews 2 and 3): Review 2 Issue 1 (Getter re-registration): This is pre-existing behavior from Review 2 Issue 2 (Void eager + VerifyAll): The specific edge case is Review 2 Issue 3 (Allocation regression): Deliberate. These are test-time ephemeral objects, not engine hot paths. The Review 2 Issue 4 / Review 3 Issue 2 (Member name inconsistency): Fixed. Setup paths now use Review 2 Issue 5 (Non-void silent no-op): Already documented. A Review 3 Issue 1 (Setter re-registration): Fixed. Review 3 Issue 3 (VoidMockMethodCall consistency): Fixed. Now uses All 475 runtime tests and 10 snapshot tests pass. |
There was a problem hiding this comment.
Code Review: Follow-up after f5f06f4
This is the fourth review pass on this PR. The previous three rounds of review (pre-f225a2c9 and pre-f5f06f4) surface-addressed thread safety and several design concerns. Here is where things stand now.
What Has Been Addressed
- Thread safety (non-void):
MockMethodCall<TReturn>and generated non-void*_MockCallclasses now useLazy<T>, which is correct. - Thread safety (void):
VoidMockMethodCallusesLazy<T>with eager initialization — also correct. The_lazyBuilder.Valuecall in the constructor ensures the setup is registered on construction before the object is visible to any other thread. PropertyMockCall<T>.Getterre-registration: Documented as intentional ("last wins" stacking). Acceptable given the prior-art inPropertySetupAccessor.- Setter deferred registration:
PropertyMockCall<T>.Setternow useseagerRegister: false, andPropertySetterMockCall<T>handles its own eager registration. Consistent.
Remaining Issue 1: PropertySetterMockCall<T>.EnsureSetup() is inconsistent with the Lazy<T> pattern
VoidMockMethodCall and all non-void *_MockCall classes use Lazy<T> for safe lazy/eager initialization. PropertySetterMockCall<T> still uses a manual null-check:
private VoidMethodSetupBuilder EnsureSetup()
{
if (_builder is null)
{
var matchers = new IArgumentMatcher[] { _matcher };
var methodSetup = new MethodSetup(_setterMemberId, matchers, $"set_{_propertyName}");
_engine.AddSetup(methodSetup);
_builder = new VoidMethodSetupBuilder(methodSetup);
}
return _builder;
}In practice, EnsureSetup() is called eagerly in the constructor so _builder is always non-null after construction. The null-check is unreachable in normal usage. But it's an inconsistency that could become a real race if someone subclasses or calls from a context where the object is visible before the constructor finishes. Recommend aligning with Lazy<T> for consistency:
private readonly Lazy<VoidMethodSetupBuilder> _lazyBuilder;
public PropertySetterMockCall(...)
{
...
_lazyBuilder = new Lazy<VoidMethodSetupBuilder>(() => {
var matchers = new IArgumentMatcher[] { _matcher };
var setup = new MethodSetup(_setterMemberId, matchers, $"set_{_propertyName}");
_engine.AddSetup(setup);
return new VoidMethodSetupBuilder(setup);
});
_ = _lazyBuilder.Value; // eager
}Remaining Issue 2: VerifyAll + Void Eager Registration Semantic Conflict
This was raised in the previous review and thomhurst's response was truncated. The concern: when a user writes verification-only code for void methods:
mock.Log(Arg.Any<string>()).WasNeverCalled();VoidMockMethodCall eagerly registers a MethodSetup before the user even calls .WasNeverCalled(). If the test then calls mock.VerifyAll(), the engine finds a registered setup for Log that was never invoked — and depending on how VerifyAll is implemented, this could produce a false failure ("Log was set up but never called").
The root issue is that eager void registration is a side effect of constructing the unified object, not of explicitly calling a setup method. Setup and verification now share a constructor, so it's impossible for the engine to know whether the user intends to set up or verify at construction time.
Suggested approach: Rather than eager registration at construction time, consider registering the setup only when a chain method with setup semantics is called, and verifying through CreateVerification for all WasCalled/WasNeverCalled paths. Strict mode compatibility (allowing the call without chaining) could be handled by a separate Allow() method:
mock.Log(Arg.Any<string>()); // lazy — does nothing yet
mock.Log(Arg.Any<string>()).Allow(); // explicit: "allow this call in strict mode"
mock.Log(Arg.Any<string>()).WasCalled(); // verify only — no setup registeredThis is admittedly a larger API change, but it eliminates the ambiguity entirely. If that's too invasive, at minimum the documentation should warn about the VerifyAll interaction when using void methods for verification only.
Minor Issue 3: Lazy<T> Default Thread-Safety Mode
Lazy<T> without a constructor argument uses LazyThreadSafetyMode.ExecutionAndPublication, which acquires a lock on every value access until the value is initialized. Since most mock setups are single-threaded and the lock is held during _engine.AddSetup() (which may itself be thread-safe), this is fine but slightly heavier than necessary.
LazyThreadSafetyMode.PublicationOnly is a better fit here — it allows multiple threads to compute the value but only one registration "wins". However, since AddSetup has side effects (registering a MethodSetup), PublicationOnly could still cause double-registration races. The current ExecutionAndPublication is technically the safer choice. No change required — this is informational.
Minor Issue 4: Generated WasCalled Overloads — Repeated CreateVerification() Calls
In every generated *_MockCall class and in MockMethodCall<T> / VoidMockMethodCall, each WasCalled overload calls _engine.CreateVerification(...) independently:
public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled();
public void WasCalled(Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times);
public void WasCalled(Times times, string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times, message);
public void WasCalled(string? message) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(message);Since only one of these is called per verification, this is not a runtime performance concern. But extracting a private ICallVerification BuildVerification() => _engine.CreateVerification(...) helper would reduce duplication and make it easier to spot if the arguments ever diverge. Low priority.
Overall Assessment
The API direction is sound — unified mock.Method().Returns() / mock.Method().WasCalled() is genuinely more ergonomic than mock.Setup.Method() / mock.Verify.Method(). The thread-safety work from previous review iterations is solid.
The two issues worth resolving before merge:
PropertySetterMockCall.EnsureSetup()alignment (minor, low risk but inconsistent with the rest of the codebase)VerifyAll+ void eager registration (needs confirmation that this is tested and documented if it's an accepted limitation)
The rest is polish.
Align PropertySetterMockCall<T> with VoidMockMethodCall and MockMethodCall<TReturn> by replacing the manual null-check pattern with Lazy<T> for thread-safe setup registration.
There was a problem hiding this comment.
Code Review: Unified Mock API
This is a well-considered API simplification. Removing the three-surface model (Setup/Verify/Raise) in favor of direct extension methods on Mock<T> meaningfully reduces cognitive overhead and the amount of code users must write. The source generator consolidation from three builders into one MockMembersBuilder is a clear maintainability win. The test suite coverage is extensive and the snapshot updates look correct.
That said, there are a few design concerns worth discussing before merging.
Issue 1: Void method verification silently registers a setup (behavioral change)
This is the most important semantic difference from the old API. In the old API:
mock.Verify.Write(Arg.Any<string>()).WasCalled(); // no setup registeredIn the new API:
mock.Write(Arg.Any<string>()).WasCalled(); // setup IS registered (eager in constructor)The generated *_MockCall class for void methods with parameters calls EnsureSetup() eagerly in its constructor (MockMembersBuilder.cs:GenerateVoidUnifiedClass). This means every call to mock.Write(...) — including calls that are purely for verification — registers a new MethodSetup in the engine.
The consequence: in tests that first verify and then continue exercising the SUT, extra setups accumulate. This could cause hard-to-diagnose failures in strict mode (unexpected method-allowed errors) or mask genuine issues (a second setup silently matching a different call). It's a real footgun for users who adopt a verify-after pattern or copy-paste a verification call for reuse.
The PR description acknowledges this tradeoff, but consider whether the ergonomics gain is worth the hidden side-effect. An alternative:
// Opt-in to strict-mode allowance explicitly
mock.Write(Arg.Any<string>()).Allow(); // only way to register void setup
mock.Write(Arg.Any<string>()).WasCalled(); // verify only, no setupThis makes the side-effect explicit and keeps the verify path pure.
Issue 2: Thread-safety inconsistency in generated void wrapper classes
VoidMockMethodCall (the runtime base type) uses Lazy<VoidMethodSetupBuilder> — which is thread-safe by default. But the generated typed wrapper classes for void methods with parameters (e.g. IReadWriter_Write_M2_MockCall, MockMembersBuilder.cs:GenerateVoidUnifiedClass) use a manual null-check pattern:
private VoidMethodSetupBuilder? _builder;
private VoidMethodSetupBuilder EnsureSetup()
{
if (_builder is null) // not thread-safe
{
var setup = new MethodSetup(...);
_engine.AddSetup(setup);
_builder = new VoidMethodSetupBuilder(setup);
}
return _builder;
}Currently this doesn't cause a race because EnsureSetup() is called eagerly in the constructor before the object is shared. But the code pattern is deceptive — if the eager-init is ever removed or the pattern is reused in a non-eager context, it becomes a latent data race. The return-type wrappers correctly use Lazy<T> throughout. Use Lazy<T> for the void wrappers too, or at minimum add a comment explaining why it's safe despite the pattern.
Issue 3: PropertyMockCall<T>.Getter registers a new setup on every access
From PropertyMockCall.cs:
public IPropertySetup<TProperty> Getter
{
get
{
var methodSetup = new MethodSetup(_getterMemberId, matchers, $"get_{_propertyName}");
_engine.AddSetup(methodSetup); // NEW setup registered each access
return new PropertySetupBuilder<TProperty>(methodSetup);
}
}The comment says this is intentional for chaining different behaviors. However, users might access .Getter for verification (checking what it returns), or store it in a variable and call it multiple times:
var name = mock.Name;
name.Getter.Returns("Alice"); // registers setup #1
// ... later
name.Getter.Returns("Bob"); // registers setup #2 (intentional)
// but:
name.WasCalled(); // verification — doesn't trigger Getter (fine)This is actually consistent with the old PropertySetupAccessor behavior, so no regression here. But the documentation note is important. Consider whether PropertyMockCall<T> should use a lazy builder for .Getter similar to how MockMethodCall<T> works, registering only the most-recently configured setup.
Issue 4: Breaking change — no migration path documented
Mock<T>.Setup, Mock<T>.Verify, and Mock<T>.Raise are removed. InState(string, Action<IMockSetup<T>>) signature changed. These are breaking changes for any existing TUnit.Mocks users. The PR description explains the new API well, but there's no mention of a migration guide or version bump strategy. Even a brief "before/after" mapping in the docs would help.
Issue 5: MockMemberNames hardcoded set is a maintenance trap
MockMembersBuilder.cs:
private static readonly HashSet<string> MockMemberNames = new(...)
{
"Object", "Engine", "Behavior", "Invocations", ...
};This needs to be manually kept in sync with Mock<T> public members. If a new member is added to Mock<T>, an interface method with the same name would silently shadow or be shadowed by the extension method. Consider deriving this list from Mock<T>'s actual member names at source-gen time, or at minimum adding a comment linking back to MockOfT.cs.
Minor: struct → class for generated per-method wrappers
The old *_TypedSetup types were readonly struct. The new *_MockCall types are sealed class. This is necessary for the lazy/eager setup pattern (structs can't have mutable reference fields), so the change is correct. It does add one heap allocation per method call expression in test setup/verification code, but for test code this is inconsequential.
Summary
| API ergonomics | Significant improvement |
| Source generator complexity | Reduced (3 builders → 1) |
| Void method verification side-effect | Design concern worth addressing |
| Thread safety in generated code | Inconsistent but not currently dangerous |
| Breaking change handling | Needs documentation |
The architecture moves in the right direction. The void method eager-registration design decision is the one I'd most want to see reconsidered or at minimum explicitly documented as a deliberate trade-off in the API surface docs, since it represents a meaningful behavior change from the old model.
…safety Generated void unified classes (e.g. IReadWriter_Write_M2_MockCall) used a manual null-check pattern for _builder, while the runtime VoidMockMethodCall and generated non-void wrappers used Lazy<T>. Now all three use Lazy<T> consistently.
There was a problem hiding this comment.
Follow-up Review: Unified Mock API
Reviewing against my previous comments on this PR. Here's the status of each prior issue, plus new findings.
Prior Issues — Status Update
✅ Issue 1 (Thread Safety) — RESOLVED
The data race in EnsureSetup() has been fully fixed. Both MockMethodCall<TReturn> and VoidMockMethodCall now use Lazy<T> (which defaults to LazyThreadSafetyMode.ExecutionAndPublication), and the source generator emits the same pattern in the typed *_MockCall classes. This is correct.
✅ Issue 2 (Void/Non-Void Asymmetry) — Acknowledged and Documented
The eagerRegister parameter is now explicit and XML docs clearly explain the design intent. The public constructor of VoidMockMethodCall defaults to eagerRegister: true, and PropertyMockCall.Setter opts out with eagerRegister: false. The comment in MockMethodCall<TReturn> explaining "chain .Returns(default!)" is sufficient for the strict mode case. Acceptable.
❌ Issue 3 (WasCalled Allocates on Every Call) — Still Open
Each WasCalled() / WasNeverCalled() call in both concrete classes and all generated *_MockCall sealed classes still creates a fresh CallVerificationBuilder:
public void WasCalled() => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled();
public void WasCalled(Times times) => _engine.CreateVerification(_memberId, _memberName, _matchers).WasCalled(times);
// ... repeated 6 times per classCaching the verification builder (lazily, similar to the setup builder) or invoking verification directly on the engine without an intermediate builder object would reduce allocations in the happy path. Not blocking, but still worth addressing.
❌ Issue 4 (MockMemberNames Hardcoded List) — Still Open
MockMembersBuilder.cs still has a static HashSet<string> listing Mock<T> member names. If Mock<T> gains a new public member (e.g. a Record(...) method), the collision-avoidance logic silently misses it and generates an ambiguous extension. Deriving this list from the Mock<T> symbol in the compilation remains the correct long-term fix.
✅ Issue 5 (PropertyMockCall.Getter Creates New Setup on Each Access) — Documented
The comment now clearly explains the deliberate re-registration design for chaining. Acceptable.
New Issue: C# 14 Extension Block Syntax in Generated Code
This is the most important new concern. Property extensions are now generated using C# 14 extension(T receiver) syntax:
// Generated output — Interface_With_Properties.verified.txt
public static class IRepository_MockMemberExtensions
{
extension(global::TUnit.Mocks.Mock<global::IRepository> mock)
{
public global::TUnit.Mocks.PropertyMockCall<string> Name
=> new(mock.Engine, 0, 1, "Name", true, true);
}
}Generated source files are compiled as part of the user's project. A user whose project has <LangVersion>13</LangVersion> (or lower — common on .NET 8/9 projects that haven't opted into preview) will get a compile error in the generated file the moment their mocked interface has any properties.
Meanwhile, method extensions are still generated using classic C# 8+ syntax (this Mock<T> mock), so mocks of interfaces with only methods continue to compile fine under older language versions. This creates an inconsistent experience where adding a property to a mocked interface suddenly breaks compilation in a confusing way.
Suggested alternatives:
Option A — Keep the same static extension class, expose a method returning PropertyMockCall<T>:
// Works with any C# version ≥ 8
public static PropertyMockCall<string> Name(this Mock<IRepository> mock)
=> new(mock.Engine, 0, 1, "Name", true, true);The ergonomics become mock.Name() instead of mock.Name, which is a minor change but avoids the language version requirement.
Option B — Require C# 14 explicitly (document it as a minimum requirement for TUnit.Mocks). If TUnit.Mocks is already only targeting .NET 10+ consumers, this is more reasonable, but it should be a conscious, documented decision rather than an implicit side-effect.
Option A is preferable unless there's a strong reason to require C# 14. The getter-like access via extension property is nice ergonomics, but not worth imposing a language version floor on all consumers.
Minor Observations
-
PropertyMockCall<TProperty>is areadonly structwhilePropertySetterMockCall<TProperty>is asealed class. The asymmetry in value vs. reference semantics within the same API surface is mildly surprising. SincePropertyMockCallonly holds references, there's no performance benefit to the struct — consider aligning both tosealed class. -
The
eagerRegisterbool parameter onVoidMockMethodCall's internal constructor could become confusing as the type evolves. Replacing the bool with a static factory method (e.g.,VoidMockMethodCall.Deferred(engine, ...)) would make intent clearer at each call site.
Summary
The thread-safety fix (Issue 1) was the most critical concern from the prior review and is now properly resolved with Lazy<T>. The overall direction of the API — removing the .Setup. / .Verify. indirection — is cleaner and the implementation is solid. The C# 14 extension block issue is the main thing I'd want addressed before merge, as it's a silent breaking change for projects targeting .NET 8/9 without preview LangVersion.
There was a problem hiding this comment.
Follow-up Review: Unified Mock API
Reviewing against my previous comments on this PR. Here is the status of each prior issue, plus new findings.
Prior Issues — Status Update
Issue 1 (Thread Safety) — RESOLVED
The data race in EnsureSetup() has been fully fixed. Both MockMethodCall<TReturn> and VoidMockMethodCall now use Lazy<T> (which defaults to LazyThreadSafetyMode.ExecutionAndPublication), and the source generator emits the same pattern in all typed *_MockCall classes. This is correct.
Issue 2 (Void/Non-Void Asymmetry) — Acknowledged and Documented
The eagerRegister parameter is now explicit and XML docs clearly explain the design intent. The public constructor of VoidMockMethodCall defaults to eagerRegister: true, and PropertyMockCall.Setter opts out with eagerRegister: false. The comment pointing to .Returns(default!) as the strict-mode escape hatch for non-void methods is sufficient. Acceptable.
Issue 3 (WasCalled Allocates on Every Call) — Still Open
Each WasCalled() / WasNeverCalled() call still creates a fresh CallVerificationBuilder in both the concrete classes and all generated *_MockCall sealed classes. Not blocking, but still worth a follow-up.
Issue 4 (MockMemberNames Hardcoded List) — Still Open
MockMembersBuilder.cs still has a static HashSet<string> listing Mock<T> member names. If Mock<T> gains a new public member, the collision-avoidance silently misses it and generates an ambiguous extension method. Deriving this list from the Mock<T> symbol at generator initialization remains the correct long-term fix.
Issue 5 (PropertyMockCall.Getter Creates New Setup on Each Access) — Documented
The comment now clearly explains the deliberate re-registration design for chaining behaviors. Acceptable.
New Issue: C# 14 Extension Block Syntax in Generated Code
This is the most important new concern. Property extensions are generated using C# 14 extension(T receiver) syntax:
// Actual generated output (Interface_With_Properties.verified.txt)
public static class IRepository_MockMemberExtensions
{
extension(global::TUnit.Mocks.Mock<global::IRepository> mock)
{
public global::TUnit.Mocks.PropertyMockCall<string> Name
=> new(mock.Engine, 0, 1, "Name", true, true);
}
}Generated source files are compiled as part of the user's project, not TUnit's. A user whose project has <LangVersion>13</LangVersion> or lower — common on .NET 8 and .NET 9 projects that have not opted into preview — will receive a compile error in the generated file the moment their mocked interface has any properties.
Meanwhile, method extensions are still generated with classic this Mock<T> mock syntax (C# 8+), so mocks of interfaces with only methods continue to compile fine under older language versions. This creates an inconsistent experience: adding a property to a mocked interface suddenly breaks compilation with an obscure error about generated code, not a clear "upgrade your LangVersion" message.
Suggested alternatives:
Option A — Use a regular static extension method returning PropertyMockCall<T>:
// Works with any C# >= 8; same result, slightly different call-site syntax
public static PropertyMockCall<string> Name(this Mock<IRepository> mock)
=> new(mock.Engine, 0, 1, "Name", true, true);Call-site becomes mock.Name() instead of mock.Name — a small ergonomics trade-off to avoid imposing a C# 14 floor on all consumers.
Option B — Document C# 14 / LangVersion preview as an explicit minimum requirement for TUnit.Mocks and emit a #error or analyzer diagnostic when it is not met. If .NET 10+ targeting is already assumed, this is a reasonable stance, but it should be a deliberate and documented decision rather than a silent side-effect.
Option A is preferable unless the property-access syntax (mock.Name vs mock.Name()) is considered important enough to impose the language version constraint.
Minor Observations
PropertyMockCall<T> struct vs PropertySetterMockCall<T> class asymmetry. PropertyMockCall<TProperty> is a readonly struct while PropertySetterMockCall<TProperty> is a sealed class. Since PropertyMockCall only holds references (IMockEngineAccess, string, int, bool), the struct gives no allocation benefit in practice — any passage through ICallVerification will box it anyway. Aligning both to sealed class would simplify the mental model without a meaningful performance cost.
eagerRegister bool parameter. The internal VoidMockMethodCall(..., bool eagerRegister) pattern works but will become hard to read as call sites accumulate. A static factory method (e.g., VoidMockMethodCall.Deferred(engine, ...)) would make intent explicit at each call site.
Summary
The thread-safety fix (Issue 1 from the prior review) was the most critical concern and is now properly resolved with Lazy<T> throughout both the concrete classes and generated wrappers. The overall direction — removing .Setup. / .Verify. indirection — is cleaner and the implementation is solid. The C# 14 extension block concern is the main item I would want addressed before merge, as it silently breaks compilation for any TUnit.Mocks consumer targeting .NET 8/9 without preview LangVersion whose mocked interface has properties.
Summary
.Setup.,.Verify., and.Raise.intermediate properties fromMock<T>. Methods are now called directly on the mock via source-generated extension methods, with chain methods (.Returns()vs.WasCalled()) disambiguating between setup and verification.MockMethodCall<T>,VoidMockMethodCall,PropertyMockCall<T>,PropertySetterMockCall<T>) that expose both setup and verification surfaces on a single object.MockSetupBuilder+MockVerifyBuilderwith a singleMockMembersBuilderthat generates extension methods onMock<T>and unified sealed classes per qualifying method.mock.Log(Arg.Any<string>())to allow calls in strict mode).Before
After
Test plan
TUnit.Mocks.Testson net10.0)TUnit.Mocks.SourceGenerator.Tests).verified.txtfiles updated to reflect new generated output