Skip to content

Commit 0afbe71

Browse files
committed
fix(mocks): thread type args through ordered verification + immutable type-arg arrays
Round 7 review fixes: - OrderedVerification now records and matches a generic expectation's type arguments, so VerifyInOrder discriminates Greet<Class1>() from Greet<Class2>() like unordered verification does. Failure messages include the type arguments. Regression tests added. - Type-argument arrays are now ImmutableArray<Type> end-to-end (TypeArguments.Of<T>.Value, MethodSetup, CallRecord, engine dispatch, verification), closing the mutable-shared-array corruption risk while keeping array-speed indexed access on the hot matching path. The arity-5+ generator fallback emits ImmutableArray.Create. All affected signatures were introduced on this branch, so no released surface changes. - CreateVerification routing deduped into MockCallVerification.Create, shared by MockMethodCall and VoidMockMethodCall. - Documented why the typed FindMatchingSetup<T1..T8> family omits a TypeArgumentsMatch check: typed dispatch never carries call-side type arguments, so the check would be a constant true (non-generic setups have none; virtual/partial generic methods use the documented degradation path).
1 parent 44f3ea1 commit 0afbe71

13 files changed

Lines changed: 163 additions & 89 deletions

TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode
744744
}
745745

746746
var (isTyped, typeArgs, argsList) = GetTypedDispatchInfo(method);
747-
// Generic methods always dispatch through the object?[] + Type[] fallback so their concrete
747+
// Generic methods always dispatch through the object?[] + type-arguments fallback so their concrete
748748
// type arguments reach the engine (typed dispatch can't carry them). Force the fallback path
749749
// here so argsArray is materialized; the emit helpers then select the type-arg overloads.
750750
if (method.IsGenericMethod)
@@ -1415,9 +1415,9 @@ private static string EmitHandleCallWithReturn(bool isTyped, string? typeArgs, s
14151415
}
14161416

14171417
/// <summary>
1418-
/// Emits the type-argument array for a generic method. For the common 1–2 type-parameter cases it
1418+
/// Emits the type-argument array for a generic method. For the common 1–4 type-parameter cases it
14191419
/// references the per-closed-type cache (<c>TypeArguments.Of&lt;T&gt;.Value</c>) to avoid a per-call
1420-
/// allocation; higher arities fall back to a fresh <c>new global::System.Type[] { typeof(T), ... }</c>.
1420+
/// allocation; higher arities fall back to a per-call <c>ImmutableArray.Create(...)</c>.
14211421
/// </summary>
14221422
internal static string TypeArgumentsArrayLiteral(MockMemberModel method)
14231423
{
@@ -1426,7 +1426,7 @@ internal static string TypeArgumentsArrayLiteral(MockMemberModel method)
14261426
{
14271427
return $"global::TUnit.Mocks.TypeArguments.Of<{string.Join(", ", typeParams.Select(tp => tp.Name))}>.Value";
14281428
}
1429-
return $"new global::System.Type[] {{ {string.Join(", ", typeParams.Select(tp => $"typeof({tp.Name})"))} }}";
1429+
return $"global::System.Collections.Immutable.ImmutableArray.Create<global::System.Type>({string.Join(", ", typeParams.Select(tp => $"typeof({tp.Name})"))})";
14301430
}
14311431

14321432
/// <summary>Emits a TryHandleCall condition, choosing typed or fallback path.</summary>

TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,8 @@ private static void EmitReturnConstruction(CodeWriter writer, MockMemberModel me
971971
{
972972
// For a generic method, pass the configured type arguments so the setup/verification can
973973
// discriminate calls by type argument. Non-generic methods omit the argument (overload with
974-
// the trailing Type[] is not selected). The typed wrapper is never generated for generic methods.
974+
// the trailing type-arguments parameter is not selected). The typed wrapper is never generated
975+
// for generic methods.
975976
var typeArgs = method.IsGenericMethod
976977
? $", {MockImplBuilder.TypeArgumentsArrayLiteral(method)}"
977978
: "";

TUnit.Mocks.Tests/GenericTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,46 @@ public async Task Generic_Method_Struct_Constraint_Distinguished_By_Type_Argumen
335335
await Assert.That(sut.Describe<int>()).IsEqualTo("int");
336336
await Assert.That(sut.Describe<long>()).IsEqualTo("long");
337337
}
338+
339+
[Test]
340+
public void Ordered_Verification_Discriminates_By_Type_Argument()
341+
{
342+
var mock = IGenericGreeter.Mock();
343+
IGenericGreeter greeter = mock.Object;
344+
345+
greeter.Greet<Class1>();
346+
greeter.Greet<Class2>();
347+
348+
// Passes only if each expectation matches the call with its own type argument.
349+
Mock.VerifyInOrder(() =>
350+
{
351+
mock.Greet<Class1>().WasCalled();
352+
mock.Greet<Class2>().WasCalled();
353+
});
354+
}
355+
356+
[Test]
357+
public async Task Ordered_Verification_Fails_When_Type_Arguments_Out_Of_Order()
358+
{
359+
var mock = IGenericGreeter.Mock();
360+
IGenericGreeter greeter = mock.Object;
361+
362+
greeter.Greet<Class1>();
363+
greeter.Greet<Class2>();
364+
365+
// Reversed type-argument order must fail. Before type arguments were threaded through
366+
// ordered verification, both expectations matched both calls and this passed regardless
367+
// of which type was actually called first.
368+
var ex = Assert.Throws<MockVerificationException>(() =>
369+
Mock.VerifyInOrder(() =>
370+
{
371+
mock.Greet<Class2>().WasCalled();
372+
mock.Greet<Class1>().WasCalled();
373+
}));
374+
375+
await Assert.That(ex!.Message).Contains("Ordered verification failed");
376+
// The failure message names the expected calls with their type arguments.
377+
await Assert.That(ex.Message).Contains("Greet<Class1>");
378+
await Assert.That(ex.Message).Contains("Greet<Class2>");
379+
}
338380
}

TUnit.Mocks/ITypeArgumentVerificationFactory.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Immutable;
12
using TUnit.Mocks.Arguments;
23
using TUnit.Mocks.Verification;
34

@@ -17,5 +18,20 @@ internal interface ITypeArgumentVerificationFactory
1718
/// Creates a call verification builder that filters recorded calls by the supplied type arguments
1819
/// (concrete types or <see cref="AnyType"/>/<see cref="AnyValueType"/> wildcards).
1920
/// </summary>
20-
ICallVerification CreateVerification(int memberId, string memberName, IArgumentMatcher[] matchers, Type[]? typeArguments);
21+
ICallVerification CreateVerification(int memberId, string memberName, IArgumentMatcher[] matchers, ImmutableArray<Type> typeArguments);
22+
}
23+
24+
/// <summary>
25+
/// Shared verification-builder routing for <see cref="MockMethodCall{TReturn}"/> and
26+
/// <see cref="VoidMockMethodCall"/>: non-generic calls use the public engine surface unchanged;
27+
/// generic calls route through <see cref="ITypeArgumentVerificationFactory"/> (always implemented by
28+
/// MockEngine). A custom <see cref="IMockEngineAccess"/> implementation falls back to the public
29+
/// surface, losing type-argument filtering but never throwing.
30+
/// </summary>
31+
internal static class MockCallVerification
32+
{
33+
public static ICallVerification Create(IMockEngineAccess engine, int memberId, string memberName, IArgumentMatcher[] matchers, ImmutableArray<Type> typeArguments)
34+
=> !typeArguments.IsDefault && engine is ITypeArgumentVerificationFactory typeArgFactory
35+
? typeArgFactory.CreateVerification(memberId, memberName, matchers, typeArguments)
36+
: engine.CreateVerification(memberId, memberName, matchers);
2137
}

TUnit.Mocks/MockEngine.cs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using TUnit.Mocks.Setup.Behaviors;
55
using TUnit.Mocks.Verification;
66
using System.Collections.Concurrent;
7+
using System.Collections.Immutable;
78
using System.Runtime.CompilerServices;
89
using System.Threading;
910
using System.ComponentModel;
@@ -224,24 +225,24 @@ private void EnsureSetupArrayCapacity(int memberId)
224225
ICallVerification IMockEngineAccess.CreateVerification(int memberId, string memberName, IArgumentMatcher[] matchers)
225226
=> new CallVerificationBuilder<T>(this, memberId, memberName, matchers);
226227

227-
ICallVerification ITypeArgumentVerificationFactory.CreateVerification(int memberId, string memberName, IArgumentMatcher[] matchers, Type[]? typeArguments)
228+
ICallVerification ITypeArgumentVerificationFactory.CreateVerification(int memberId, string memberName, IArgumentMatcher[] matchers, ImmutableArray<Type> typeArguments)
228229
=> new CallVerificationBuilder<T>(this, memberId, memberName, matchers, typeArguments);
229230

230231
/// <summary>
231232
/// Handles a void method call. Records the call and executes matching setup behavior.
232233
/// </summary>
233234
public void HandleCall(int memberId, string memberName, object?[] args)
234-
=> HandleCallCore(memberId, memberName, args, null);
235+
=> HandleCallCore(memberId, memberName, args, default);
235236

236237
/// <summary>
237238
/// Handles a void generic-method call. <paramref name="typeArguments"/> are the concrete closed
238239
/// type arguments, used to discriminate setups and recorded calls by type argument.
239240
/// </summary>
240241
[EditorBrowsable(EditorBrowsableState.Never)]
241-
public void HandleCall(int memberId, string memberName, object?[] args, Type[] typeArguments)
242+
public void HandleCall(int memberId, string memberName, object?[] args, ImmutableArray<Type> typeArguments)
242243
=> HandleCallCore(memberId, memberName, args, typeArguments);
243244

244-
private void HandleCallCore(int memberId, string memberName, object?[] args, Type[]? typeArguments)
245+
private void HandleCallCore(int memberId, string memberName, object?[] args, ImmutableArray<Type> typeArguments)
245246
{
246247
RawReturnContext.Clear();
247248
var callRecord = RecordCall(memberId, memberName, args, typeArguments);
@@ -296,29 +297,29 @@ private void HandleCallCore(int memberId, string memberName, object?[] args, Typ
296297
/// or returns default/throws for strict mode.
297298
/// </summary>
298299
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue)
299-
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, null, null);
300+
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, null, default);
300301

301302
[EditorBrowsable(EditorBrowsableState.Never)]
302303
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Func<MockBehavior, IMock>? autoMockFactory)
303-
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, autoMockFactory, null);
304+
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, autoMockFactory, default);
304305

305306
/// <summary>
306307
/// Handles a generic-method call with a return value. <paramref name="typeArguments"/> are the
307308
/// concrete closed type arguments, used to discriminate setups and recorded calls by type argument.
308309
/// </summary>
309310
[EditorBrowsable(EditorBrowsableState.Never)]
310-
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Type[] typeArguments)
311+
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, ImmutableArray<Type> typeArguments)
311312
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, null, typeArguments);
312313

313314
/// <summary>
314315
/// Generic-method call with a return value that may also auto-mock its return type. Combines the
315316
/// <paramref name="autoMockFactory"/> and <paramref name="typeArguments"/> paths.
316317
/// </summary>
317318
[EditorBrowsable(EditorBrowsableState.Never)]
318-
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Func<MockBehavior, IMock>? autoMockFactory, Type[] typeArguments)
319+
public TReturn HandleCallWithReturn<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Func<MockBehavior, IMock>? autoMockFactory, ImmutableArray<Type> typeArguments)
319320
=> HandleCallWithReturnCore(memberId, memberName, args, defaultValue, autoMockFactory, typeArguments);
320321

321-
private TReturn HandleCallWithReturnCore<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Func<MockBehavior, IMock>? autoMockFactory, Type[]? typeArguments)
322+
private TReturn HandleCallWithReturnCore<TReturn>(int memberId, string memberName, object?[] args, TReturn defaultValue, Func<MockBehavior, IMock>? autoMockFactory, ImmutableArray<Type> typeArguments)
322323
{
323324
RawReturnContext.Clear();
324325
var callRecord = RecordCall(memberId, memberName, args, typeArguments);
@@ -785,7 +786,7 @@ private void CollectCallRecords(List<CallRecord> target, Func<CallRecord, bool>?
785786
private CallRecord RecordCall(int memberId, string memberName, object?[] args)
786787
=> StoreCallRecord(new CallRecord(memberId, memberName, args, MockCallSequence.Next()));
787788

788-
private CallRecord RecordCall(int memberId, string memberName, object?[] args, Type[]? typeArguments)
789+
private CallRecord RecordCall(int memberId, string memberName, object?[] args, ImmutableArray<Type> typeArguments)
789790
=> StoreCallRecord(new CallRecord(memberId, memberName, args, MockCallSequence.Next(), typeArguments));
790791

791792
private CallRecord RecordCall(int memberId, string memberName, IArgumentStore store)
@@ -916,9 +917,9 @@ private void RebuildStaleSnapshots()
916917
// Adding a defaulted third parameter here would let the generic overload bind instead,
917918
// silently treating the args array as a single typed argument.
918919
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetup(int memberId, object?[] args)
919-
=> FindMatchingSetup(memberId, args, (Type[]?)null);
920+
=> FindMatchingSetup(memberId, args, default(ImmutableArray<Type>));
920921

921-
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetup(int memberId, object?[] args, Type[]? typeArguments)
922+
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetup(int memberId, object?[] args, ImmutableArray<Type> typeArguments)
922923
{
923924
// Rebuild snapshots if setup phase just ended (batches all ToArray work into one pass)
924925
if (_hasStaleSetups)
@@ -963,7 +964,7 @@ private void RebuildStaleSnapshots()
963964

964965
// FindMatchingSetupLocked has no generic sibling, so a defaulted parameter is safe here
965966
// (unlike FindMatchingSetup, which competes with FindMatchingSetup<T1>).
966-
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetupLocked(int memberId, object?[] args, Type[]? typeArguments = null)
967+
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetupLocked(int memberId, object?[] args, ImmutableArray<Type> typeArguments = default)
967968
{
968969
lock (Lock)
969970
{
@@ -999,6 +1000,14 @@ private void RebuildStaleSnapshots()
9991000
return (false, null, null);
10001001
}
10011002

1003+
// The typed FindMatchingSetup<T1..T8> family deliberately omits a TypeArgumentsMatch check.
1004+
// Typed dispatch never carries call-side type arguments, and per TypeArgumentMatching.Matches a
1005+
// default call side matches any setup — so the check would be a constant `true`. Two paths land
1006+
// here: non-generic methods (their setups never carry type arguments), and virtual/partial/wrap
1007+
// generic methods, which use the documented graceful-degradation path where setups are matched by
1008+
// arguments only, regardless of configured type arguments. Interface generic methods never use
1009+
// typed dispatch (see MockMembersBuilder.ShouldGenerateTypedWrapper) and are fully discriminated
1010+
// via the object?[] overloads above.
10021011
private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetup<T1>(int memberId, T1 arg1)
10031012
{
10041013
if (_hasStaleSetups) RebuildStaleSnapshots();

TUnit.Mocks/MockMethodCall.cs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Immutable;
12
using System.ComponentModel;
23
using TUnit.Mocks.Arguments;
34
using TUnit.Mocks.Setup;
@@ -20,7 +21,7 @@ public sealed class MockMethodCall<TReturn> : IMethodSetup<TReturn>, ISetupChain
2021
private readonly int _memberId;
2122
private readonly string _memberName;
2223
private readonly IArgumentMatcher[] _matchers;
23-
private readonly Type[]? _typeArguments;
24+
private readonly ImmutableArray<Type> _typeArguments;
2425
private MethodSetupBuilder<TReturn>? _builder;
2526
private bool _builderInitialized;
2627
private object? _builderLock;
@@ -29,12 +30,12 @@ public sealed class MockMethodCall<TReturn> : IMethodSetup<TReturn>, ISetupChain
2930
// public binary signature for backward compatibility.
3031
[EditorBrowsable(EditorBrowsableState.Never)]
3132
public MockMethodCall(IMockEngineAccess engine, int memberId, string memberName, IArgumentMatcher[] matchers)
32-
: this(engine, memberId, memberName, matchers, null)
33+
: this(engine, memberId, memberName, matchers, default)
3334
{
3435
}
3536

3637
[EditorBrowsable(EditorBrowsableState.Never)]
37-
public MockMethodCall(IMockEngineAccess engine, int memberId, string memberName, IArgumentMatcher[] matchers, Type[]? typeArguments)
38+
public MockMethodCall(IMockEngineAccess engine, int memberId, string memberName, IArgumentMatcher[] matchers, ImmutableArray<Type> typeArguments)
3839
{
3940
_engine = engine;
4041
_memberId = memberId;
@@ -119,14 +120,8 @@ public IMethodSetup<TReturn> Then()
119120

120121
// ICallVerification implementation
121122

122-
// Non-generic calls use the public engine surface unchanged; generic calls route through the
123-
// internal type-argument-aware factory (always implemented by MockEngine). A custom
124-
// IMockEngineAccess implementation falls back to the public surface, losing type-argument
125-
// filtering but never throwing.
126123
private ICallVerification CreateVerification()
127-
=> _typeArguments is not null && _engine is ITypeArgumentVerificationFactory typeArgFactory
128-
? typeArgFactory.CreateVerification(_memberId, _memberName, _matchers, _typeArguments)
129-
: _engine.CreateVerification(_memberId, _memberName, _matchers);
124+
=> MockCallVerification.Create(_engine, _memberId, _memberName, _matchers, _typeArguments);
130125

131126
public void WasCalled(Times times)
132127
{

TUnit.Mocks/Setup/MethodSetup.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Immutable;
12
using System.ComponentModel;
23
using System.Runtime.CompilerServices;
34
using TUnit.Mocks.Arguments;
@@ -80,19 +81,19 @@ public string? TransitionTarget
8081

8182
/// <summary>
8283
/// For a generic method, the configured type arguments (concrete types, or
83-
/// <see cref="Arguments.AnyType"/>/<see cref="Arguments.AnyValueType"/> wildcards). Null for a
84+
/// <see cref="Arguments.AnyType"/>/<see cref="Arguments.AnyValueType"/> wildcards). Default for a
8485
/// non-generic method, in which case the setup matches regardless of call-site type arguments.
8586
/// </summary>
8687
[EditorBrowsable(EditorBrowsableState.Never)]
87-
public Type[]? TypeArguments { get; }
88+
public ImmutableArray<Type> TypeArguments { get; }
8889

8990
public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName = "")
90-
: this(memberId, matchers, memberName, null)
91+
: this(memberId, matchers, memberName, default)
9192
{
9293
}
9394

9495
[EditorBrowsable(EditorBrowsableState.Never)]
95-
public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName, Type[]? typeArguments)
96+
public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName, ImmutableArray<Type> typeArguments)
9697
{
9798
MemberId = memberId;
9899
_matchers = matchers;
@@ -102,10 +103,10 @@ public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName,
102103

103104
/// <summary>
104105
/// True if this setup's configured type arguments match a call made with
105-
/// <paramref name="callTypeArgs"/>. Non-generic setups (null <see cref="TypeArguments"/>) match any call.
106+
/// <paramref name="callTypeArgs"/>. Non-generic setups (default <see cref="TypeArguments"/>) match any call.
106107
/// </summary>
107108
[EditorBrowsable(EditorBrowsableState.Never)]
108-
public bool TypeArgumentsMatch(Type[]? callTypeArgs)
109+
public bool TypeArgumentsMatch(ImmutableArray<Type> callTypeArgs)
109110
=> TypeArgumentMatching.Matches(TypeArguments, callTypeArgs);
110111

111112
private RareState EnsureRareState()

0 commit comments

Comments
 (0)