diff --git a/Tests/Mockolate.Tests/MockTests.ThreadSafetyTests.cs b/Tests/Mockolate.Tests/MockTests.ThreadSafetyTests.cs new file mode 100644 index 00000000..4f0864c2 --- /dev/null +++ b/Tests/Mockolate.Tests/MockTests.ThreadSafetyTests.cs @@ -0,0 +1,427 @@ +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using Xunit.Sdk; + +namespace Mockolate.Tests; + +public sealed partial class MockTests +{ + public sealed class ThreadSafetyTests + { + [Fact] + public async Task Event_ShouldBeThreadSafe() + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + ManualResetEventSlim barrier = new(false); + int taskCount = 50; + int iterationsPerTask = 20; + Task[] tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + tasks[i] = Task.Run(() => + { + EventHandler handler = (_, _) => { }; + barrier.Wait(); + for (int j = 0; j < iterationsPerTask; j++) + { + sut.MyEvent += handler; + sut.MyEvent -= handler; + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + await That(sut.Mock.Verify.MyEvent.Subscribed()).Exactly(taskCount * iterationsPerTask); + await That(sut.Mock.Verify.MyEvent.Unsubscribed()).Exactly(taskCount * iterationsPerTask); + + ValidateInteractionIndices(sut); + } + + [Fact] + public async Task Event_SubscribeRaiseUnsubscribe_ShouldBeThreadSafe() + { + for (int round = 0; round < 10; round++) + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + int handlerCallCount = 0; + ManualResetEventSlim barrier = new(false); + int subscriberCount = 20; + int eventsPerSubscriber = 10; + Task[] tasks = new Task[subscriberCount * 3]; + for (int i = 0; i < subscriberCount; i++) + { + tasks[i] = Task.Run(() => + { + EventHandler handler = (_, _) => Interlocked.Increment(ref handlerCallCount); + barrier.Wait(); + sut.MyEvent += handler; + }, CancellationToken.None); + } + + for (int i = 0; i < subscriberCount; i++) + { + int idx = subscriberCount + i; + tasks[idx] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < eventsPerSubscriber; j++) + { + sut.Mock.Raise.MyEvent(this, EventArgs.Empty); + } + }, CancellationToken.None); + } + + for (int i = 0; i < subscriberCount; i++) + { + int idx = (subscriberCount * 2) + i; + tasks[idx] = Task.Run(() => + { + EventHandler handler = (_, _) => Interlocked.Increment(ref handlerCallCount); + barrier.Wait(); + sut.MyEvent -= handler; + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + await That(sut.Mock.Verify.MyEvent.Subscribed()).AtLeast(subscriberCount); + await That(sut.Mock.Verify.MyEvent.Unsubscribed()).AtLeast(subscriberCount); + } + } + + [Fact] + public async Task Indexer_ManyKeysWithCallbackSequence_ShouldBeThreadSafe() + { + for (int round = 0; round < 5; round++) + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + int keyCount = 20; + for (int k = 0; k < keyCount; k++) + { + sut.Mock.Setup[k].InitializeWith($"key-{k}"); + } + + ConcurrentDictionary> valuesByKey = []; + ManualResetEventSlim barrier = new(false); + int readerCount = 30; + int iterationsPerReader = 10; + int writerCount = 8; + int iterationsPerWriter = 10; + Task[] tasks = new Task[readerCount + writerCount]; + Random random = new(42 + round); + + int[][] readerKeys = new int[readerCount][]; + for (int i = 0; i < readerCount; i++) + { + readerKeys[i] = new int[iterationsPerReader]; + for (int j = 0; j < iterationsPerReader; j++) + { + readerKeys[i][j] = random.Next(keyCount); + } + } + + int[][] writerKeys = new int[writerCount][]; + for (int i = 0; i < writerCount; i++) + { + writerKeys[i] = new int[iterationsPerWriter]; + for (int j = 0; j < iterationsPerWriter; j++) + { + writerKeys[i][j] = random.Next(keyCount); + } + } + + for (int i = 0; i < readerCount; i++) + { + int[] keys = readerKeys[i]; + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerReader; j++) + { + int key = keys[j]; + string value = sut[key]; + valuesByKey.GetOrAdd(key, _ => new ConcurrentQueue()).Enqueue(value); + } + }, CancellationToken.None); + } + + for (int i = 0; i < writerCount; i++) + { + int[] keys = writerKeys[i]; + tasks[readerCount + i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerWriter; j++) + { + int key = keys[j]; + sut[key] = $"key-{key}"; + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + // Verify all reads returned expected values (no corruption) + await That(valuesByKey).All().Satisfy(kvp => + { + string expectedValue = $"key-{kvp.Key}"; + return kvp.Value.All(v => v == expectedValue); + }); + + ValidateInteractionIndices(sut); + } + } + + [Fact] + public async Task Indexer_ShouldBeThreadSafe() + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + sut.Mock.Setup[0].InitializeWith("hello"); + sut.Mock.Setup[1].InitializeWith("hello"); + ConcurrentQueue values = []; + ManualResetEventSlim barrier = new(false); + int readerCount = 50; + int iterationsPerReader = 20; + int writerCount = 10; + int iterationsPerWriter = 20; + Task[] tasks = new Task[readerCount + writerCount]; + for (int i = 0; i < readerCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerReader; j++) + { + values.Enqueue(sut[0]); + values.Enqueue(sut[1]); + } + }, CancellationToken.None); + } + + for (int i = 0; i < writerCount; i++) + { + tasks[readerCount + i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerWriter; j++) + { + sut[0] = "hello"; + sut[1] = "hello"; + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + await That(sut.Mock.Verify[0].Got()).Exactly(readerCount * iterationsPerReader); + await That(sut.Mock.Verify[1].Got()).Exactly(readerCount * iterationsPerReader); + await That(sut.Mock.Verify[0].Set(It.IsAny())).Exactly(writerCount * iterationsPerWriter); + await That(sut.Mock.Verify[1].Set(It.IsAny())).Exactly(writerCount * iterationsPerWriter); + + await That(values).Contains("hello") + .Exactly(readerCount * iterationsPerReader * 2); + + ValidateInteractionIndices(sut); + } + + [Fact] + public async Task Method_HighContention_ShouldBeThreadSafe() + { + for (int round = 0; round < 10; round++) + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + sut.Mock.Setup.MyMethod(It.IsAny()).Returns(42); + ConcurrentQueue values = []; + ManualResetEventSlim barrier = new(false); + int taskCount = 60; + int iterationsPerTask = 20; + Task[] tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerTask; j++) + { + values.Enqueue(sut.MyMethod(j)); + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + int expectedCalls = taskCount * iterationsPerTask; + await That(values).All().Satisfy(v => v == 42); + await That(sut.Mock.Verify.MyMethod(It.IsAny())).Exactly(expectedCalls); + } + } + + [Fact] + public async Task Method_ShouldBeThreadSafe() + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + sut.Mock.Setup.MyMethod(It.IsAny()).Returns(42); + ConcurrentQueue values = []; + ManualResetEventSlim barrier = new(false); + int taskCount = 50; + int iterationsPerTask = 20; + Task[] tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerTask; j++) + { + values.Enqueue(sut.MyMethod(j)); + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + await That(sut.Mock.Verify.MyMethod(It.IsAny())).Exactly(taskCount * iterationsPerTask); + await That(values).Contains(42).Exactly(taskCount * iterationsPerTask); + + ValidateInteractionIndices(sut); + } + + [Fact] + public async Task Property_HighContention_ShouldBeThreadSafe() + { + for (int round = 0; round < 10; round++) + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + sut.Mock.Setup.MyStringProperty.InitializeWith("test-value"); + ConcurrentQueue values = []; + ManualResetEventSlim barrier = new(false); + int taskCount = 60; + int iterationsPerTask = 20; + Task[] tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerTask; j++) + { + values.Enqueue(sut.MyStringProperty); + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + int expectedReads = taskCount * iterationsPerTask; + await That(values).All().Satisfy(v => v == "test-value"); + await That(sut.Mock.Verify.MyStringProperty.Got()).Exactly(expectedReads); + } + } + + [Fact] + public async Task Property_ShouldBeThreadSafe() + { + IMyThreadSafetyService sut = IMyThreadSafetyService.CreateMock(); + Guid expectedGuid = Guid.NewGuid(); + sut.Mock.Setup.MyStringProperty.InitializeWith("hello"); + sut.Mock.Setup.MyGuidProperty.InitializeWith(expectedGuid); + ConcurrentQueue stringValues = []; + ConcurrentQueue guidValues = []; + ManualResetEventSlim barrier = new(false); + int readerCount = 50; + int iterationsPerReader = 20; + int writerCount = 10; + int iterationsPerWriter = 20; + Task[] tasks = new Task[readerCount + writerCount]; + for (int i = 0; i < readerCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerReader; j++) + { + stringValues.Enqueue(sut.MyStringProperty); + guidValues.Enqueue(sut.MyGuidProperty); + } + }, CancellationToken.None); + } + + for (int i = 0; i < writerCount; i++) + { + tasks[readerCount + i] = Task.Run(() => + { + barrier.Wait(); + for (int j = 0; j < iterationsPerWriter; j++) + { + sut.MyStringProperty = "hello"; + sut.MyGuidProperty = expectedGuid; + } + }, CancellationToken.None); + } + + barrier.Set(); + await Task.WhenAll(tasks); + + await That(sut.Mock.Verify.MyStringProperty.Got()) + .Exactly(readerCount * iterationsPerReader); + await That(sut.Mock.Verify.MyGuidProperty.Got()) + .Exactly(readerCount * iterationsPerReader); + + await That(sut.Mock.Verify.MyStringProperty.Set(It.IsAny())) + .Exactly(writerCount * iterationsPerWriter); + await That(sut.Mock.Verify.MyGuidProperty.Set(It.IsAny())) + .Exactly(writerCount * iterationsPerWriter); + + await That(stringValues).All() + .Satisfy(v => v == "hello"); + await That(guidValues).All() + .Satisfy(v => v == expectedGuid); + + ValidateInteractionIndices(sut); + } + + private static void ValidateInteractionIndices(IMyThreadSafetyService sut) + { + MockRegistry registry = ((IMock)sut).MockRegistry; + int[] indices = registry.Interactions.Interactions + .Select(i => i.Index) + .ToArray(); + + // Verify indices are strictly ascending + for (int i = 1; i < indices.Length; i++) + { + if (indices[i] <= indices[i - 1]) + { + throw new XunitException( + $"Interaction indices not strictly ascending at position {i}: {indices[i - 1]} -> {indices[i]}"); + } + } + + // Verify all indices are unique + int uniqueCount = indices.Distinct().Count(); + if (uniqueCount != indices.Length) + { + throw new XunitException( + $"Interaction indices not all unique: {indices.Length} total, {uniqueCount} unique"); + } + } + + internal interface IMyThreadSafetyService + { + string MyStringProperty { get; set; } + Guid MyGuidProperty { get; set; } + string this[int index] { get; set; } + int MyMethod(int value); + event EventHandler? MyEvent; + } + } +}