diff --git a/TUnit.Mocks.Tests/PerformanceOptimizationTests.cs b/TUnit.Mocks.Tests/PerformanceOptimizationTests.cs
new file mode 100644
index 0000000000..679dbe2faa
--- /dev/null
+++ b/TUnit.Mocks.Tests/PerformanceOptimizationTests.cs
@@ -0,0 +1,491 @@
+using TUnit.Mocks;
+using TUnit.Mocks.Arguments;
+using TUnit.Mocks.Exceptions;
+using TUnit.Mocks.Verification;
+
+namespace TUnit.Mocks.Tests;
+
+///
+/// Tests targeting the internal performance optimizations:
+/// - Flat array-based setup storage (replacing Dictionary snapshots)
+/// - Per-member call indexing and counters (replacing ConcurrentQueue linear scans)
+/// - Fast-path verification when no argument matchers are present
+/// - Lock-based call recording thread safety
+///
+public class PerformanceOptimizationTests
+{
+ // ========================================================================
+ // Per-member call indexing and GetCallCountFor
+ // ========================================================================
+
+ [Test]
+ public async Task Invocations_Track_Per_Member_Counts_Correctly()
+ {
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ mock.GetName().Returns("test");
+ ICalculator calc = mock.Object;
+
+ // Act — call different members different numbers of times
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+ calc.Add(5, 6);
+ calc.GetName();
+ calc.Log("msg1");
+ calc.Log("msg2");
+
+ // Assert — total invocations correct
+ await Assert.That(mock.Invocations).HasCount().EqualTo(6);
+
+ // Verify per-member counts via WasCalled
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(3));
+ mock.GetName().WasCalled(Times.Once);
+ mock.Log(Any()).WasCalled(Times.Exactly(2));
+ }
+
+ [Test]
+ public async Task Invocations_Per_Member_Independent_After_Reset()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ // Act — call, reset, call again
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(2));
+
+ mock.Reset();
+ mock.Add(Any(), Any()).Returns(99);
+
+ calc.Add(5, 6);
+
+ // Assert — only one call after reset
+ mock.Add(Any(), Any()).WasCalled(Times.Once);
+ await Assert.That(mock.Invocations).HasCount().EqualTo(1);
+ }
+
+ [Test]
+ public async Task GetCallsFor_Returns_Only_Matching_Member_Calls()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ mock.GetName().Returns("test");
+ ICalculator calc = mock.Object;
+
+ // Act
+ calc.Add(1, 2);
+ calc.GetName();
+ calc.Add(3, 4);
+ calc.GetName();
+ calc.GetName();
+
+ // Assert — verify correct counts per member
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(2));
+ mock.GetName().WasCalled(Times.Exactly(3));
+ await Assert.That(mock.Invocations).HasCount().EqualTo(5);
+ }
+
+ // ========================================================================
+ // Setup storage: flat array with multiple members
+ // ========================================================================
+
+ [Test]
+ public async Task Setups_For_Multiple_Members_Work_Independently()
+ {
+ // Arrange — setup all 3 members of ICalculator
+ var mock = Mock.Of();
+ mock.Add(1, 2).Returns(3);
+ mock.Add(10, 20).Returns(30);
+ mock.GetName().Returns("calculator");
+
+ ICalculator calc = mock.Object;
+
+ // Act & Assert — each member has independent setups
+ await Assert.That(calc.Add(1, 2)).IsEqualTo(3);
+ await Assert.That(calc.Add(10, 20)).IsEqualTo(30);
+ await Assert.That(calc.GetName()).IsEqualTo("calculator");
+ }
+
+ [Test]
+ public async Task Many_Setups_On_Same_Member_Last_Wins()
+ {
+ // Arrange — multiple setups with same args, last should win
+ var mock = Mock.Of();
+ mock.Add(1, 2).Returns(10);
+ mock.Add(1, 2).Returns(20);
+ mock.Add(1, 2).Returns(30);
+
+ ICalculator calc = mock.Object;
+
+ // Assert — last setup wins
+ await Assert.That(calc.Add(1, 2)).IsEqualTo(30);
+ }
+
+ [Test]
+ public async Task Setup_Storage_Handles_Interface_With_Many_Members()
+ {
+ // Arrange — IUserRepository has 7 methods, testing that the flat array
+ // handles larger interfaces correctly
+ var mock = Mock.Of();
+ var user = new UserDto { Id = 1, Name = "Alice" };
+
+ mock.GetByIdAsync(1).Returns(user);
+ mock.ExistsAsync(1).Returns(true);
+ mock.ExistsAsync(2).Returns(false);
+ mock.GetAllAsync().Returns((IReadOnlyList)new List { user });
+
+ var repo = mock.Object;
+
+ // Act & Assert
+ await Assert.That(await repo.GetByIdAsync(1)).IsEqualTo(user);
+ await Assert.That(await repo.ExistsAsync(1)).IsTrue();
+ await Assert.That(await repo.ExistsAsync(2)).IsFalse();
+ var all = await repo.GetAllAsync();
+ await Assert.That(all).HasCount().EqualTo(1);
+ }
+
+ // ========================================================================
+ // Fast-path verification (no argument matchers)
+ // ========================================================================
+
+ [Test]
+ public async Task Verification_Zero_Param_Method_Uses_Fast_Path()
+ {
+ // GetName() has zero parameters, so _matchers.Length == 0 → fast path (per-member counter)
+ var mock = Mock.Of();
+ mock.GetName().Returns("test");
+ ICalculator calc = mock.Object;
+
+ calc.GetName();
+ calc.GetName();
+ calc.GetName();
+
+ mock.GetName().WasCalled(Times.Exactly(3));
+ mock.GetName().WasCalled(Times.AtLeast(2));
+ mock.GetName().WasCalled(Times.AtMost(5));
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task Verification_With_Any_Matchers_Uses_Matcher_Path()
+ {
+ // Any() creates argument matchers, so _matchers.Length > 0 → matcher path
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+ calc.Add(5, 6);
+
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(3));
+ mock.Add(Any(), Any()).WasCalled(Times.AtLeast(2));
+ mock.Add(Any(), Any()).WasCalled(Times.AtMost(5));
+ mock.GetName().WasNeverCalled();
+ mock.Log(Any()).WasNeverCalled();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task Verification_With_Exact_Args_Uses_Matcher_Path()
+ {
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ calc.Add(1, 2);
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+
+ // Exact arg matching only counts matching calls
+ mock.Add(1, 2).WasCalled(Times.Exactly(2));
+ mock.Add(3, 4).WasCalled(Times.Once);
+ mock.Add(5, 6).WasNeverCalled();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task Verification_WasNeverCalled_Zero_Param_Method()
+ {
+ var mock = Mock.Of();
+
+ // Zero-param method → fast path with per-member counter = 0
+ mock.GetName().WasNeverCalled();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task Verification_WasNeverCalled_With_Matchers()
+ {
+ var mock = Mock.Of();
+
+ // Any() creates matchers → matcher path, but still zero calls
+ mock.Add(Any(), Any()).WasNeverCalled();
+ mock.Log(Any()).WasNeverCalled();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task Verification_Failure_With_No_Matchers_Shows_Correct_Message()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+ calc.Add(1, 2);
+
+ // Assert — expecting 3 calls but only 1 was made
+ var ex = Assert.Throws(
+ () => mock.Add(Any(), Any()).WasCalled(Times.Exactly(3)));
+ await Assert.That(ex).IsNotNull();
+ }
+
+ // ========================================================================
+ // Thread safety with new lock-based call recording
+ // ========================================================================
+
+ [Test]
+ public async Task Concurrent_Calls_To_Multiple_Members_All_Recorded()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ mock.GetName().Returns("test");
+ ICalculator calc = mock.Object;
+
+ // Act — 50 concurrent Add calls + 50 concurrent GetName calls
+ var addTasks = Enumerable.Range(0, 50).Select(i => Task.Run(() => { calc.Add(i, i); return 0; }));
+ var nameTasks = Enumerable.Range(0, 50).Select(_ => Task.Run(() => { calc.GetName(); return 0; }));
+ await Task.WhenAll(addTasks.Concat(nameTasks));
+
+ // Assert — all 100 calls recorded
+ await Assert.That(mock.Invocations).HasCount().EqualTo(100);
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(50));
+ mock.GetName().WasCalled(Times.Exactly(50));
+ }
+
+ [Test]
+ public async Task Concurrent_Setup_Then_Calls_Across_Members()
+ {
+ // Arrange
+ var mock = Mock.Of();
+
+ // Act — setup and call concurrently across members
+ var setupAndCallTasks = Enumerable.Range(0, 20).Select(i => Task.Run(() =>
+ {
+ mock.Add(Any(), Any()).Returns(i);
+ mock.Object.Add(i, i);
+ }));
+ await Task.WhenAll(setupAndCallTasks);
+
+ // Assert — all calls should be recorded
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(20));
+ }
+
+ [Test]
+ public async Task Concurrent_Verification_With_Per_Member_Counters()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ // Make 100 calls first
+ for (int i = 0; i < 100; i++)
+ {
+ calc.Add(i, i);
+ }
+
+ // Act — 20 concurrent verifications should all succeed
+ var verifyTasks = Enumerable.Range(0, 20).Select(_ => Task.Run(() =>
+ {
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(100));
+ }));
+ await Task.WhenAll(verifyTasks);
+ await Assert.That(true).IsTrue();
+ }
+
+ // ========================================================================
+ // VerifyAll and VerifyNoOtherCalls with new data structures
+ // ========================================================================
+
+ [Test]
+ public async Task VerifyAll_Works_With_Flat_Array_Setup_Storage()
+ {
+ // Arrange — setups across multiple members
+ var mock = Mock.Of();
+ mock.Add(1, 2).Returns(3);
+ mock.GetName().Returns("test");
+
+ ICalculator calc = mock.Object;
+ calc.Add(1, 2);
+ calc.GetName();
+
+ // Assert — VerifyAll should iterate all setups in flat array
+ mock.VerifyAll();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task VerifyAll_Fails_When_Setup_Not_Invoked_With_Flat_Array()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(1, 2).Returns(3);
+ mock.GetName().Returns("test");
+
+ ICalculator calc = mock.Object;
+ calc.Add(1, 2); // Only call Add, not GetName
+
+ // Assert — should fail because GetName setup was not invoked
+ var ex = Assert.Throws(() => mock.VerifyAll());
+ await Assert.That(ex).IsNotNull();
+ }
+
+ [Test]
+ public async Task VerifyNoOtherCalls_Works_With_Per_Member_Index()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+
+ // Verify all calls
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(2));
+
+ // Assert — no unverified calls
+ mock.VerifyNoOtherCalls();
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task VerifyNoOtherCalls_Fails_With_Unverified_Member()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ mock.GetName().Returns("test");
+ ICalculator calc = mock.Object;
+
+ calc.Add(1, 2);
+ calc.GetName();
+
+ // Only verify Add, not GetName
+ mock.Add(Any(), Any()).WasCalled(Times.Once);
+
+ // Assert — GetName call is unverified
+ var ex = Assert.Throws(() => mock.VerifyNoOtherCalls());
+ await Assert.That(ex).IsNotNull();
+ }
+
+ // ========================================================================
+ // Reset clears new data structures completely
+ // ========================================================================
+
+ [Test]
+ public async Task Reset_Clears_Per_Member_Call_Index_And_Counters()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(Any(), Any()).Returns(42);
+ ICalculator calc = mock.Object;
+
+ calc.Add(1, 2);
+ calc.Add(3, 4);
+ calc.Add(5, 6);
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(3));
+
+ // Act
+ mock.Reset();
+
+ // Assert — all counters reset
+ mock.Add(Any(), Any()).WasNeverCalled();
+ mock.GetName().WasNeverCalled();
+ mock.Log(Any()).WasNeverCalled();
+ await Assert.That(mock.Invocations).HasCount().EqualTo(0);
+ }
+
+ [Test]
+ public async Task Reset_Clears_Flat_Array_Setups_And_Allows_Reconfiguration()
+ {
+ // Arrange
+ var mock = Mock.Of();
+ mock.Add(1, 2).Returns(100);
+ mock.GetName().Returns("first");
+ ICalculator calc = mock.Object;
+
+ await Assert.That(calc.Add(1, 2)).IsEqualTo(100);
+ await Assert.That(calc.GetName()).IsEqualTo("first");
+
+ // Act
+ mock.Reset();
+ mock.Add(1, 2).Returns(200);
+ mock.GetName().Returns("second");
+
+ // Assert — new setups active
+ await Assert.That(calc.Add(1, 2)).IsEqualTo(200);
+ await Assert.That(calc.GetName()).IsEqualTo("second");
+ }
+
+ [Test]
+ public async Task Multiple_Reset_Cycles_Work_Correctly()
+ {
+ var mock = Mock.Of();
+ ICalculator calc = mock.Object;
+
+ for (int cycle = 0; cycle < 5; cycle++)
+ {
+ mock.Add(Any(), Any()).Returns(cycle);
+ calc.Add(1, 2);
+ await Assert.That(calc.Add(1, 2)).IsEqualTo(cycle);
+ mock.Add(Any(), Any()).WasCalled(Times.Exactly(2));
+ mock.Reset();
+ }
+
+ // Final state: clean
+ mock.Add(Any(), Any()).WasNeverCalled();
+ await Assert.That(mock.Invocations).HasCount().EqualTo(0);
+ }
+
+ // ========================================================================
+ // Edge cases for array growth/sizing
+ // ========================================================================
+
+ [Test]
+ public async Task Verification_Before_Any_Setup_Or_Call()
+ {
+ // Arrange — fresh mock, no setups, no calls
+ var mock = Mock.Of();
+
+ // Assert — should not throw, all members have zero calls
+ mock.Add(Any(), Any()).WasNeverCalled();
+ mock.GetName().WasNeverCalled();
+ mock.Log(Any()).WasNeverCalled();
+ await Assert.That(mock.Invocations).HasCount().EqualTo(0);
+ }
+
+ [Test]
+ public async Task Call_Without_Setup_Still_Recorded_In_Per_Member_Index()
+ {
+ // Arrange — no setup, loose mode
+ var mock = Mock.Of();
+ ICalculator calc = mock.Object;
+
+ // Act — call without any setup
+ calc.Add(1, 2);
+ calc.Log("test");
+ calc.GetName();
+
+ // Assert — all calls recorded even without setups
+ await Assert.That(mock.Invocations).HasCount().EqualTo(3);
+ mock.Add(Any(), Any()).WasCalled(Times.Once);
+ mock.Log(Any()).WasCalled(Times.Once);
+ mock.GetName().WasCalled(Times.Once);
+ }
+}
diff --git a/TUnit.Mocks/MockEngine.cs b/TUnit.Mocks/MockEngine.cs
index a0acfb17aa..e9c5d41cf8 100644
--- a/TUnit.Mocks/MockEngine.cs
+++ b/TUnit.Mocks/MockEngine.cs
@@ -4,6 +4,7 @@
using TUnit.Mocks.Setup.Behaviors;
using TUnit.Mocks.Verification;
using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.ComponentModel;
@@ -22,11 +23,21 @@ internal static class MockCallSequence
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class MockEngine : IMockEngineAccess where T : class
{
- private readonly Lock _setupLock = new();
- private Dictionary>? _setupsByMember;
- private volatile Dictionary? _setupsSnapshot;
+ // Single lock for both setup and call mutations — reduces allocation by one Lock object.
+ // Contention is acceptable since setup and call recording rarely overlap in typical usage.
+ private Lock? _lock;
+ private Lock Lock => _lock ?? EnsureLock();
+
+ // Flat array indexed by memberId for O(1) setup lookup (member IDs are dense sequential ints 0..N)
+ private volatile MethodSetup[]?[]? _setupsByMemberId;
+ private List?[]? _setupListsByMemberId; // mutable lists used during AddSetup, guarded by Lock
private volatile bool _hasStatefulSetups;
- private volatile ConcurrentQueue _callHistory = new();
+
+ // Call history: main list + per-member index for fast lookup + per-member counters for fast verification
+ // All lazily initialized on first RecordCall to save ~64B when mock is created but never invoked.
+ private List? _callHistory;
+ private List?[]? _callsByMemberId;
+ private int[]? _callCountByMemberId;
private ConcurrentDictionary? _autoTrackValues;
private ConcurrentQueue<(string EventName, bool IsSubscribe)>? _eventSubscriptions;
@@ -81,6 +92,13 @@ public MockEngine(MockBehavior behavior)
Behavior = behavior;
}
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private Lock EnsureLock()
+ {
+ Interlocked.CompareExchange(ref _lock, new Lock(), null);
+ return _lock!;
+ }
+
private ConcurrentDictionary AutoTrackValues
=> LazyInitializer.EnsureInitialized(ref _autoTrackValues)!;
@@ -100,13 +118,13 @@ private ConcurrentDictionary OnUnsubscribeCallbacks
/// Transitions the engine to the specified state. Null clears the state.
///
[EditorBrowsable(EditorBrowsableState.Never)]
- public void TransitionTo(string? stateName) { lock (_setupLock) { _currentState = stateName; } }
+ public void TransitionTo(string? stateName) { lock (Lock) { _currentState = stateName; } }
///
/// Gets the current state name. Null means no state.
///
[EditorBrowsable(EditorBrowsableState.Never)]
- public string? CurrentState { get { lock (_setupLock) { return _currentState; } } }
+ public string? CurrentState { get { lock (Lock) { return _currentState; } } }
///
/// Registers a new setup. Thread-safe via lock.
@@ -114,20 +132,18 @@ private ConcurrentDictionary OnUnsubscribeCallbacks
///
public void AddSetup(MethodSetup setup)
{
- lock (_setupLock)
+ lock (Lock)
{
if (PendingRequiredState is not null)
{
setup.RequiredState = PendingRequiredState;
}
- var dict = _setupsByMember ??= new();
-
- if (!dict.TryGetValue(setup.MemberId, out var list))
- {
- dict[setup.MemberId] = list = new();
- }
+ var memberId = setup.MemberId;
+ EnsureSetupArrayCapacity(memberId);
+ var lists = _setupListsByMemberId!;
+ var list = lists[memberId] ??= new();
list.Add(setup);
if (setup.RequiredState is not null || setup.TransitionTarget is not null)
@@ -135,13 +151,30 @@ public void AddSetup(MethodSetup setup)
_hasStatefulSetups = true;
}
- // Rebuild lock-free snapshot: shallow-copy existing, only re-array the affected member
- var prev = _setupsSnapshot;
- var snapshot = prev is null
- ? new Dictionary()
- : new Dictionary(prev);
- snapshot[setup.MemberId] = list.ToArray();
- _setupsSnapshot = snapshot;
+ // Update the lock-free snapshot array for this member only
+ var snapshot = _setupsByMemberId!;
+ snapshot[memberId] = list.ToArray();
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void EnsureSetupArrayCapacity(int memberId)
+ {
+ var required = memberId + 1;
+ if (_setupsByMemberId is null || _setupsByMemberId.Length < required)
+ {
+ var newSize = _setupsByMemberId is null
+ ? Math.Max(required, 8)
+ : Math.Max(required, _setupsByMemberId.Length * 2);
+ var newSnapshot = new MethodSetup[]?[newSize];
+ var newLists = new List?[newSize];
+ if (_setupsByMemberId is not null)
+ {
+ Array.Copy(_setupsByMemberId, newSnapshot, _setupsByMemberId.Length);
+ Array.Copy(_setupListsByMemberId!, newLists, _setupListsByMemberId!.Length);
+ }
+ _setupsByMemberId = newSnapshot;
+ _setupListsByMemberId = newLists;
}
}
@@ -174,12 +207,7 @@ public void HandleCall(int memberId, string memberName, object?[] args)
}
try
{
- // Set out/ref assignments after Execute to avoid reentrancy overwrite from callbacks
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
- if (matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
+ ApplyMatchedSetup(matchedSetup);
}
catch
{
@@ -189,16 +217,11 @@ public void HandleCall(int memberId, string memberName, object?[] args)
return;
}
- // Set out/ref assignments for generated code to consume
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
+ ApplyMatchedSetup(matchedSetup);
// A matching setup with no explicit behavior means "allow this call" (e.g., void setup with no callback)
if (setupFound)
{
- if (matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
return;
}
@@ -232,12 +255,7 @@ public TReturn HandleCallWithReturn(int memberId, string memberName, ob
}
try
{
- // Set out/ref assignments after Execute to avoid reentrancy overwrite from callbacks
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
- if (matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
+ ApplyMatchedSetup(matchedSetup);
}
catch
{
@@ -254,17 +272,11 @@ public TReturn HandleCallWithReturn(int memberId, string memberName, ob
$"Setup for method returning {typeof(TReturn).Name} returned incompatible type {result.GetType().Name}.");
}
- // Set out/ref assignments for generated code to consume
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
+ ApplyMatchedSetup(matchedSetup);
// A matching setup with no explicit behavior returns the default value
if (setupFound)
{
- if (matchedSetup is not null)
- {
-
- RaiseEventsForSetup(matchedSetup);
- }
return defaultValue;
}
@@ -340,12 +352,7 @@ public bool TryHandleCall(int memberId, string memberName, object?[] args)
}
try
{
- // Set out/ref assignments after Execute to avoid reentrancy overwrite from callbacks
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
- if (matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
+ ApplyMatchedSetup(matchedSetup);
}
catch
{
@@ -355,13 +362,7 @@ public bool TryHandleCall(int memberId, string memberName, object?[] args)
return true;
}
- // Set out/ref assignments for generated code to consume
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
-
- if (setupFound && matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
+ ApplyMatchedSetup(matchedSetup);
if (!setupFound)
{
@@ -400,12 +401,7 @@ public bool TryHandleCallWithReturn(int memberId, string memberName, ob
}
try
{
- // Set out/ref assignments after Execute to avoid reentrancy overwrite from callbacks
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
- if (matchedSetup is not null)
- {
- RaiseEventsForSetup(matchedSetup);
- }
+ ApplyMatchedSetup(matchedSetup);
}
catch
{
@@ -423,16 +419,10 @@ public bool TryHandleCallWithReturn(int memberId, string memberName, ob
return true;
}
- // Set out/ref assignments for generated code to consume
- OutRefContext.Set(matchedSetup?.OutRefAssignments);
+ ApplyMatchedSetup(matchedSetup);
if (setupFound)
{
- if (matchedSetup is not null)
- {
-
- RaiseEventsForSetup(matchedSetup);
- }
result = defaultValue;
return true;
}
@@ -455,15 +445,47 @@ public bool TryHandleCallWithReturn(int memberId, string memberName, ob
///
public IReadOnlyList GetCallsFor(int memberId)
{
- var result = new List();
- foreach (var record in _callHistory)
+ lock (Lock)
+ {
+ if (_callsByMemberId is not null && (uint)memberId < (uint)_callsByMemberId.Length
+ && _callsByMemberId[memberId] is { } list)
+ {
+ return list.ToArray();
+ }
+ }
+ return [];
+ }
+
+ ///
+ /// Marks all calls for a specific member as verified without allocating a copy.
+ ///
+ internal void MarkCallsVerified(int memberId)
+ {
+ lock (Lock)
{
- if (record.MemberId == memberId)
+ if (_callsByMemberId is not null && (uint)memberId < (uint)_callsByMemberId.Length
+ && _callsByMemberId[memberId] is { } list)
{
- result.Add(record);
+ for (int i = 0; i < list.Count; i++)
+ {
+ list[i].IsVerified = true;
+ }
}
}
- return result;
+ }
+
+ ///
+ /// Gets the count of calls for a specific member without allocating.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public int GetCallCountFor(int memberId)
+ {
+ var counts = Volatile.Read(ref _callCountByMemberId);
+ if (counts is not null && (uint)memberId < (uint)counts.Length)
+ {
+ return Volatile.Read(ref counts[memberId]);
+ }
+ return 0;
}
///
@@ -471,7 +493,10 @@ public IReadOnlyList GetCallsFor(int memberId)
///
public IReadOnlyList GetAllCalls()
{
- return _callHistory.ToArray();
+ lock (Lock)
+ {
+ return _callHistory is null ? [] : _callHistory.ToArray();
+ }
}
///
@@ -480,15 +505,19 @@ public IReadOnlyList GetAllCalls()
[EditorBrowsable(EditorBrowsableState.Never)]
public IReadOnlyList GetUnverifiedCalls()
{
- var result = new List();
- foreach (var record in _callHistory)
+ lock (Lock)
{
- if (!record.IsVerified)
+ if (_callHistory is null) return [];
+ var result = new List();
+ foreach (var record in _callHistory)
{
- result.Add(record);
+ if (!record.IsVerified)
+ {
+ result.Add(record);
+ }
}
+ return result;
}
- return result;
}
///
@@ -497,16 +526,19 @@ public IReadOnlyList GetUnverifiedCalls()
[EditorBrowsable(EditorBrowsableState.Never)]
public IReadOnlyList GetSetups()
{
- var snapshot = _setupsSnapshot;
+ var snapshot = _setupsByMemberId;
if (snapshot is null)
{
return [];
}
var all = new List();
- foreach (var arr in snapshot.Values)
+ foreach (var arr in snapshot)
{
- all.AddRange(arr);
+ if (arr is not null)
+ {
+ all.AddRange(arr);
+ }
}
return all;
}
@@ -539,11 +571,17 @@ public Diagnostics.MockDiagnostics GetDiagnostics()
}
var unmatchedCalls = new List();
- foreach (var call in _callHistory)
+ lock (Lock)
{
- if (call.IsUnmatched)
+ if (_callHistory is not null)
{
- unmatchedCalls.Add(call);
+ foreach (var call in _callHistory)
+ {
+ if (call.IsUnmatched)
+ {
+ unmatchedCalls.Add(call);
+ }
+ }
}
}
@@ -569,16 +607,17 @@ public bool TryGetAutoMock(string cacheKey, [System.Diagnostics.CodeAnalysis.Not
///
public void Reset()
{
- lock (_setupLock)
+ lock (Lock)
{
- _setupsByMember = null;
- _setupsSnapshot = null;
+ _setupsByMemberId = null;
+ _setupListsByMemberId = null;
_hasStatefulSetups = false;
_currentState = null;
PendingRequiredState = null;
+ _callHistory = null;
+ _callsByMemberId = null;
+ _callCountByMemberId = null;
}
-
- _callHistory = new ConcurrentQueue(); // volatile field — assignment is a volatile write
Volatile.Write(ref _autoTrackValues, null);
Volatile.Write(ref _eventSubscriptions, null);
Volatile.Write(ref _onSubscribeCallbacks, null);
@@ -665,10 +704,55 @@ private CallRecord RecordCall(int memberId, string memberName, object?[] args)
{
var seq = MockCallSequence.Next();
var record = new CallRecord(memberId, memberName, args, seq);
- _callHistory.Enqueue(record);
+ lock (Lock)
+ {
+ var history = _callHistory ??= new();
+ history.Add(record);
+ EnsureCallArrayCapacity(memberId);
+ var memberCalls = _callsByMemberId![memberId] ??= new();
+ memberCalls.Add(record);
+ _callCountByMemberId![memberId]++;
+ }
return record;
}
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void EnsureCallArrayCapacity(int memberId)
+ {
+ var required = memberId + 1;
+ if (_callsByMemberId is null || _callsByMemberId.Length < required)
+ {
+ var newSize = _callsByMemberId is null
+ ? Math.Max(required, 8)
+ : Math.Max(required, _callsByMemberId.Length * 2);
+ var newByMember = new List?[newSize];
+ var newCounts = new int[newSize];
+ if (_callsByMemberId is not null)
+ {
+ Array.Copy(_callsByMemberId, newByMember, _callsByMemberId.Length);
+ Array.Copy(_callCountByMemberId!, newCounts, _callCountByMemberId!.Length);
+ }
+ _callsByMemberId = newByMember;
+ _callCountByMemberId = newCounts;
+ }
+ }
+
+ ///
+ /// Applies out/ref assignments and raises events for a matched setup.
+ /// Consolidates the pattern that appeared in all four Handle methods.
+ ///
+ private void ApplyMatchedSetup(MethodSetup? matchedSetup)
+ {
+ // Always set (or clear) OutRefContext — even when matchedSetup is null.
+ // A reentrant mock call inside Execute() may have written to it, and the
+ // outer call must overwrite that to avoid stale thread-local state.
+ OutRefContext.Set(matchedSetup?.OutRefAssignments);
+ if (matchedSetup is not null)
+ {
+ RaiseEventsForSetup(matchedSetup);
+ }
+ }
+
private void RaiseEventsForSetup(MethodSetup setup)
{
if (Raisable is null) return;
@@ -689,8 +773,14 @@ private void RaiseEventsForSetup(MethodSetup setup)
return FindMatchingSetupLocked(memberId, args);
}
- var snapshot = _setupsSnapshot;
- if (snapshot is null || !snapshot.TryGetValue(memberId, out var setups))
+ var snapshot = _setupsByMemberId;
+ if (snapshot is null || (uint)memberId >= (uint)snapshot.Length)
+ {
+ return (false, null, null);
+ }
+
+ var setups = snapshot[memberId];
+ if (setups is null)
{
return (false, null, null);
}
@@ -713,9 +803,15 @@ private void RaiseEventsForSetup(MethodSetup setup)
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetupLocked(int memberId, object?[] args)
{
- lock (_setupLock)
+ lock (Lock)
{
- if (_setupsByMember is not { } setupDict || !setupDict.TryGetValue(memberId, out var setups))
+ if (_setupListsByMemberId is not { } lists || (uint)memberId >= (uint)lists.Length)
+ {
+ return (false, null, null);
+ }
+
+ var setups = lists[memberId];
+ if (setups is null)
{
return (false, null, null);
}
diff --git a/TUnit.Mocks/Setup/MethodSetup.cs b/TUnit.Mocks/Setup/MethodSetup.cs
index 55ad23b17e..0f48291eca 100644
--- a/TUnit.Mocks/Setup/MethodSetup.cs
+++ b/TUnit.Mocks/Setup/MethodSetup.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Runtime.CompilerServices;
using TUnit.Mocks.Arguments;
using TUnit.Mocks.Setup.Behaviors;
@@ -11,7 +12,15 @@ namespace TUnit.Mocks.Setup;
public sealed class MethodSetup
{
private readonly IArgumentMatcher[] _matchers;
- private readonly Lock _behaviorLock = new();
+ private Lock? _behaviorLock;
+ private Lock BehaviorLock => _behaviorLock ?? EnsureBehaviorLock();
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private Lock EnsureBehaviorLock()
+ {
+ Interlocked.CompareExchange(ref _behaviorLock, new Lock(), null);
+ return _behaviorLock!;
+ }
/// Fast path for the common single-behavior case. Avoids list + lock on read.
private volatile IBehavior? _singleBehavior;
private volatile List? _behaviors;
@@ -62,7 +71,7 @@ public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName
public void AddBehavior(IBehavior behavior)
{
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
if (_singleBehavior is null && _behaviors is null)
{
@@ -100,7 +109,7 @@ public bool Matches(object?[] actualArgs)
public void AddEventRaise(EventRaiseInfo raiseInfo)
{
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
var list = _eventRaises ??= new();
list.Add(raiseInfo);
@@ -121,7 +130,7 @@ public IReadOnlyList GetEventRaises()
return snapshot;
}
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
return _eventRaisesSnapshot ??= _eventRaises!.ToArray();
}
@@ -150,7 +159,7 @@ public void ApplyCaptures(object?[] args)
/// The value to assign.
public void SetOutRefValue(int paramIndex, object? value)
{
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
_outRefAssignments ??= new Dictionary();
_outRefAssignments[paramIndex] = value;
@@ -165,7 +174,7 @@ public void SetOutRefValue(int paramIndex, object? value)
{
get
{
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
return _outRefAssignments;
}
@@ -199,7 +208,7 @@ public string[] GetMatcherDescriptions()
return null;
}
- lock (_behaviorLock)
+ lock (BehaviorLock)
{
if (_behaviors is not { Count: > 0 } behaviors)
{
diff --git a/TUnit.Mocks/Verification/CallVerificationBuilder.cs b/TUnit.Mocks/Verification/CallVerificationBuilder.cs
index a1671f2d9d..9749861dd2 100644
--- a/TUnit.Mocks/Verification/CallVerificationBuilder.cs
+++ b/TUnit.Mocks/Verification/CallVerificationBuilder.cs
@@ -46,19 +46,42 @@ public void WasCalled(Times times, string? message)
return;
}
- var allCallsForMember = _engine.GetCallsFor(_memberId);
+ // Fast path: when no argument matchers, use the per-member call counter directly.
+ // Note: the count is read lock-free, then MarkCallsVerified acquires the lock.
+ // Calls recorded between these two steps will be marked verified but weren't counted.
+ // This is safe because verification should only run after all calls have completed.
+ if (_matchers.Length == 0)
+ {
+ var totalCount = _engine.GetCallCountFor(_memberId);
+ if (!times.Matches(totalCount))
+ {
+ var callsForError = _engine.GetCallsFor(_memberId);
+ var expectedCall = FormatExpectedCall();
+ var actualCallDescriptions = callsForError.Select(c => c.FormatCall()).ToList();
+ throw new MockVerificationException(expectedCall, times, totalCount, actualCallDescriptions, message);
+ }
+
+ // Mark all calls for this member as verified (single fetch)
+ if (totalCount > 0)
+ {
+ _engine.MarkCallsVerified(_memberId);
+ }
+ return;
+ }
- var matchingCount = CountMatchingCalls(allCallsForMember, markVerified: false);
+ // Slow path: need to match arguments — single-pass count then mark
+ var calls = _engine.GetCallsFor(_memberId);
+ var matchingCount = CountMatchingCalls(calls, markVerified: false);
if (!times.Matches(matchingCount))
{
var expectedCall = FormatExpectedCall();
- var actualCallDescriptions = allCallsForMember.Select(c => c.FormatCall()).ToList();
+ var actualCallDescriptions = calls.Select(c => c.FormatCall()).ToList();
throw new MockVerificationException(expectedCall, times, matchingCount, actualCallDescriptions, message);
}
// Mark matched calls as verified only after assertion passes
- CountMatchingCalls(allCallsForMember, markVerified: true);
+ CountMatchingCalls(calls, markVerified: true);
}
///