refactor(mocks): move Mock<T> control members to static Mock helpers#5006
refactor(mocks): move Mock<T> control members to static Mock helpers#5006
Conversation
…pers Move 11 public instance members off Mock<T> onto the Mock static class to eliminate naming collisions with user interface members. Mock<T> now only exposes Object, Engine (hidden), and implicit conversion to T. IMock methods are implemented explicitly for MockRepository batch ops.
…ect members Only Object, Engine, and inherited System.Object members need reservation now that all control members live on the static Mock class.
Reset_ → Reset now that Reset is no longer a reserved Mock<T> member name.
Migrate ~100 call sites across 19 test files from instance methods (mock.VerifyAll(), mock.Reset(), etc.) to static helpers (Mock.VerifyAll(mock), Mock.Reset(mock), etc.).
There was a problem hiding this comment.
Code Review: refactor(mocks): move Mock control members to static Mock helpers
The core motivation here is solid — eliminating the _ suffix on source-generated extension methods when a user interface has a member named Reset, Invocations, etc. is a real ergonomics win. The implementation is clean and the tests/docs are comprehensively updated. That said, here are some concerns worth addressing:
⚠️ Breaking Change Without a Deprecation Path
This PR removes 11 public instance members from Mock<T> in a single step. Any user currently calling mock.VerifyAll(), mock.Reset(), mock.Invocations, mock.Behavior, etc. will get compile errors with no warning ahead of time.
Suggested approach: Add [Obsolete("Use Mock.VerifyAll(mock) instead. This member will be removed in a future version.", error: false)] wrappers temporarily, allowing users to migrate. This is especially important since the TUnit.Mocks library is still relatively new but likely has early adopters.
🐛 Mock.Invocations(mock) Called Multiple Times in Loops
In the updated test (AdditionalCoverageTests.cs):
for (int i = 1; i < Mock.Invocations(mock).Count; i++)
{
await Assert.That(Mock.Invocations(mock)[i].SequenceNumber)
.IsGreaterThan(Mock.Invocations(mock)[i - 1].SequenceNumber);
}Mock.Invocations(mock) calls mock.Engine.GetAllCalls() on every iteration. If GetAllCalls() returns a new snapshot each time, this is wasteful and potentially inconsistent. Even if it returns the same backing list, this is a pattern that teaches users bad habits.
The previous mock.Invocations property had the same behavior but was at least a single assignment point. Now users are more likely to call this in a loop without realizing it. Consider caching in the test to set a better example:
var invocations = Mock.Invocations(mock);
for (int i = 1; i < invocations.Count; i++) { ... }🔍 GetAutoMock Requires Both Type Parameters — Ergonomics Regression
Before:
var autoMock = mock.GetAutoMock<IServiceB>("GetServiceB");After:
var autoMock = Mock.GetAutoMock<IServiceA, IServiceB>(mock, "GetServiceB");C# cannot do partial type inference, so users must now specify both IServiceA and IServiceB. The parent type T is redundant information since mock is already typed as Mock<IServiceA>.
A cleaner alternative: keep it as an extension method on Mock<T> since it doesn't conflict with any user interface member (it's not a name a typical interface would have):
// Extension method — no collision risk with user interfaces
public static Mock<TChild> GetAutoMock<T, TChild>(this Mock<T> mock, string memberName) ...Or at minimum, add an overload that takes the mock as a typed parameter so T can still be inferred:
// At call site, only TChild needs to be specified
var autoMock = Mock.GetAutoMock<IServiceB>(mock, "GetServiceB");This requires restructuring the generic constraints but would restore the original ergonomics.
📝 Naming Inconsistency in the Static API
The new static methods have inconsistent naming conventions:
| Pattern | Method |
|---|---|
No Get prefix |
Mock.Invocations(mock) |
Get prefix |
Mock.GetBehavior(mock), Mock.GetDiagnostics(mock), Mock.GetAutoMock(mock, ...) |
Set prefix |
Mock.SetState(mock, ...), Mock.SetDefaultValueProvider(mock, ...) |
| Action name | Mock.Reset(mock), Mock.SetupAllProperties(mock), Mock.VerifyAll(mock) |
Why is Invocations not GetInvocations? Consider making this consistent — either all property-style accessors get a Get prefix, or none do.
💭 Minor: Indirect Cast in Reset Static Helper
// Mock.cs
public static void Reset<T>(Mock<T> mock) where T : class
=> ((IMock)mock).Reset(); // routes through explicit interface implementation
// MockOfT.cs
void IMock.Reset() => Engine.Reset();The ((IMock)mock).Reset() approach works but adds a boxing-like indirection for no benefit. The static helper could call mock.Engine.Reset() directly since Engine is accessible (just [EditorBrowsable(Never)]). Same applies to VerifyAll and VerifyNoOtherCalls, though those have real logic rather than just a delegation.
This is minor, but VerifyAll and VerifyNoOtherCalls in particular duplicate logic rather than delegate — consider factoring the common verification logic into a shared internal method so the explicit interface implementations and future overloads don't drift.
✅ What's Done Well
- The primary motivation (reducing reserved name collisions from 16 to 6) is achieved cleanly. The snapshot diff (
Reset_→Reset) is exactly right. - The explicit
IMockinterface implementations keepMockRepositorybatch operations working with zero changes — good use of the existing interface design. - Comprehensive test and documentation updates across all 19 test files and 3 doc files.
- The approach is consistent with the existing static pattern already established by
Mock.Of<T>()andMock.VerifyInOrder(...).
The biggest ask before merging is the deprecation path for existing users. Everything else is either a naming polish or an ergonomics improvement.
…appers Add Mock.Get<T>(T obj) to retrieve Mock<T> from any mock object. Uses ConditionalWeakTable registry populated in Mock<T> constructor. Removes GetAutoMock (magic string, redundant type params). Mock.Get works for auto-mocked return values, or any object from Mock.Of.
All static accessors now use Get prefix: GetBehavior, GetDiagnostics, GetDefaultValueProvider, GetInvocations. Makes it clear these are method calls with potential cost (GetInvocations allocates an array).
Introduce IMockEngineAccess<T> with explicit implementation so Engine is no longer visible on Mock<T>. Add Mock.GetEngine<T> static helper. Update source generator to emit Mock.GetEngine(mock) in generated code. MockMemberNames now only reserves Object + System.Object members.
There was a problem hiding this comment.
Code Review
Summary
The motivation here is solid: name collisions between instance members and source-generated extension methods (e.g., user interfaces with forcing ) are a real UX problem, and the static-helper pattern is consistent with the existing / style. The overall direction is good.
Breaking Change Without a Migration Strategy
Moving 11 public instance members off Mock<T> is a breaking change for any existing consumer. The PR description documents what moved but not how existing users migrate. Consider:
- Adding
[Obsolete("Use Mock.Reset(mock) instead.", error: false)]shims on the removed members for one version cycle, then removing them in the next major bump. - Alternatively, document this as a semver-major change in the changelog so downstream adopters know to search-and-replace.
Without either, adopters upgrading between patch/minor versions will hit unexpected compile errors with no guidance in the error message itself.
Inconsistent GetInvocations Caching in Tests
In several tests, Mock.GetInvocations(mock) is called once per assertion on the same snapshot:
// AdditionalCoverageTests.cs — 5 separate calls, each allocates a new list
await Assert.That(Mock.GetInvocations(mock)).HasCount().EqualTo(4);
await Assert.That(Mock.GetInvocations(mock)[0].MemberName).IsEqualTo("Add");
await Assert.That(Mock.GetInvocations(mock)[1].MemberName).IsEqualTo("Log");
await Assert.That(Mock.GetInvocations(mock)[2].MemberName).IsEqualTo("GetName");
await Assert.That(Mock.GetInvocations(mock)[3].MemberName).IsEqualTo("Add");The PR already uses the correct pattern once (the sequence-number loop), but doesn't apply it consistently. The method name GetInvocations looks like a cached property accessor; in practice it calls GetAllCalls() on every invocation. Caching locally makes the allocation cost visible and the intent clearer:
var invocations = Mock.GetInvocations(mock);
await Assert.That(invocations).HasCount().EqualTo(4);
await Assert.That(invocations[0].MemberName).IsEqualTo("Add");
// ...This is a test-code concern, but the inconsistency could mislead future authors into thinking the method is cheap/cached.
IMockEngineAccess<T> Is a Public Leaky Abstraction
IMockEngineAccess<T> is marked [EditorBrowsable(Never)] but remains public. Any external assembly can:
var engine = ((IMockEngineAccess<IFoo>)mock).Engine; // bypasses GetEngineSince MockEngine<T> is the real internal surface, exposing it through a public (if hidden) interface couples external code to the engine's concrete type. If MockEngine<T> ever needs to be replaced or renamed, this becomes a breaking change on a path you didn't intend to support.
Better approach: Make IMockEngineAccess<T> internal. Mock.GetEngine is the intended gateway for generated code — keep the interface as a private implementation detail of the Mock<T> class. The existing [EditorBrowsable(Never)] hint works for IDE discoverability but doesn't protect API surface in compiled assemblies.
Positive Notes
Mock.Get<T>(T mockedObject)is a genuinely better API thanGetAutoMock<T>(string methodName). Requiring callers to have the actual object rather than remembering a string name is more type-safe and less error-prone.ConditionalWeakTablefor the object→wrapper registry is the right choice — mock objects are GC-eligible normally and the mapping evicts automatically.- The
netstandard2.0compatibility path forAddOrUpdateis handled cleanly with the#if NET7_0_OR_GREATERguard. - Shrinking the reserved-name set from 16 to 6 names is the concrete win that motivated all this work — the snapshot diff showing
Reset_→Resetis the proof it works. - All 11 snapshot files are updated, and the test counts confirm no regressions.
There was a problem hiding this comment.
Code Review
Summary
The motivation here is solid: name collisions between Mock<T> instance members and source-generated extension methods (e.g., user interfaces with Reset() forcing Reset_()) are a real UX problem, and the static-helper pattern is consistent with the existing Mock.Of<T>() / Mock.VerifyInOrder() style. The overall direction is good.
Breaking Change Without a Migration Strategy
Moving 11 public instance members off Mock<T> is a breaking change for any existing consumer. The PR description documents what moved but not how existing users migrate. Consider:
- Adding
[Obsolete("Use Mock.Reset(mock) instead.", error: false)]shims on the removed members for one version cycle, then removing them in the next major bump. - Alternatively, document this as a semver-major change in the changelog so downstream adopters know to search-and-replace.
Without either, adopters upgrading between patch/minor versions will hit unexpected compile errors with no guidance in the error message itself.
Inconsistent GetInvocations Caching in Tests
In several tests, Mock.GetInvocations(mock) is called once per assertion on the same snapshot:
// AdditionalCoverageTests.cs — 5 separate calls, each allocates a new list
await Assert.That(Mock.GetInvocations(mock)).HasCount().EqualTo(4);
await Assert.That(Mock.GetInvocations(mock)[0].MemberName).IsEqualTo("Add");
await Assert.That(Mock.GetInvocations(mock)[1].MemberName).IsEqualTo("Log");
await Assert.That(Mock.GetInvocations(mock)[2].MemberName).IsEqualTo("GetName");
await Assert.That(Mock.GetInvocations(mock)[3].MemberName).IsEqualTo("Add");The PR already uses the correct pattern once (the sequence-number loop), but doesn't apply it consistently. The method name GetInvocations looks like a cached property accessor; in practice it calls GetAllCalls() on every invocation. Caching locally makes the allocation cost visible and the intent clearer:
var invocations = Mock.GetInvocations(mock);
await Assert.That(invocations).HasCount().EqualTo(4);
await Assert.That(invocations[0].MemberName).IsEqualTo("Add");
// ...This is a test-code concern, but the inconsistency could mislead future authors into thinking the method is cheap/cached.
IMockEngineAccess<T> Is a Public Leaky Abstraction
IMockEngineAccess<T> is marked [EditorBrowsable(Never)] but remains public. Any external assembly can:
var engine = ((IMockEngineAccess<IFoo>)mock).Engine; // bypasses GetEngineSince MockEngine<T> is the real internal surface, exposing it through a public (if hidden) interface couples external code to the engine's concrete type. If MockEngine<T> ever needs to be replaced or renamed, this becomes a breaking change on a path you didn't intend to support.
Better approach: Make IMockEngineAccess<T> internal. Mock.GetEngine is the intended gateway for generated code — keep the interface as a private implementation detail of the Mock<T> class. The existing [EditorBrowsable(Never)] hint works for IDE discoverability but doesn't protect API surface in compiled assemblies.
Positive Notes
Mock.Get<T>(T mockedObject)is a genuinely better API thanGetAutoMock<T>(string methodName). Requiring callers to have the actual object rather than remembering a string name is more type-safe and less error-prone.ConditionalWeakTablefor the object-to-wrapper registry is the right choice — mock objects are GC-eligible normally and the mapping evicts automatically.- The
netstandard2.0compatibility path forAddOrUpdateis handled cleanly with the#if NET7_0_OR_GREATERguard. - Shrinking the reserved-name set from 16 to 6 names is the concrete win that motivated all this work — the snapshot diff showing
Reset_toResetis the proof it works. - All 11 snapshot files are updated, and the test counts confirm no regressions.
Avoid repeated Mock.GetInvocations() calls in Invocations_Are_In_Call_Order test — cache the result in a local variable for clarity and efficiency.
There was a problem hiding this comment.
Code Review
Great refactor! The core motivation is solid: reducing the reservation set from 16 to 5 eliminates a class of ugly -suffixed extension method collisions. The static-helper pattern is internally consistent with and , and the approach (replacing magic-string ) is a clean improvement.
What's good
- API symmetry — all control operations now live on the static class, matching the existing / pattern. Users have one mental model.
- **** — retrieving a wrapper from the concrete object is far better UX than requiring a magic string + redundant type parameter. The keyed on the mock object is the right data structure (correct GC semantics, no memory leaks).
- Explicit interface implementations — as explicit implementations keeps batch operations working while removing the methods from the public surface.
- Snapshot and doc coverage — all 11 snapshots and 3 doc files updated in the same PR.
Issues
1. throws on type mismatch — misleading error
If a user has a registered and calls , they get an with a runtime message pointing at an internal cast rather than their code. They might not understand why it failed. A type-safe check is cheap and gives a much better error:
This is architecturally better because it distinguishes "not a mock at all" from "mock of the wrong type", producing actionable errors in both cases.
2. Pre-.NET 7 fallback is logically inconsistent
The comment correctly states "each mock object is unique so Add should not throw." But if that invariant holds, the catch block is dead code. If it can throw (e.g., someone calls with the same object twice), then the + is a race condition — another thread could insert between those two calls and the second would throw again.
Pick one consistent approach: if the invariant is guaranteed, remove the try/catch entirely. If re-registration is a real scenario, use a lock or restructure. The current code gives false confidence of thread safety.
3. should be , not
is only called from the constructor, which lives in the same assembly (). It does not need to be . Making it actually enforces the encapsulation rather than relying on IntelliSense suppression, which is easily bypassed.
, by contrast, legitimately needs to be because it's called from source-generated extension methods that live in the user's compilation — that's correct.
4. Minor: still calls twice on consecutive lines
The last commit explicitly fixed the loop case, but still makes two allocating calls:
Given the rename to was specifically chosen to signal "this call has cost," it would be consistent to cache the result here too. Minor, but the commit message set the expectation.
Nit
The new file is named while the interface name is . C# convention usually uses the backtick form in the file name () or the same verbatim name ( with a suffix to distinguish from the non-generic sibling). The suffix is a workable alternative but it's inconsistent with how (non-generic) is named. Not a blocker.
Summary
The architectural direction is right and the implementation is clean. The main issues to address before merging are the misleading from (issue 1) and the logically confused pre-.NET 7 fallback (issue 2). Issue 3 ( visibility) is a meaningful encapsulation concern. Issue 4 is a minor consistency fix.
There was a problem hiding this comment.
Code Review
Great refactor! The core motivation is solid: reducing the MockMemberNames reservation set from 16 to 5 eliminates a class of ugly _-suffixed extension method collisions. The static-helper pattern is internally consistent with Mock.Of<T>() and Mock.VerifyInOrder(...), and Mock.Get<T> (replacing magic-string GetAutoMock) is a clean improvement.
What's good
- API symmetry — all control operations now live on the static
Mockclass, matching the existingMock.Of<T>()/Mock.VerifyInOrder(...)pattern. Users have one mental model. Mock.Get<T>— retrieving a wrapper from the concrete object is far better UX than requiring a magic string + redundant type parameter. TheConditionalWeakTablekeyed on the mock object is the right data structure (correct GC semantics, no memory leaks).- Explicit interface implementations —
IMock.VerifyAll/VerifyNoOtherCalls/Resetas explicit implementations keepsMockRepositorybatch operations working while removing the methods from the public surface. - Snapshot and doc coverage — all 11 snapshots and 3 doc files updated in the same PR.
Issues
1. Mock.Get<T> throws InvalidCastException on type mismatch — misleading error
public static Mock<T> Get<T>(T mockedObject) where T : class
{
if (_objectToMock.TryGetValue(mockedObject, out var mock))
{
return (Mock<T>)mock; // InvalidCastException if T is wrong
}
throw new InvalidOperationException("...");
}If a user has a Mock<IServiceB> registered and calls Mock.Get<IServiceA>(serviceB), they get an InvalidCastException with a runtime message pointing at an internal cast rather than their code. A type-safe check is cheap and gives a much better error:
if (_objectToMock.TryGetValue(mockedObject, out var mock))
{
if (mock is Mock<T> typed)
return typed;
throw new InvalidOperationException(
$"The object is a mock of '{mock.GetType().GenericTypeArguments[0].Name}', not '{typeof(T).Name}'.");
}This is architecturally better because it distinguishes "not a mock at all" from "mock of the wrong type", producing actionable errors in both cases.
2. Pre-.NET 7 Register fallback is logically inconsistent
// Each mock object is unique so Add should not throw, but be safe.
try { _objectToMock.Add(mockObject, mockWrapper); }
catch (ArgumentException) { _objectToMock.Remove(mockObject); _objectToMock.Add(mockObject, mockWrapper); }The comment correctly states "each mock object is unique so Add should not throw." But if that invariant holds, the catch block is dead code. If it can throw (e.g., someone calls Register with the same object twice), then Remove + Add is a TOCTOU race condition — another thread could insert between those two calls and the second Add would throw again.
Pick one consistent approach: if the invariant is guaranteed (objects are always unique), remove the try/catch entirely. If re-registration is a real scenario, use a lock or restructure. The current code gives false confidence of thread safety.
3. Mock.Register should be internal, not public [EditorBrowsable(Never)]
Register is only called from the Mock<T> constructor, which lives in the same assembly (TUnit.Mocks). It does not need to be public. Making it internal actually enforces the encapsulation rather than relying on IntelliSense suppression, which is easily bypassed.
GetEngine, by contrast, legitimately needs to be public because it is called from source-generated extension methods that live in the user's compilation — that's correct.
4. Minor: InvocationsTests.cs still calls GetInvocations twice consecutively
The last commit explicitly fixed the loop case, but Invocations_Contains_Correct_Method_Names still makes two allocating calls:
await Assert.That(Mock.GetInvocations(mock)[0].MemberName).IsEqualTo("GetValue");
await Assert.That(Mock.GetInvocations(mock)[1].MemberName).IsEqualTo("Process");Given the rename to GetInvocations was specifically chosen to signal "this call has cost", caching in a local variable here would be consistent with that reasoning. Minor, but the commit message set the expectation.
Nit
The new file is named IMockEngineAccessOfT.cs while the interface name is IMockEngineAccess<T>. C# convention usually uses the verbatim type name as the file name. The OfT suffix is workable but mildly inconsistent with how the non-generic sibling IMockEngineAccess.cs is named. Not a blocker.
Summary
The architectural direction is right and the implementation is clean. Issues to address before merging:
- Issue 1 (recommended): Catch the wrong-type scenario in
Mock.Get<T>with a meaningful error instead of an opaqueInvalidCastException. - Issue 2: Resolve the logical contradiction in the pre-.NET 7
Registerfallback. - Issue 3: Make
Registerinternal— it has no reason to bepublic. - Issue 4: Nit — cache
GetInvocationsin the remaining test.
- Mock.Get<T>: use 'is' pattern match instead of direct cast to give actionable error when mock type doesn't match (distinguishes "not a mock" from "wrong mock type") - Register: remove dead try/catch in pre-.NET 7 fallback — mock objects are always unique, so Add will not throw - Register: change from public to internal — only called from Mock<T> constructor in the same assembly - InvocationsTests: cache GetInvocations result in local variable
There was a problem hiding this comment.
Code Review: refactor(mocks): move Mock control members to static Mock helpers (Follow-up)
This is a follow-up to the previous review. The refactoring is solid and the majority of earlier concerns have been addressed. Here's the updated assessment:
✅ Previous Issues — Addressed
Naming inconsistency: Fixed. Invocations → GetInvocations, giving all read accessors a consistent Get prefix.
Loop anti-pattern in tests: Fixed. Mock.GetInvocations(mock) is now captured before the loop, not called per-iteration.
GetAutoMock ergonomics: Replaced entirely with Mock.Get<T>(T mockedObject) — a significantly better API that eliminates both the string name and the redundant parent type parameter. No casting or guessing required.
Indirect cast in Reset: Addressed. Mock.Reset(mock) now calls GetEngine(mock).Reset() directly. VerifyAll/VerifyNoOtherCalls correctly still route through ((IMock)mock) since the logic lives there.
⚠️ Remaining Concern: Breaking Change Without Deprecation
The prior review raised this and it remains unaddressed. All 11 removed public members (mock.VerifyAll(), mock.Reset(), mock.Invocations, mock.Behavior, mock.DefaultValueProvider, etc.) disappear without any [Obsolete] bridge. Early adopters will get compile errors with no guidance.
Recommended approach — even a single release with warnings would help:
[Obsolete("Use Mock.VerifyAll(mock) instead. Will be removed in the next version.")]
public void VerifyAll() => Mock.VerifyAll(this);If the library is intentionally pre-1.0 and breaking changes are acceptable without notice, this can be skipped — but it should be called out in release notes.
🐛 Mock.Get<T> Limitation With Multi-Interface Mocks
The _objectToMock table registers a proxy object against a single IMock (the first type parameter). Consider:
var mock = Mock.Of<IMultiLogger, IMultiDisposable>();
IMultiDisposable disposable = mock.Object; // proxy implements both interfaces
// This will throw: "The object is a mock of 'IMultiLogger', not 'IMultiDisposable'"
var wrapper = Mock.Get<IMultiDisposable>(disposable);The proxy implements IMultiDisposable correctly, but Mock.Get<IMultiDisposable> fails because the table holds Mock<IMultiLogger>. The error message is misleading in this case — it says "not a mock of IMultiDisposable" when the object truly does mock that interface.
This is a real footgun if a user receives a multi-interface mock object typed as the second interface and tries to retrieve its wrapper. Two options:
- Covariant lookup: If
IMultiDisposableis a known secondary interface, return the sameMock<IMultiLogger>wrapper (it controls both). - Better error message: "This object is a multi-interface mock registered under
IMultiLogger. CallMock.Get<IMultiLogger>(obj)instead." — requires storing the registered type name.
💡 DefaultValueProvider Setter Loses Property-Style Ergonomics
Previously:
mock.DefaultValueProvider ??= new MyProvider();Now requires:
if (Mock.GetDefaultValueProvider(mock) is null)
Mock.SetDefaultValueProvider(mock, new MyProvider());This is a minor ergonomic regression. Since DefaultValueProvider won't conflict with user interface names (it's not a common interface method name), an extension property on Mock<T> would preserve the nice syntax without polluting the reserved-name list significantly. That said, this is a low-priority polish item.
✅ What's Well Done
- The core motivation is sound and the snapshot diff proves it works:
Reset_()→Reset()inMulti_Method_Interface.verified.txtis the concrete win. ConditionalWeakTablefor_objectToMockis GC-friendly and correct. The.NET < 7fallback usingAdd(safe because each proxy isnew()) is well-reasoned.IMockEngineAccess<T>with[EditorBrowsable(Never)]correctly hides the engine from IntelliSense while keeping it accessible to generated code.- All 19 test files, 11 snapshots, and 3 doc files are consistently updated — no orphaned old API usage left behind.
Mock.GetEnginebeing[EditorBrowsable(Never)]but public is the right choice for source-generator consumption without polluting user IntelliSense.
Summary: The refactoring achieves its stated goal cleanly. The breaking change concern is the most actionable item before merging. The multi-interface Mock.Get limitation is worth documenting at minimum, and fixing if multi-interface auto-mocking is a supported use case.
There was a problem hiding this comment.
Code Review: refactor(mocks): move Mock control members to static Mock helpers (Follow-up)
This is a follow-up to the previous review. The refactoring is solid and the majority of earlier concerns have been addressed. Here's the updated assessment:
Previous Issues — Addressed
Naming inconsistency: Fixed. Invocations => GetInvocations, giving all read accessors a consistent Get prefix.
Loop anti-pattern in tests: Fixed. Mock.GetInvocations(mock) is now captured before the loop, not called per-iteration.
GetAutoMock ergonomics: Replaced entirely with Mock.Get<T>(T mockedObject) — a significantly better API that eliminates both the string name and the redundant parent type parameter. No casting or guessing required.
Indirect cast in Reset: Addressed. Mock.Reset(mock) now calls GetEngine(mock).Reset() directly. VerifyAll/VerifyNoOtherCalls correctly still route through ((IMock)mock) since the logic lives there.
Remaining Concern: Breaking Change Without Deprecation
The prior review raised this and it remains unaddressed. All 11 removed public members (mock.VerifyAll(), mock.Reset(), mock.Invocations, mock.Behavior, mock.DefaultValueProvider, etc.) disappear without any [Obsolete] bridge. Early adopters will get compile errors with no guidance.
Recommended approach — even a single release with warnings would help:
[Obsolete("Use Mock.VerifyAll(mock) instead. Will be removed in the next version.")]
public void VerifyAll() => Mock.VerifyAll(this);If the library is intentionally pre-1.0 and breaking changes are acceptable without notice, this can be skipped — but it should be called out in release notes.
Mock.Get<T> Limitation With Multi-Interface Mocks
The _objectToMock table registers a proxy object against a single IMock (the first type parameter). Consider:
var mock = Mock.Of<IMultiLogger, IMultiDisposable>();
IMultiDisposable disposable = mock.Object; // proxy implements both
// This throws: "The object is a mock of 'IMultiLogger', not 'IMultiDisposable'"
var wrapper = Mock.Get<IMultiDisposable>(disposable);The proxy implements IMultiDisposable correctly, but Mock.Get<IMultiDisposable> fails because the table holds Mock<IMultiLogger>. The error message is misleading — it says "not a mock of IMultiDisposable" when the object does mock that interface.
Two options:
- Covariant lookup: Return the same
Mock<IMultiLogger>wrapper when the registered type is a known secondary interface. - Better error message: "This object is a multi-interface mock registered under IMultiLogger. Call
Mock.Get<IMultiLogger>(obj)instead."
DefaultValueProvider Setter Loses Property-Style Ergonomics
Previously: mock.DefaultValueProvider ??= new MyProvider();
Now requires:
if (Mock.GetDefaultValueProvider(mock) is null)
Mock.SetDefaultValueProvider(mock, new MyProvider());Since DefaultValueProvider is unlikely to collide with user interface names, an extension property on Mock<T> could preserve the nice syntax. Low-priority polish item.
What's Well Done
- The core motivation is sound and the snapshot diff proves it:
Reset_()=>Reset()inMulti_Method_Interface.verified.txtis the concrete win this whole PR is built around. ConditionalWeakTablefor_objectToMockis GC-friendly and correct. The pre-.NET 7 fallback usingAdd(safe because each proxy is unique by construction) is well-reasoned.IMockEngineAccess<T>with[EditorBrowsable(Never)]correctly hides the engine from IntelliSense while keeping it accessible to generated code.- All 19 test files, 11 snapshots, and 3 doc files are consistently updated — no orphaned old API usage left behind.
Summary: The refactoring achieves its stated goal cleanly. The breaking change concern is the most actionable item before merging. The Mock.Get multi-interface limitation is worth at least documenting, and fixing if multi-interface auto-mocking is a supported use case.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.17.25 to 1.17.29. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.17.29 <!-- Release notes generated using configuration in .github/release.yml at v1.17.29 --> ## What's Changed ### Other Changes * feat(mocks): span return .Returns() support, out span params, and in param tests by @thomhurst in thomhurst/TUnit#5007 * refactor(mocks): move Mock<T> control members to static Mock helpers by @thomhurst in thomhurst/TUnit#5006 ### Dependencies * chore(deps): update dependency rabbitmq.client to 7.2.1 by @thomhurst in thomhurst/TUnit#5005 * chore(deps): update tunit to 1.17.25 by @thomhurst in thomhurst/TUnit#5004 **Full Changelog**: thomhurst/TUnit@v1.17.25...v1.17.29 Commits viewable in [compare view](thomhurst/TUnit@v1.17.25...v1.17.29). </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
Mock<T>onto theMockstatic class (VerifyAll,Reset,GetInvocations,GetBehavior,SetupAllProperties,GetDiagnostics,SetState,InState,SetDefaultValueProvider, etc.)Mock<T>now has exactly one public member:Object—Enginemoved behind explicitIMockEngineAccess<T>interface, all control methods are staticIMockmethods implemented explicitly soMockRepositorybatch operations continue working unchangedMock.Get<T>(T obj)— retrieves theMock<T>wrapper for any mock object, replacingGetAutoMock(no magic strings, no redundant type params)Object+ 4 inheritedSystem.Objectmembers) — user interfaces with members likeReset,Invocations,VerifyAll,Enginenow generate clean extension method names without the_suffixMotivation
Source-generated extension methods on
Mock<T>can collide with TUnit's own instance members. When a user's interface has a method namedReset(), the generator had to emitmock.Reset_()with a trailing underscore — ugly and confusing. By moving control operations to static helpers and hiding the engine behind an explicit interface, the collision surface drops from 16 names to just 5 (the unavoidableObject+System.Objectinherited members).New API
Consistent with the existing static pattern:
Mock.Of<T>(),Mock.VerifyInOrder(...).Test plan