From ab1d2d74cf187e3fa4237f55936cddc3da93c2c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:13:47 +0100 Subject: [PATCH 1/4] fix(mocks): resolve CS0111/CS0411 errors when mocking Azure SDK clients - Generic methods now emit explicit type arguments on base calls so T can be inferred when it only appears in the return type (e.g. TableClient.GetEntity). - Extension method generator now keeps `out` parameters when stripping them would produce duplicate signatures (e.g. BlobClient.GenerateSasUri). Fixes #5434 --- Directory.Packages.props | 2 + .../Builders/MockImplBuilder.cs | 12 +- .../Builders/MockMembersBuilder.cs | 107 +++++++++++++++--- TUnit.Mocks.Tests/Issue5434Tests.cs | 25 ++++ TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj | 2 + 5 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 TUnit.Mocks.Tests/Issue5434Tests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e1aea90142..6552ed4c44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,8 @@ + + diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index c617603e18..b156f96e66 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -569,7 +569,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"base.{method.Name}({argPassList});"); + writer.AppendLine($"base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsVoid && method.IsAsync) { @@ -589,7 +589,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsAsync) { @@ -619,7 +619,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsRefStructReturn) { @@ -638,7 +638,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else if (method.IsReturnTypeStaticAbstractInterface) { @@ -650,7 +650,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return __result;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } else { @@ -662,7 +662,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("return __result;"); writer.DecreaseIndent(); writer.AppendLine("}"); - writer.AppendLine($"return base.{method.Name}({argPassList});"); + writer.AppendLine($"return base.{method.Name}{GetTypeParameterList(method)}({argPassList});"); } } diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 465a211023..01b2fbd0ca 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -41,12 +41,19 @@ public static string Build(MockTypeModel model) { bool firstMember = true; + // Pre-compute which methods need their `out` parameters kept in the extension + // signature to avoid CS0111 collisions. A method needs disambiguation when + // some other method on the model shares the same name AND the same + // matchable-parameter signature (i.e. parameters excluding out). + var needsOutDisambiguation = ComputeOutDisambiguationSet(model.Methods); + // Methods foreach (var method in model.Methods) { if (!firstMember) writer.AppendLine(); firstMember = false; - GenerateMemberMethod(writer, method, model, safeName); + GenerateMemberMethod(writer, method, model, safeName, + keepOutParams: needsOutDisambiguation.Contains(method.MemberId)); } // Properties -- extension properties via C# 14 extension blocks @@ -84,6 +91,15 @@ public static string Build(MockTypeModel model) return writer.ToString(); } + private static void EmitOutParamDefaults(CodeWriter writer, MockMemberModel method, bool keepOutParams) + { + if (!keepOutParams) return; + foreach (var op in method.Parameters.Where(p => p.Direction == ParameterDirection.Out)) + { + writer.AppendLine($"{op.Name} = default!;"); + } + } + private static bool ShouldGenerateTypedWrapper(MockMemberModel method, bool hasEvents) { if (method.IsGenericMethod) return false; @@ -558,25 +574,60 @@ private static string CastArg(MockParameterModel p, int index) return $"({p.FullyQualifiedType})args[{index}]{bang}"; } - private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) + private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool keepOutParams) { if (method.HasRefStructParams) { writer.AppendLine("#if NET9_0_OR_GREATER"); - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true, keepOutParams); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true, keepOutParams); writer.AppendLine("#else"); - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); writer.AppendLine("#endif"); } else { - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); } } + private static HashSet ComputeOutDisambiguationSet(EquatableArray methods) + { + // Group methods by (name, matchable-parameter signature). Any group with >1 entry + // contains methods that would otherwise emit colliding extension overloads — flag + // every member of such a group whose original method has out parameters. + var result = new HashSet(); + var byKey = new Dictionary>(System.StringComparer.Ordinal); + foreach (var m in methods) + { + var matchable = string.Join(",", m.Parameters + .Where(p => p.Direction != ParameterDirection.Out) + .Select(p => p.FullyQualifiedType)); + var typeArity = m.TypeParameters.Length; + var key = $"{m.Name}`{typeArity}({matchable})"; + if (!byKey.TryGetValue(key, out var list)) + { + list = new List(); + byKey[key] = list; + } + list.Add(m); + } + foreach (var group in byKey.Values) + { + if (group.Count < 2) continue; + foreach (var m in group) + { + if (m.Parameters.Any(p => p.Direction == ParameterDirection.Out)) + { + result.Add(m.MemberId); + } + } + } + return result; + } + private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) GetReturnTypeInfo( MockMemberModel method, MockTypeModel model, string safeName) { @@ -608,11 +659,11 @@ private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) return (useTypedWrapper, returnType, setupReturnType); } - private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs) + private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs, bool keepOutParams) { var (useTypedWrapper, returnType, setupReturnType) = GetReturnTypeInfo(method, model, safeName); - var paramList = GetArgParameterList(method, includeRefStructArgs); + var paramList = GetArgParameterList(method, includeRefStructArgs, keepOutParams); var typeParams = MockImplBuilder.GetTypeParameterList(method); var constraints = MockImplBuilder.GetConstraintClauses(method); @@ -633,6 +684,8 @@ private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { + EmitOutParamDefaults(writer, method, keepOutParams); + // Build matchers array var matchableParams = includeRefStructArgs ? method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList() @@ -684,7 +737,7 @@ private static List GetFuncEligibleParamIndices(MockMemberModel method) } private static void EmitFuncOverloads(CodeWriter writer, MockMemberModel method, MockTypeModel model, - string safeName, bool includeRefStructArgs) + string safeName, bool includeRefStructArgs, bool keepOutParams) { var eligible = GetFuncEligibleParamIndices(method); if (eligible.Count == 0 || eligible.Count > MaxFuncOverloadParams) return; @@ -693,12 +746,12 @@ private static void EmitFuncOverloads(CodeWriter writer, MockMemberModel method, for (int mask = 1; mask <= totalMasks; mask++) { writer.AppendLine(); - EmitSingleFuncOverload(writer, method, model, safeName, eligible, mask, includeRefStructArgs); + EmitSingleFuncOverload(writer, method, model, safeName, eligible, mask, includeRefStructArgs, keepOutParams); } } private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel method, MockTypeModel model, - string safeName, List eligibleIndices, int funcMask, bool includeRefStructArgs) + string safeName, List eligibleIndices, int funcMask, bool includeRefStructArgs, bool keepOutParams) { // Determine which parameter indices use Func var funcIndices = new HashSet(); @@ -717,7 +770,15 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me for (int i = 0; i < method.Parameters.Length; i++) { var p = method.Parameters[i]; - if (p.Direction == ParameterDirection.Out) continue; + if (p.Direction == ParameterDirection.Out) + { + // Keep out params only when needed to disambiguate colliding overloads. + if (keepOutParams) + { + paramParts.Add($"out {p.FullyQualifiedType} {p.Name}"); + } + continue; + } if (funcIndices.Contains(i)) { @@ -757,6 +818,8 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { + EmitOutParamDefaults(writer, method, keepOutParams); + // Convert Func params to Arg via implicit conversion foreach (var idx in funcIndices.OrderBy(i => i)) { @@ -871,12 +934,24 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod } } - private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs) + private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs, bool keepOutParams) { var parts = new List(); foreach (var p in method.Parameters) { - if (p.Direction == ParameterDirection.Out) continue; + if (p.Direction == ParameterDirection.Out) + { + // Normally out params are omitted from the extension signature so callers + // don't have to write `out _`. But when another overload of this method has + // the same matchable-parameter signature (e.g. GenerateSasUri(perms, expires) + // vs GenerateSasUri(perms, expires, out string)), we MUST keep the out param + // in the signature, otherwise CS0111 fires on the generated extensions. + if (keepOutParams) + { + parts.Add($"out {p.FullyQualifiedType} {p.Name}"); + } + continue; + } if (p.IsRefStruct) { if (includeRefStructArgs) diff --git a/TUnit.Mocks.Tests/Issue5434Tests.cs b/TUnit.Mocks.Tests/Issue5434Tests.cs new file mode 100644 index 0000000000..4db964e659 --- /dev/null +++ b/TUnit.Mocks.Tests/Issue5434Tests.cs @@ -0,0 +1,25 @@ +using Azure.Data.Tables; +using Azure.Storage.Blobs; + +namespace TUnit.Mocks.Tests; + +// Reproduction for https://github.com/thomhurst/TUnit/issues/5434 +// BlobClient: CS0111 duplicate GenerateSasUri / GenerateUserDelegationSasUri members in generated extensions. +// TableClient: CS0411 type inference failures for generic methods (GetEntity, GetEntityAsync, +// GetEntityIfExists, GetEntityIfExistsAsync, Query, QueryAsync) in generated impl factory. +public class Issue5434Tests +{ + [Test] + public void Can_Mock_BlobClient() + { + var mock = Mock.Of(MockBehavior.Strict); + _ = mock.Object; + } + + [Test] + public void Can_Mock_TableClient() + { + var mock = Mock.Of(MockBehavior.Strict); + _ = mock.Object; + } +} diff --git a/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj b/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj index e16011c09f..2b7f98ea61 100644 --- a/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj +++ b/TUnit.Mocks.Tests/TUnit.Mocks.Tests.csproj @@ -9,6 +9,8 @@ + + From 2a828f9d4f210640c7a6b2c373aa89a0f44880da Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:26:47 +0100 Subject: [PATCH 2/4] refactor(mocks): address PR review feedback for #5434 fix - Move `keepOutParams` from a 5-signature threaded bool into a `KeepOutParamsInExtensionSignature` property on MockMemberModel. The disambiguation decision is now baked into the model in Build() via `ApplyOutDisambiguation`, so emit code reads the flag from the member rather than receiving it as a parameter. - Include parameter direction in the disambiguation collision key so overloads differing only by ref vs by-value are not falsely flagged. - Document why `default!` is used for out-param initialization in EmitOutParamDefaults (the extension never observes the value). - Add a comment in EmitSingleFuncOverload noting that disambiguated overloads require `out _` at the call site. - Add behavioral tests that actually invoke the disambiguated GenerateSasUri(..., out _) overload and the generic GetEntity override path, so a future regression in the emitted bodies is caught. --- .../Builders/MockMembersBuilder.cs | 80 ++++++++++++------- .../Models/MockMemberModel.cs | 12 +++ TUnit.Mocks.Tests/Issue5434Tests.cs | 26 +++++- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 01b2fbd0ca..873c4e50a7 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -45,15 +45,14 @@ public static string Build(MockTypeModel model) // signature to avoid CS0111 collisions. A method needs disambiguation when // some other method on the model shares the same name AND the same // matchable-parameter signature (i.e. parameters excluding out). - var needsOutDisambiguation = ComputeOutDisambiguationSet(model.Methods); + var methodsWithDisambiguation = ApplyOutDisambiguation(model.Methods); // Methods - foreach (var method in model.Methods) + foreach (var method in methodsWithDisambiguation) { if (!firstMember) writer.AppendLine(); firstMember = false; - GenerateMemberMethod(writer, method, model, safeName, - keepOutParams: needsOutDisambiguation.Contains(method.MemberId)); + GenerateMemberMethod(writer, method, model, safeName); } // Properties -- extension properties via C# 14 extension blocks @@ -91,9 +90,14 @@ public static string Build(MockTypeModel model) return writer.ToString(); } - private static void EmitOutParamDefaults(CodeWriter writer, MockMemberModel method, bool keepOutParams) + private static void EmitOutParamDefaults(CodeWriter writer, MockMemberModel method) { - if (!keepOutParams) return; + if (!method.KeepOutParamsInExtensionSignature) return; + // Out params are assigned `default!` because the extension method never actually invokes + // the mocked method — it only *configures* a setup. The out value is never observed by + // caller code: this setup-configuration call returns a MockMethodCall, not the mocked + // result. For reference types this suppresses the CS8625 nullable warning on an unused + // assignment that exists solely to satisfy the `out` contract. foreach (var op in method.Parameters.Where(p => p.Direction == ParameterDirection.Out)) { writer.AppendLine($"{op.Name} = default!;"); @@ -574,37 +578,43 @@ private static string CastArg(MockParameterModel p, int index) return $"({p.FullyQualifiedType})args[{index}]{bang}"; } - private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool keepOutParams) + private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) { if (method.HasRefStructParams) { writer.AppendLine("#if NET9_0_OR_GREATER"); - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true, keepOutParams); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true, keepOutParams); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true); writer.AppendLine("#else"); - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false); writer.AppendLine("#endif"); } else { - EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); - EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, keepOutParams); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false); } } - private static HashSet ComputeOutDisambiguationSet(EquatableArray methods) + /// + /// Returns the input methods, with + /// set to true on any method whose generated extension method would otherwise collide with + /// another overload. Methods are grouped by (name, type-arity, matchable-parameter signature); + /// any group with more than one entry causes its out-bearing members to be flagged. + /// The matchable-parameter signature includes parameter direction (ref/in/by-value) so that + /// overloads differing only by direction (e.g. Foo(int) vs Foo(ref int)) are not + /// treated as collisions. + /// + private static IEnumerable ApplyOutDisambiguation(EquatableArray methods) { - // Group methods by (name, matchable-parameter signature). Any group with >1 entry - // contains methods that would otherwise emit colliding extension overloads — flag - // every member of such a group whose original method has out parameters. - var result = new HashSet(); + var flagged = new HashSet(); var byKey = new Dictionary>(System.StringComparer.Ordinal); foreach (var m in methods) { var matchable = string.Join(",", m.Parameters .Where(p => p.Direction != ParameterDirection.Out) - .Select(p => p.FullyQualifiedType)); + .Select(p => $"{p.Direction}:{p.FullyQualifiedType}")); var typeArity = m.TypeParameters.Length; var key = $"{m.Name}`{typeArity}({matchable})"; if (!byKey.TryGetValue(key, out var list)) @@ -621,11 +631,18 @@ private static HashSet ComputeOutDisambiguationSet(EquatableArray p.Direction == ParameterDirection.Out)) { - result.Add(m.MemberId); + flagged.Add(m.MemberId); } } } - return result; + + if (flagged.Count == 0) + { + return methods; + } + return methods.Select(m => flagged.Contains(m.MemberId) + ? m with { KeepOutParamsInExtensionSignature = true } + : m); } private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) GetReturnTypeInfo( @@ -659,11 +676,11 @@ private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType) return (useTypedWrapper, returnType, setupReturnType); } - private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs, bool keepOutParams) + private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs) { var (useTypedWrapper, returnType, setupReturnType) = GetReturnTypeInfo(method, model, safeName); - var paramList = GetArgParameterList(method, includeRefStructArgs, keepOutParams); + var paramList = GetArgParameterList(method, includeRefStructArgs); var typeParams = MockImplBuilder.GetTypeParameterList(method); var constraints = MockImplBuilder.GetConstraintClauses(method); @@ -684,7 +701,7 @@ private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { - EmitOutParamDefaults(writer, method, keepOutParams); + EmitOutParamDefaults(writer, method); // Build matchers array var matchableParams = includeRefStructArgs @@ -737,7 +754,7 @@ private static List GetFuncEligibleParamIndices(MockMemberModel method) } private static void EmitFuncOverloads(CodeWriter writer, MockMemberModel method, MockTypeModel model, - string safeName, bool includeRefStructArgs, bool keepOutParams) + string safeName, bool includeRefStructArgs) { var eligible = GetFuncEligibleParamIndices(method); if (eligible.Count == 0 || eligible.Count > MaxFuncOverloadParams) return; @@ -746,12 +763,12 @@ private static void EmitFuncOverloads(CodeWriter writer, MockMemberModel method, for (int mask = 1; mask <= totalMasks; mask++) { writer.AppendLine(); - EmitSingleFuncOverload(writer, method, model, safeName, eligible, mask, includeRefStructArgs, keepOutParams); + EmitSingleFuncOverload(writer, method, model, safeName, eligible, mask, includeRefStructArgs); } } private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel method, MockTypeModel model, - string safeName, List eligibleIndices, int funcMask, bool includeRefStructArgs, bool keepOutParams) + string safeName, List eligibleIndices, int funcMask, bool includeRefStructArgs) { // Determine which parameter indices use Func var funcIndices = new HashSet(); @@ -773,7 +790,8 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me if (p.Direction == ParameterDirection.Out) { // Keep out params only when needed to disambiguate colliding overloads. - if (keepOutParams) + // Callers of the disambiguated overload must write `out _` at the call site. + if (method.KeepOutParamsInExtensionSignature) { paramParts.Add($"out {p.FullyQualifiedType} {p.Name}"); } @@ -818,7 +836,7 @@ private static void EmitSingleFuncOverload(CodeWriter writer, MockMemberModel me using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { - EmitOutParamDefaults(writer, method, keepOutParams); + EmitOutParamDefaults(writer, method); // Convert Func params to Arg via implicit conversion foreach (var idx in funcIndices.OrderBy(i => i)) @@ -934,7 +952,7 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod } } - private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs, bool keepOutParams) + private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs) { var parts = new List(); foreach (var p in method.Parameters) @@ -946,7 +964,7 @@ private static string GetArgParameterList(MockMemberModel method, bool includeRe // the same matchable-parameter signature (e.g. GenerateSasUri(perms, expires) // vs GenerateSasUri(perms, expires, out string)), we MUST keep the out param // in the signature, otherwise CS0111 fires on the generated extensions. - if (keepOutParams) + if (method.KeepOutParamsInExtensionSignature) { parts.Add($"out {p.FullyQualifiedType} {p.Name}"); } diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index e123ef3fe6..e923f97e06 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -45,6 +45,16 @@ internal sealed record MockMemberModel : IEquatable /// public bool IsReturnTypeStaticAbstractInterface { get; init; } + /// + /// True when this method's out parameters must be kept in the generated extension + /// method signature to avoid CS0111 collisions with a sibling overload that shares the same + /// matchable-parameter signature (e.g. BlobClient.GenerateSasUri(perms, expires) vs + /// GenerateSasUri(perms, expires, out string stringToSign)). When true, callers must + /// pass out _ at the call site for the disambiguated overload. + /// Computed across the full method set in . + /// + public bool KeepOutParamsInExtensionSignature { get; init; } + /// /// For methods returning ReadOnlySpan<T> or Span<T>, the fully qualified element type. /// Null for non-span return types. Used to support configurable span return values via array conversion. @@ -86,6 +96,7 @@ public bool Equals(MockMemberModel? other) && IsRefStructReturn == other.IsRefStructReturn && IsStaticAbstract == other.IsStaticAbstract && IsReturnTypeStaticAbstractInterface == other.IsReturnTypeStaticAbstractInterface + && KeepOutParamsInExtensionSignature == other.KeepOutParamsInExtensionSignature && SpanReturnElementType == other.SpanReturnElementType; } @@ -100,6 +111,7 @@ public override int GetHashCode() hash = hash * 31 + Parameters.GetHashCode(); hash = hash * 31 + IsStaticAbstract.GetHashCode(); hash = hash * 31 + IsReturnTypeStaticAbstractInterface.GetHashCode(); + hash = hash * 31 + KeepOutParamsInExtensionSignature.GetHashCode(); hash = hash * 31 + (ExplicitInterfaceName?.GetHashCode() ?? 0); hash = hash * 31 + (DeclaringInterfaceName?.GetHashCode() ?? 0); return hash; diff --git a/TUnit.Mocks.Tests/Issue5434Tests.cs b/TUnit.Mocks.Tests/Issue5434Tests.cs index 4db964e659..0c60ce255d 100644 --- a/TUnit.Mocks.Tests/Issue5434Tests.cs +++ b/TUnit.Mocks.Tests/Issue5434Tests.cs @@ -1,9 +1,10 @@ using Azure.Data.Tables; using Azure.Storage.Blobs; +using Azure.Storage.Sas; namespace TUnit.Mocks.Tests; -// Reproduction for https://github.com/thomhurst/TUnit/issues/5434 +// Reproduction and regression tests for https://github.com/thomhurst/TUnit/issues/5434 // BlobClient: CS0111 duplicate GenerateSasUri / GenerateUserDelegationSasUri members in generated extensions. // TableClient: CS0411 type inference failures for generic methods (GetEntity, GetEntityAsync, // GetEntityIfExists, GetEntityIfExistsAsync, Query, QueryAsync) in generated impl factory. @@ -22,4 +23,27 @@ public void Can_Mock_TableClient() var mock = Mock.Of(MockBehavior.Strict); _ = mock.Object; } + + // Exercises the disambiguated overload that keeps `out string stringToSign` in its + // signature to distinguish it from GenerateSasUri(perms, expires). This call would not + // compile if `keepOutParams` disambiguation regressed. + [Test] + public void Can_Configure_BlobClient_GenerateSasUri_OutOverload() + { + var mock = Mock.Of(MockBehavior.Loose); + _ = mock.GenerateSasUri(Arg.Any(), Arg.Any(), out _); + } + + // Exercises the generic-return-type override path. This would not compile if + // the base.GetEntity(...) call in the generated override was missing the type argument. + [Test] + public void Can_Configure_TableClient_GetEntity_Generic() + { + var mock = Mock.Of(MockBehavior.Loose); + _ = mock.GetEntity( + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } } From c6e63380c1c4ae72f42aa3533cb8fa276bf942d8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:33:00 +0100 Subject: [PATCH 3/4] refactor(mocks): exclude transient KeepOutParamsInExtensionSignature from model equality The flag is computed ephemerally in Build() via ApplyOutDisambiguation and is never true on models flowing through the incremental pipeline, so it should not participate in model identity. --- TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index e923f97e06..78a38556ce 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -51,7 +51,10 @@ internal sealed record MockMemberModel : IEquatable /// matchable-parameter signature (e.g. BlobClient.GenerateSasUri(perms, expires) vs /// GenerateSasUri(perms, expires, out string stringToSign)). When true, callers must /// pass out _ at the call site for the disambiguated overload. - /// Computed across the full method set in . + /// Computed transiently inside by examining + /// the full method set; never set on models flowing through the incremental pipeline. + /// Excluded from / because + /// it is a derived per-build flag, not part of model identity. /// public bool KeepOutParamsInExtensionSignature { get; init; } @@ -96,7 +99,6 @@ public bool Equals(MockMemberModel? other) && IsRefStructReturn == other.IsRefStructReturn && IsStaticAbstract == other.IsStaticAbstract && IsReturnTypeStaticAbstractInterface == other.IsReturnTypeStaticAbstractInterface - && KeepOutParamsInExtensionSignature == other.KeepOutParamsInExtensionSignature && SpanReturnElementType == other.SpanReturnElementType; } @@ -111,7 +113,6 @@ public override int GetHashCode() hash = hash * 31 + Parameters.GetHashCode(); hash = hash * 31 + IsStaticAbstract.GetHashCode(); hash = hash * 31 + IsReturnTypeStaticAbstractInterface.GetHashCode(); - hash = hash * 31 + KeepOutParamsInExtensionSignature.GetHashCode(); hash = hash * 31 + (ExplicitInterfaceName?.GetHashCode() ?? 0); hash = hash * 31 + (DeclaringInterfaceName?.GetHashCode() ?? 0); return hash; From 7d0875477bb6eae23f307a136fe934768fcbdd38 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:55:17 +0100 Subject: [PATCH 4/4] test(mocks): update snapshot for Partial_Mock_With_Generic_Constrained_Virtual_Methods --- ...ck_With_Generic_Constrained_Virtual_Methods.verified.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt index 23de6dc4a0..f985714752 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Partial_Mock_With_Generic_Constrained_Virtual_Methods.verified.txt @@ -21,7 +21,7 @@ namespace TUnit.Mocks.Generated { return __result; } - return base.GetById(id); + return base.GetById(id); } public override void Save(T entity) @@ -30,7 +30,7 @@ namespace TUnit.Mocks.Generated { return; } - base.Save(entity); + base.Save(entity); } public override TResult Transform(TInput input) @@ -39,7 +39,7 @@ namespace TUnit.Mocks.Generated { return __result; } - return base.Transform(input); + return base.Transform(input); } public override global::System.Collections.Generic.IEnumerable GetAll()