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); } ///