diff --git a/TUnit.Mocks.Tests/BehaviorCompositionRegressionTests.cs b/TUnit.Mocks.Tests/BehaviorCompositionRegressionTests.cs new file mode 100644 index 0000000000..5304e3f27b --- /dev/null +++ b/TUnit.Mocks.Tests/BehaviorCompositionRegressionTests.cs @@ -0,0 +1,122 @@ +using TUnit.Mocks.Arguments; +using TUnit.Mocks.Matchers; +using TUnit.Mocks.Setup; +using TUnit.Mocks.Setup.Behaviors; + +namespace TUnit.Mocks.Tests; + +public interface IStatefulCommand +{ + int Run(int value); + void Crash(int value); + void Ping(); +} + +public class BehaviorCompositionRegressionTests +{ + [Test] + public async Task Public_SideEffect_Behavior_Does_Not_Replace_Configured_Return() + { + var mock = ICalculator.Mock(); + var sideEffectCount = 0; + var setup = new MethodSetup(0, [AnyMatcher.Instance, AnyMatcher.Instance], nameof(ICalculator.Add)); + setup.AddBehavior(new CustomReturnBehavior(42)); + setup.AddBehavior(new CustomSideEffectBehavior(() => sideEffectCount++)); + MockRegistry.GetEngine(mock).AddSetup(setup); + + var result = mock.Object.Add(1, 2); + + await Assert.That(typeof(ISideEffectBehavior).IsPublic).IsTrue(); + await Assert.That(result).IsEqualTo(42); + await Assert.That(sideEffectCount).IsEqualTo(1); + } + + [Test] + public async Task Composite_Behavior_Uses_Typed_Dispatch_For_Chained_Behaviors() + { + var mock = ICalculator.Mock(); + var captured = 0; + + mock.Add(Arg.Any(), Arg.Any()) + .Callback((int a, int b) => captured = a + b) + .Returns(99); + + var result = mock.Object.Add(4, 5); + + await Assert.That(result).IsEqualTo(99); + await Assert.That(captured).IsEqualTo(9); + } + + [Test] + public async Task TransitionsTo_Chains_With_Typed_Callback_And_Returns() + { + var mock = IStatefulCommand.Mock(); + var captured = 0; + Mock.SetState(mock, "ready"); + + Mock.InState(mock, "ready", m => + { + m.Run(Arg.Any()) + .TransitionsTo("done") + .Callback((int value) => captured = value) + .Returns(123); + }); + + var result = mock.Object.Run(456); + + await Assert.That(result).IsEqualTo(123); + await Assert.That(captured).IsEqualTo(456); + await Assert.That(MockRegistry.GetEngine(mock).CurrentState).IsEqualTo("done"); + } + + [Test] + public async Task TransitionsTo_Does_Not_Advance_State_When_Behavior_Throws() + { + var mock = IStatefulCommand.Mock(); + var captured = 0; + Mock.SetState(mock, "ready"); + + Mock.InState(mock, "ready", m => + { + m.Crash(Arg.Any()) + .TransitionsTo("failed") + .Callback((int value) => captured = value) + .Throws(new InvalidOperationException("boom")); + }); + + var exception = Assert.Throws(() => mock.Object.Crash(7)); + + await Assert.That(exception.Message).IsEqualTo("boom"); + await Assert.That(captured).IsEqualTo(7); + await Assert.That(MockRegistry.GetEngine(mock).CurrentState).IsEqualTo("ready"); + } + + [Test] + public async Task TransitionsTo_Works_Without_Required_State() + { + var mock = IStatefulCommand.Mock(); + + mock.Ping().TransitionsTo("pinged"); + + mock.Object.Ping(); + + await Assert.That(MockRegistry.GetEngine(mock).CurrentState).IsEqualTo("pinged"); + } + + private sealed class CustomSideEffectBehavior(Action callback) : IBehavior, ISideEffectBehavior + { + public object? Execute(object?[] arguments) + { + callback(); + return null; + } + } + + private sealed class CustomReturnBehavior(T value) : IBehavior, IArgumentFreeBehavior + { + public object? Execute(object?[] arguments) => Execute(); + + public object? Execute() => value; + } + +} diff --git a/TUnit.Mocks.Tests/Issue5972Tests.cs b/TUnit.Mocks.Tests/Issue5972Tests.cs new file mode 100644 index 0000000000..668da23834 --- /dev/null +++ b/TUnit.Mocks.Tests/Issue5972Tests.cs @@ -0,0 +1,28 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +public interface IIssue5972Service +{ + int GetInt(int i); +} + +public class Issue5972Tests +{ + [Test] + public async Task Callback_Chained_Before_Returns_Still_Returns_Configured_Value() + { + var mock = IIssue5972Service.Mock(); + var capture = 0; + + mock.GetInt(Any()) + .Callback((int i) => capture = i) + .Returns(42); + + var result = mock.Object.GetInt(123); + + await Assert.That(result).IsEqualTo(42); + await Assert.That(capture).IsEqualTo(123); + } +} diff --git a/TUnit.Mocks.Tests/MixedBehaviorRegressionTests.cs b/TUnit.Mocks.Tests/MixedBehaviorRegressionTests.cs new file mode 100644 index 0000000000..b75a2c3c92 --- /dev/null +++ b/TUnit.Mocks.Tests/MixedBehaviorRegressionTests.cs @@ -0,0 +1,243 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +public class MixedBehaviorRegressionTests +{ + [Test] + public async Task Multiple_Callbacks_And_Return_Run_In_One_Repeating_Behavior() + { + var observations = new List(); + var mock = ICalculator.Mock(); + + mock.Add(Any(), Any()) + .Callback((int a, int b) => observations.Add($"typed:{a}:{b}")) + .Callback(() => observations.Add("plain")) + .Returns(42); + + await Assert.That(mock.Object.Add(1, 2)).IsEqualTo(42); + await Assert.That(mock.Object.Add(3, 4)).IsEqualTo(42); + + await Assert.That(observations).Count().IsEqualTo(4); + await Assert.That(observations[0]).IsEqualTo("typed:1:2"); + await Assert.That(observations[1]).IsEqualTo("plain"); + await Assert.That(observations[2]).IsEqualTo("typed:3:4"); + await Assert.That(observations[3]).IsEqualTo("plain"); + } + + [Test] + public async Task Return_Callback_And_Computed_Return_Use_Last_Return_While_Running_Side_Effects() + { + var captured = new List(); + var tailCallbackCount = 0; + var mock = ICalculator.Mock(); + + mock.Add(Any(), Any()) + .Returns(1) + .Callback((int a, int b) => captured.Add(a + b)) + .Returns((int a, int b) => a * b) + .Callback(() => tailCallbackCount++); + + await Assert.That(mock.Object.Add(3, 4)).IsEqualTo(12); + + await Assert.That(captured).Count().IsEqualTo(1); + await Assert.That(captured[0]).IsEqualTo(7); + await Assert.That(tailCallbackCount).IsEqualTo(1); + } + + [Test] + public async Task Callback_Then_Throws_Then_Returns_Sequences_Composite_Steps() + { + var observations = new List(); + var mock = ICalculator.Mock(); + + mock.Add(Any(), Any()) + .Callback((int a, int b) => observations.Add($"first:{a + b}")) + .Throws(new InvalidOperationException("first step failed")) + .Then() + .Callback((int a, int b) => observations.Add($"second:{a + b}")) + .Returns(99); + + var exception = Assert.Throws(() => mock.Object.Add(1, 2)); + await Assert.That(exception.Message).IsEqualTo("first step failed"); + await Assert.That(observations).Count().IsEqualTo(1); + await Assert.That(observations[0]).IsEqualTo("first:3"); + + await Assert.That(mock.Object.Add(4, 5)).IsEqualTo(99); + await Assert.That(observations).Count().IsEqualTo(2); + await Assert.That(observations[1]).IsEqualTo("second:9"); + } + + [Test] + public async Task Throw_Before_Callback_Does_Not_Run_Later_Callback_In_Same_Behavior() + { + var laterCallbackRan = false; + var mock = ICalculator.Mock(); + + mock.Add(Any(), Any()) + .Throws(new InvalidOperationException("boom")) + .Callback(() => laterCallbackRan = true) + .Then() + .Returns(7); + + var exception = Assert.Throws(() => mock.Object.Add(1, 1)); + await Assert.That(exception.Message).IsEqualTo("boom"); + await Assert.That(laterCallbackRan).IsFalse(); + await Assert.That(mock.Object.Add(1, 1)).IsEqualTo(7); + } + + [Test] + public async Task Explicit_Then_Separates_Mixed_Steps_And_Last_Step_Repeats() + { + var observations = new List(); + var mock = IGreeter.Mock(); + + mock.Greet(Any()) + .Callback((string name) => observations.Add($"first:{name}")) + .Returns((string name) => name.ToUpperInvariant()) + .Then() + .Callback((string name) => observations.Add($"second:{name}")) + .Returns("fallback"); + + await Assert.That(mock.Object.Greet("alice")).IsEqualTo("ALICE"); + await Assert.That(mock.Object.Greet("bob")).IsEqualTo("fallback"); + await Assert.That(mock.Object.Greet("cara")).IsEqualTo("fallback"); + + await Assert.That(observations).Count().IsEqualTo(3); + await Assert.That(observations[0]).IsEqualTo("first:alice"); + await Assert.That(observations[1]).IsEqualTo("second:bob"); + await Assert.That(observations[2]).IsEqualTo("second:cara"); + } + + [Test] + public async Task Separate_Setups_Stay_Independent_When_Each_Setup_Has_Mixed_Behaviors() + { + var observations = new List(); + var mock = ICalculator.Mock(); + + mock.Add(Any(), Any()) + .Callback(() => observations.Add("first setup")) + .Returns(1); + + mock.Add(Any(), Any()) + .Callback(() => observations.Add("second setup")) + .Returns(2); + + await Assert.That(mock.Object.Add(10, 20)).IsEqualTo(2); + + await Assert.That(observations).Count().IsEqualTo(1); + await Assert.That(observations[0]).IsEqualTo("second setup"); + } + + [Test] + public async Task Async_Task_Callback_And_ReturnsAsync_Run_In_One_Repeating_Behavior() + { + var capturedKeys = new List(); + var mock = IAsyncService.Mock(); + + mock.GetNameAsync(Any()) + .Callback((string key) => capturedKeys.Add(key)) + .ReturnsAsync((string key) => Task.FromResult($"value:{key}")); + + await Assert.That(await mock.Object.GetNameAsync("one")).IsEqualTo("value:one"); + await Assert.That(await mock.Object.GetNameAsync("two")).IsEqualTo("value:two"); + + await Assert.That(capturedKeys).Count().IsEqualTo(2); + await Assert.That(capturedKeys[0]).IsEqualTo("one"); + await Assert.That(capturedKeys[1]).IsEqualTo("two"); + } + + [Test] + public async Task Async_ValueTask_Mixed_Sequence_Preserves_Callbacks_And_Returns() + { + var capturedInputs = new List(); + var mock = IAsyncService.Mock(); + + mock.ComputeValueTaskAsync(Any()) + .Callback((int input) => capturedInputs.Add(input)) + .ReturnsAsync((int input) => new ValueTask(input + 1)) + .Then() + .Callback((int input) => capturedInputs.Add(input * 10)) + .Returns(500); + + await Assert.That(await mock.Object.ComputeValueTaskAsync(4)).IsEqualTo(5); + await Assert.That(await mock.Object.ComputeValueTaskAsync(4)).IsEqualTo(500); + await Assert.That(await mock.Object.ComputeValueTaskAsync(2)).IsEqualTo(500); + + await Assert.That(capturedInputs).Count().IsEqualTo(3); + await Assert.That(capturedInputs[0]).IsEqualTo(4); + await Assert.That(capturedInputs[1]).IsEqualTo(40); + await Assert.That(capturedInputs[2]).IsEqualTo(20); + } + + [Test] + public async Task Out_Parameter_Callback_Return_And_Assignment_All_Apply() + { + string? capturedKey = null; + var mock = IDictionary.Mock(); + + mock.TryGet(Any()) + .Callback((string key) => capturedKey = key) + .Returns(true) + .SetsOutValue("configured"); + + var result = mock.Object.TryGet("key", out var value); + + await Assert.That(result).IsTrue(); + await Assert.That(value).IsEqualTo("configured"); + await Assert.That(capturedKey).IsEqualTo("key"); + } + + [Test] + public async Task Event_Raise_Callback_And_Return_All_Apply() + { + var ids = new List(); + string? raisedStatus = null; + var mock = IProcessService.Mock(); + mock.Object.StatusChanged += (_, status) => raisedStatus = status; + + mock.Process(Any()) + .Callback((int id) => ids.Add(id)) + .Returns(true) + .RaisesStatusChanged("done"); + + var result = mock.Object.Process(123); + + await Assert.That(result).IsTrue(); + await Assert.That(ids).Count().IsEqualTo(1); + await Assert.That(ids[0]).IsEqualTo(123); + await Assert.That(raisedStatus).IsEqualTo("done"); + } + + [Test] + public async Task State_Transition_Callback_And_Returns_All_Apply() + { + var observations = new List(); + var mock = IConnection.Mock(); + Mock.SetState(mock, "disconnected"); + + Mock.InState(mock, "disconnected", m => + { + m.Connect() + .Callback(() => observations.Add("connect")) + .TransitionsTo("connected"); + }); + + Mock.InState(mock, "connected", m => + { + m.GetStatus() + .TransitionsTo("checked") + .Callback(() => observations.Add("status")) + .Returns("ONLINE"); + }); + + mock.Object.Connect(); + + await Assert.That(mock.Object.GetStatus()).IsEqualTo("ONLINE"); + await Assert.That(observations).Count().IsEqualTo(2); + await Assert.That(observations[0]).IsEqualTo("connect"); + await Assert.That(observations[1]).IsEqualTo("status"); + await Assert.That(MockRegistry.GetEngine(mock).CurrentState).IsEqualTo("checked"); + } +} diff --git a/TUnit.Mocks/MockEngine.Typed.cs b/TUnit.Mocks/MockEngine.Typed.cs index 3e775fced5..2895530979 100644 --- a/TUnit.Mocks/MockEngine.Typed.cs +++ b/TUnit.Mocks/MockEngine.Typed.cs @@ -9,49 +9,37 @@ namespace TUnit.Mocks; public sealed partial class MockEngine where T : class { - // ── ARITY COUPLING (1–8) ────────────────────────────────────────────── - // Behavior execution helpers — check IArgumentFreeBehavior, then - // ITypedBehavior, then fall back to store.ToArray(). - // If you add an arity (e.g. T9), you MUST also update: - // - ITypedBehavior and TypedCallbackBehavior in TypedCallbackBehavior.cs - // - Callback in MethodSetupBuilder.cs and VoidMethodSetupBuilder.cs - // - MaxTypedParams in MockMembersBuilder.cs (source generator) - // ────────────────────────────────────────────────────────────────────── - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1) : b is ITypedBehavior tb ? tb.Execute(a1) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2) : b is ITypedBehavior tb ? tb.Execute(a1, a2) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3, T4 a4) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3, a4) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3, T4 a4, T5 a5) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3, a4, a5) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3, T4 a4, T5 a5, T6 a6) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3, a4, a5, a6) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3, T4 a4, T5 a5, T6 a6, T7 a7) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6, a7) : b.Execute(store.ToArray()); + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3, a4, a5, a6, a7) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6, a7) : b.Execute(store.ToArray()); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ExecuteBehavior(IBehavior b, in ArgumentStore store, T1 a1, T2 a2, T3 a3, T4 a4, T5 a5, T6 a6, T7 a7, T8 a8) - => b is IArgumentFreeBehavior af ? af.Execute() : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6, a7, a8) : b.Execute(store.ToArray()); - // ────────────────────────────────────────────────────────────────────── - // Arity 1 - // ────────────────────────────────────────────────────────────────────── + => b is IArgumentFreeBehavior af ? af.Execute() : b is ICompositeBehavior cb ? cb.Execute(a1, a2, a3, a4, a5, a6, a7, a8) : b is ITypedBehavior tb ? tb.Execute(a1, a2, a3, a4, a5, a6, a7, a8) : b.Execute(store.ToArray()); [EditorBrowsable(EditorBrowsableState.Never)] public void HandleCall(int memberId, string memberName, T1 arg1) diff --git a/TUnit.Mocks/MockEngine.cs b/TUnit.Mocks/MockEngine.cs index 9f49c599a7..b5497a6953 100644 --- a/TUnit.Mocks/MockEngine.cs +++ b/TUnit.Mocks/MockEngine.cs @@ -826,6 +826,14 @@ private void ApplyMatchedSetup(MethodSetup? matchedSetup) OutRefContext.Set(matchedSetup?.OutRefAssignments); if (matchedSetup is not null) { + if (matchedSetup.TransitionTarget is not null) + { + lock (Lock) + { + _currentState = matchedSetup.TransitionTarget; + } + } + RaiseEventsForSetup(matchedSetup); } } @@ -938,10 +946,6 @@ private void RebuildStaleSnapshots() { setup.IncrementInvokeCount(); setup.ApplyCaptures(args); - if (setup.TransitionTarget is not null) - { - _currentState = setup.TransitionTarget; - } return (true, setup.GetNextBehavior(), setup); } } diff --git a/TUnit.Mocks/Setup/Behaviors/CallbackBehavior.cs b/TUnit.Mocks/Setup/Behaviors/CallbackBehavior.cs index 98ceac185e..7fffbe053b 100644 --- a/TUnit.Mocks/Setup/Behaviors/CallbackBehavior.cs +++ b/TUnit.Mocks/Setup/Behaviors/CallbackBehavior.cs @@ -1,6 +1,6 @@ namespace TUnit.Mocks.Setup.Behaviors; -internal sealed class CallbackBehavior : IBehavior, IArgumentFreeBehavior +internal sealed class CallbackBehavior : IBehavior, IArgumentFreeBehavior, ISideEffectBehavior { private readonly Action _callback; diff --git a/TUnit.Mocks/Setup/Behaviors/CallbackWithArgsBehavior.cs b/TUnit.Mocks/Setup/Behaviors/CallbackWithArgsBehavior.cs index 11ddd683c4..ec3d3b77f8 100644 --- a/TUnit.Mocks/Setup/Behaviors/CallbackWithArgsBehavior.cs +++ b/TUnit.Mocks/Setup/Behaviors/CallbackWithArgsBehavior.cs @@ -10,7 +10,7 @@ namespace TUnit.Mocks.Setup.Behaviors; /// Future optimization: implement ITypedBehavior<T...> to avoid store.ToArray() when args are needed. /// [EditorBrowsable(EditorBrowsableState.Never)] -public sealed class CallbackWithArgsBehavior : IBehavior +public sealed class CallbackWithArgsBehavior : IBehavior, ISideEffectBehavior { private readonly Action _callback; diff --git a/TUnit.Mocks/Setup/Behaviors/CompositeBehavior.cs b/TUnit.Mocks/Setup/Behaviors/CompositeBehavior.cs new file mode 100644 index 0000000000..ec07aba87a --- /dev/null +++ b/TUnit.Mocks/Setup/Behaviors/CompositeBehavior.cs @@ -0,0 +1,279 @@ +namespace TUnit.Mocks.Setup.Behaviors; + +// Arity coupling: if a new typed mock arity is added, update this interface, +// CompositeBehavior.Execute, MockEngine.Typed.ExecuteBehavior, +// TypedCallbackBehavior, both setup builders, and the source generator's +// MaxTypedParams together. +internal interface ICompositeBehavior : IBehavior +{ + object? Execute(T1 arg1); + + object? Execute(T1 arg1, T2 arg2); + + object? Execute(T1 arg1, T2 arg2, T3 arg3); + + object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4); + + object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5); + + object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6); + + object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7); + + object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8); +} + +internal sealed class CompositeBehavior : ICompositeBehavior +{ + private readonly Lock _lock = new(); + private readonly List _behaviors; + private IBehavior[]? _snapshot; + + private CompositeBehavior(IBehavior first, IBehavior second) + { + _behaviors = [first, second]; + } + + public static IBehavior Combine(IBehavior first, IBehavior second) + { + if (first is CompositeBehavior composite) + { + composite.Add(second); + return composite; + } + + return new CompositeBehavior(first, second); + } + + private void Add(IBehavior behavior) + { + lock (_lock) + { + _behaviors.Add(behavior); + Volatile.Write(ref _snapshot, null); + } + } + + private IBehavior[] GetSnapshot() + { + if (Volatile.Read(ref _snapshot) is { } snapshot) + { + return snapshot; + } + + lock (_lock) + { + var currentSnapshot = _snapshot; + if (currentSnapshot is null) + { + currentSnapshot = _behaviors.ToArray(); + Volatile.Write(ref _snapshot, currentSnapshot); + } + + return currentSnapshot; + } + } + + public object? Execute(object?[] arguments) + { + object? result = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior is IArgumentFreeBehavior argumentFree + ? argumentFree.Execute() + : behavior.Execute(arguments); + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1), + _ => behavior.Execute(arguments ??= [arg1]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2), + _ => behavior.Execute(arguments ??= [arg1, arg2]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3, arg4), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3, arg4]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3, arg4, arg5), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3, arg4, arg5]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3, arg4, arg5, arg6), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3, arg4, arg5, arg6]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3, arg4, arg5, arg6, arg7), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3, arg4, arg5, arg6, arg7]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } + + public object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) + { + object? result = null; + object?[]? arguments = null; + + foreach (var behavior in GetSnapshot()) + { + var behaviorResult = behavior switch + { + IArgumentFreeBehavior argumentFree => argumentFree.Execute(), + ITypedBehavior typed => typed.Execute(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8), + _ => behavior.Execute(arguments ??= [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8]) + }; + + if (behavior is not ISideEffectBehavior) + { + result = behaviorResult; + } + } + + return result; + } +} diff --git a/TUnit.Mocks/Setup/Behaviors/IBehavior.cs b/TUnit.Mocks/Setup/Behaviors/IBehavior.cs index 15552d50f0..9aaa7db29c 100644 --- a/TUnit.Mocks/Setup/Behaviors/IBehavior.cs +++ b/TUnit.Mocks/Setup/Behaviors/IBehavior.cs @@ -23,3 +23,9 @@ public interface IArgumentFreeBehavior { object? Execute(); } + +/// +/// Marker interface for behaviors that perform side effects but do not provide the mock return value. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface ISideEffectBehavior; diff --git a/TUnit.Mocks/Setup/Behaviors/TypedCallbackBehavior.cs b/TUnit.Mocks/Setup/Behaviors/TypedCallbackBehavior.cs index e09c54e759..1986a121ce 100644 --- a/TUnit.Mocks/Setup/Behaviors/TypedCallbackBehavior.cs +++ b/TUnit.Mocks/Setup/Behaviors/TypedCallbackBehavior.cs @@ -1,65 +1,60 @@ +using System.ComponentModel; + namespace TUnit.Mocks.Setup.Behaviors; /// -/// Typed behavior dispatch interfaces. ExecuteBehavior checks for these after IArgumentFreeBehavior, -/// enabling behaviors to receive typed arguments without boxing into object?[]. -/// Currently implemented by TypedCallbackBehavior; extensible for future typed return behaviors -/// (e.g. a TypedComputedReturnBehavior that takes Func<T1, TReturn>). +/// Internal typed behavior dispatch interfaces. ExecuteBehavior checks for these after IArgumentFreeBehavior, +/// enabling built-in behaviors to receive typed arguments without boxing into object?[]. /// -/// -/// Intentionally internal: the typed dispatch is tightly coupled to the source generator's -/// knowledge of parameter arity — only generated code knows the concrete types at compile time. -/// is public because any behavior can opt in without type knowledge. -/// +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7); } +[EditorBrowsable(EditorBrowsableState.Never)] internal interface ITypedBehavior { object? Execute(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8); } -// ── ARITY COUPLING (1–8) ────────────────────────────────────────────── -// If you add an arity (e.g. T9), you MUST also update: -// - ITypedBehavior above -// - ExecuteBehavior in MockEngine.Typed.cs -// - Callback in MethodSetupBuilder.cs and VoidMethodSetupBuilder.cs -// - MaxTypedParams in MockMembersBuilder.cs (source generator) -// ────────────────────────────────────────────────────────────────────── - -internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -71,7 +66,7 @@ internal sealed class TypedCallbackBehavior(Action callback) : IBehavior public object? Execute(T1 arg1) { callback(arg1); return null; } } -internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -83,7 +78,7 @@ internal sealed class TypedCallbackBehavior(Action callback) : I public object? Execute(T1 arg1, T2 arg2) { callback(arg1, arg2); return null; } } -internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -95,7 +90,7 @@ internal sealed class TypedCallbackBehavior(Action callb public object? Execute(T1 arg1, T2 arg2, T3 arg3) { callback(arg1, arg2, arg3); return null; } } -internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -107,7 +102,7 @@ internal sealed class TypedCallbackBehavior(Action(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -119,7 +114,7 @@ internal sealed class TypedCallbackBehavior(Action(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -131,7 +126,7 @@ internal sealed class TypedCallbackBehavior(Action(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { @@ -143,7 +138,7 @@ internal sealed class TypedCallbackBehavior(Action(Action callback) : IBehavior, ITypedBehavior +internal sealed class TypedCallbackBehavior(Action callback) : IBehavior, ITypedBehavior, ISideEffectBehavior { public object? Execute(object?[] arguments) { diff --git a/TUnit.Mocks/Setup/ISetupChain.cs b/TUnit.Mocks/Setup/ISetupChain.cs index 837746a31a..faa65e7fcc 100644 --- a/TUnit.Mocks/Setup/ISetupChain.cs +++ b/TUnit.Mocks/Setup/ISetupChain.cs @@ -3,23 +3,17 @@ namespace TUnit.Mocks.Setup; /// /// Setup chain for sequential behavior configuration on methods with a return value. /// -public interface ISetupChain +public interface ISetupChain : IMethodSetup { /// Chain the next call's behavior. IMethodSetup Then(); - - /// Transition to the named state after this behavior executes. - ISetupChain TransitionsTo(string stateName); } /// /// Setup chain for void method sequential behavior configuration. /// -public interface IVoidSetupChain +public interface IVoidSetupChain : IVoidMethodSetup { /// Chain the next call's behavior. IVoidMethodSetup Then(); - - /// Transition to the named state after this behavior executes. - IVoidSetupChain TransitionsTo(string stateName); } diff --git a/TUnit.Mocks/Setup/MethodSetup.cs b/TUnit.Mocks/Setup/MethodSetup.cs index e803f31451..54dc147bca 100644 --- a/TUnit.Mocks/Setup/MethodSetup.cs +++ b/TUnit.Mocks/Setup/MethodSetup.cs @@ -31,12 +31,14 @@ private sealed class RareState /// Fast path for the common single-behavior case. Avoids list + lock on read. /// /// Not declared volatile to avoid CS0420 with Interlocked.CompareExchange. - /// All accesses use Volatile.Read/Write or Interlocked ops for correct ordering. + /// Readers use Volatile.Read where they avoid taking BehaviorLock. /// private IBehavior? _singleBehavior; /// See for volatility rationale. private List? _behaviors; private int _callIndex; + /// True while chained behaviors are being composed into the current invocation step. + private bool _hasOpenBehaviorStep; public int MemberId { get; } @@ -117,41 +119,57 @@ private Lock BehaviorLock public void AddBehavior(IBehavior behavior) { - // Lock-free fast path: CAS for the common single-behavior case. - // Avoids allocating the Lock object entirely when only one behavior is registered. - // Safety: Interlocked.CompareExchange is a full memory barrier, so a successful CAS - // on _singleBehavior is visible to any subsequent Volatile.Read in AddBehaviorSlow. - if (Volatile.Read(ref _behaviors) is null - && Interlocked.CompareExchange(ref _singleBehavior, behavior, null) is null) + // Setup mutation is not a hot path; one lock keeps sequential steps and composed + // behavior steps consistent without the previous single-behavior CAS fast path. + lock (BehaviorLock) { - // Double-check: if a concurrent AddBehaviorSlow promoted to list between our - // _behaviors read and the CAS, fall through to slow path to reconcile. - if (Volatile.Read(ref _behaviors) is null) + if (!_hasOpenBehaviorStep) { + AddBehaviorStep(behavior); + _hasOpenBehaviorStep = true; return; } - } - AddBehaviorSlow(behavior); + AppendToCurrentBehaviorStep(behavior); + } } - [MethodImpl(MethodImplOptions.NoInlining)] - private void AddBehaviorSlow(IBehavior behavior) + public void Then() { lock (BehaviorLock) { - // Promote to list on second behavior. Write _behaviors before clearing - // _singleBehavior so that a lock-free reader in GetNextBehavior that sees - // _singleBehavior == null will also see the updated _behaviors reference. - if (Volatile.Read(ref _behaviors) is null) - { - var current = Volatile.Read(ref _singleBehavior); - Volatile.Write(ref _behaviors, current is not null ? [current] : []); - } + _hasOpenBehaviorStep = false; + } + } + + private void AddBehaviorStep(IBehavior behavior) + { + if (_singleBehavior is null && _behaviors is null) + { + _singleBehavior = behavior; + return; + } + + var behaviors = _behaviors; + if (behaviors is null) + { + behaviors = [_singleBehavior!]; + _behaviors = behaviors; + _singleBehavior = null; + } - _behaviors!.Add(behavior); - Volatile.Write(ref _singleBehavior, null); + behaviors.Add(behavior); + } + + private void AppendToCurrentBehaviorStep(IBehavior behavior) + { + if (_behaviors is { Count: > 0 } behaviors) + { + behaviors[^1] = CompositeBehavior.Combine(behaviors[^1], behavior); + return; } + + _singleBehavior = CompositeBehavior.Combine(_singleBehavior!, behavior); } public bool Matches(object?[] actualArgs) diff --git a/TUnit.Mocks/Setup/MethodSetupBuilder.cs b/TUnit.Mocks/Setup/MethodSetupBuilder.cs index 17fe4bb4ab..e924a84617 100644 --- a/TUnit.Mocks/Setup/MethodSetupBuilder.cs +++ b/TUnit.Mocks/Setup/MethodSetupBuilder.cs @@ -31,9 +31,14 @@ public ISetupChain Returns(Func factory) public ISetupChain ReturnsSequentially(params TReturn[] values) { - foreach (var value in values) + for (var i = 0; i < values.Length; i++) { - _setup.AddBehavior(new ReturnBehavior(value)); + if (i > 0) + { + _setup.Then(); + } + + _setup.AddBehavior(new ReturnBehavior(values[i])); } return this; @@ -179,5 +184,9 @@ public ISetupChain ReturnsRaw(Func factory) return this; } - public IMethodSetup Then() => this; + public IMethodSetup Then() + { + _setup.Then(); + return this; + } } diff --git a/TUnit.Mocks/Setup/VoidMethodSetupBuilder.cs b/TUnit.Mocks/Setup/VoidMethodSetupBuilder.cs index 05ddc34169..8aa6dda8bb 100644 --- a/TUnit.Mocks/Setup/VoidMethodSetupBuilder.cs +++ b/TUnit.Mocks/Setup/VoidMethodSetupBuilder.cs @@ -149,5 +149,9 @@ public IVoidSetupChain ReturnsRaw(Func factory) return this; } - public IVoidMethodSetup Then() => this; + public IVoidMethodSetup Then() + { + _setup.Then(); + return this; + } } diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs index 0f5e6d672f..84f20d3da2 100644 --- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs +++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs @@ -194,35 +194,62 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess() } [Test] - public async Task Receiver_DrainAsync_WaitsForLatePostBeforeReturning() + public async Task Receiver_DrainAsync_WaitsForInFlightForwardBeforeReturning() { - await using var receiver = new OtlpReceiver(); - receiver.Start(); + using var upstreamListener = new HttpListener(); + var upstreamPort = LoopbackHttpListenerFactory.FindFreePort(); + upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/"); + upstreamListener.Start(); + + var upstreamReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseUpstream = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var listenerTask = Task.Run(async () => + { + var ctx = await upstreamListener.GetContextAsync(); + upstreamReceived.TrySetResult(); + + await releaseUpstream.Task; + + ctx.Response.StatusCode = 200; + ctx.Response.ContentLength64 = 0; + ctx.Response.Close(); + }); - // Simulate a SUT exporter that flushes a couple hundred ms after the test logic - // would finish — without DrainAsync, AspireFixture would tear down the AppHost - // and the late POST would fail / be dropped. - var latePostCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var latePost = Task.Run(async () => + try { - await Task.Delay(TimeSpan.FromMilliseconds(200)); + await using var receiver = new OtlpReceiver($"http://127.0.0.1:{upstreamPort}"); + receiver.Start(); + using var client = new HttpClient(); using var content = new ByteArrayContent(Array.Empty()); - await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); - latePostCompleted.SetResult(true); - }); + content.Headers.ContentType = new("application/x-protobuf"); + var response = await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await upstreamReceived.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var drainTask = receiver.DrainAsync(TimeSpan.FromSeconds(3)); + var completedBeforeUpstreamReleased = await Task.WhenAny( + drainTask, + Task.Delay(TimeSpan.FromMilliseconds(750))) == drainTask; + + await Assert.That(completedBeforeUpstreamReleased).IsFalse(); - await receiver.DrainAsync(TimeSpan.FromSeconds(3)); + releaseUpstream.TrySetResult(); - // Asserts the real contract directly: drain returned only after the late POST - // had been issued and acknowledged. Wall-clock floors (the previous approach) - // failed on macOS arm64 because Task.Delay scheduling jitter could push the - // late POST inside the synchronous prefix of DrainAsync, making the elapsed - // measurement misrepresent the actual invariant the drain must hold. - await Assert.That(latePostCompleted.Task.IsCompleted).IsTrue(); - await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(1); + await drainTask.WaitAsync(TimeSpan.FromSeconds(2)); + await listenerTask.WaitAsync(TimeSpan.FromSeconds(2)); - await latePost; + await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(1); + await Assert.That(receiver.Diagnostics.UpstreamForwardSuccess).IsEqualTo(1); + await Assert.That(receiver.Diagnostics.UpstreamForwardFailures).IsEqualTo(0); + } + finally + { + releaseUpstream.TrySetResult(); + upstreamListener.Stop(); + try { await listenerTask; } catch { } + } } [Test] diff --git a/docs/docs/writing-tests/mocking/setup.md b/docs/docs/writing-tests/mocking/setup.md index 60a7fc5db9..10406ec55c 100644 --- a/docs/docs/writing-tests/mocking/setup.md +++ b/docs/docs/writing-tests/mocking/setup.md @@ -63,6 +63,19 @@ mock.GetValue(Any()) // 1st: "first", 2nd: "second", 3rd+: "third" (last value repeats) ``` +Chained setup behaviors without `.Then()` run together as a single invocation step. +When multiple return behaviors are chained in one step, the last return wins: + +```csharp +mock.GetValue(Any()) + .Returns("first") + .Returns("second"); +// Every call returns "second" +``` + +Use `.Then()` or `.ReturnsSequentially(...)` when each return should apply to a +different invocation. + ### Void Methods Void methods support `Callback` and `Throws` (but not `Returns`):