From b4b7d73eefe32f8afced1a25c2dc56e33ebbb52b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 21:04:54 +0100 Subject: [PATCH 1/4] perf: avoid LINQ chains in dependency equality and method matching Replace Select/SequenceEqual iterator allocations with manual loops in TestDependency.Matches and MethodDataSourceAttribute method resolution. Both are hot paths (dependency graph build, per-candidate data source resolution). Behavior is preserved, including null-argument matching semantics. Closes #6061 --- .../TestData/MethodDataSourceAttribute.cs | 23 ++++++++++++++++++- TUnit.Core/TestDependency.cs | 11 +++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 5254d656cf..86ede2c503 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -182,7 +182,7 @@ public MethodDataSourceAttribute( // Try to find a method first var methodInfo = targetType.GetMethods(BindingFlags).SingleOrDefault(x => x.Name == MethodNameProvidingDataSource - && x.GetParameters().Select(p => p.ParameterType).SequenceEqual(Arguments.Select(a => a?.GetType()))) + && ParameterTypesMatchArguments(x.GetParameters(), Arguments)) ?? targetType.GetMethod(MethodNameProvidingDataSource, BindingFlags); object? methodResult; @@ -324,6 +324,27 @@ public MethodDataSourceAttribute( } } + // Matches a candidate method's parameter types against the runtime types of the supplied arguments. + // A null argument has no runtime type, so it never matches a (non-null) parameter type, preserving + // the previous SequenceEqual(parameterTypes, arguments.Select(a => a?.GetType())) behavior. + private static bool ParameterTypesMatchArguments(ParameterInfo[] parameters, object?[] arguments) + { + if (parameters.Length != arguments.Length) + { + return false; + } + + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].ParameterType != arguments[i]?.GetType()) + { + return false; + } + } + + return true; + } + // MethodInfo.Invoke does not auto-fill optional parameters the way a C# call site does. private static object?[] BuildInvokeArgs(MethodInfo methodInfo, object?[] suppliedArguments) { diff --git a/TUnit.Core/TestDependency.cs b/TUnit.Core/TestDependency.cs index 8d26869f14..e4b908f5dd 100644 --- a/TUnit.Core/TestDependency.cs +++ b/TUnit.Core/TestDependency.cs @@ -142,11 +142,18 @@ public bool Matches(TestMetadata test, TestMetadata? dependentTest = null) { var testParams = test.MethodMetadata.Parameters; - if (testParams.Length != MethodParameters.Length - || !testParams.Select(x => x.Type).SequenceEqual(MethodParameters!)) + if (testParams.Length != MethodParameters.Length) { return false; } + + for (var i = 0; i < testParams.Length; i++) + { + if (testParams[i].Type != MethodParameters[i]) + { + return false; + } + } } } From 3694516a34104f68b2988c78bb4c66d50b85a77a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 22:13:21 +0100 Subject: [PATCH 2/4] fix: use IsAssignableFrom for parameter-type matching in MethodDataSource Exact-type comparison missed derived-type/interface arguments (e.g. List arg vs IList param). IsAssignableFrom mirrors what the call accepts. --- .../Attributes/TestData/MethodDataSourceAttribute.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 86ede2c503..b785f9744a 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -325,8 +325,9 @@ public MethodDataSourceAttribute( } // Matches a candidate method's parameter types against the runtime types of the supplied arguments. - // A null argument has no runtime type, so it never matches a (non-null) parameter type, preserving - // the previous SequenceEqual(parameterTypes, arguments.Select(a => a?.GetType())) behavior. + // Uses IsAssignableFrom (like GenericTypeResolver) so a derived-type or interface-implementing argument + // matches a base-class/interface parameter, mirroring what the runtime call would actually accept. + // A null argument has no runtime type, so it never matches a (non-null) parameter type. private static bool ParameterTypesMatchArguments(ParameterInfo[] parameters, object?[] arguments) { if (parameters.Length != arguments.Length) @@ -336,7 +337,8 @@ private static bool ParameterTypesMatchArguments(ParameterInfo[] parameters, obj for (var i = 0; i < parameters.Length; i++) { - if (parameters[i].ParameterType != arguments[i]?.GetType()) + var argumentType = arguments[i]?.GetType(); + if (argumentType is null || !parameters[i].ParameterType.IsAssignableFrom(argumentType)) { return false; } From ff637f1218e96c0ccefe18ecbd15d6bb42060816 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 23:44:56 +0100 Subject: [PATCH 3/4] fix: resolve MethodDataSource overloads via runtime binder The IsAssignableFrom scan widened matching so competing overloads (e.g. GetData(object) and GetData(string) with Arguments=["hello"]) both matched, making SingleOrDefault throw InvalidOperationException. Delegate to Type.GetMethod(name, flags, binder, Type[], modifiers) when all arguments are non-null: the default binder applies normal overload resolution (most-specific wins) while still honouring derived-type/interface widening (List arg -> IList param), preserving this PR's bug-fix. Null arguments have no runtime type, so fall back to the name-only lookup. --- .../TestData/MethodDataSourceAttribute.cs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index b785f9744a..134196041b 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -180,9 +180,8 @@ public MethodDataSourceAttribute( throw new InvalidOperationException($"Could not determine target type for method '{MethodNameProvidingDataSource}'. This may occur during static property initialization without a test context."); } - // Try to find a method first - var methodInfo = targetType.GetMethods(BindingFlags).SingleOrDefault(x => x.Name == MethodNameProvidingDataSource - && ParameterTypesMatchArguments(x.GetParameters(), Arguments)) + // Try to find a method first. + var methodInfo = ResolveDataSourceMethod(targetType) ?? targetType.GetMethod(MethodNameProvidingDataSource, BindingFlags); object? methodResult; @@ -324,27 +323,46 @@ public MethodDataSourceAttribute( } } - // Matches a candidate method's parameter types against the runtime types of the supplied arguments. - // Uses IsAssignableFrom (like GenericTypeResolver) so a derived-type or interface-implementing argument - // matches a base-class/interface parameter, mirroring what the runtime call would actually accept. - // A null argument has no runtime type, so it never matches a (non-null) parameter type. - private static bool ParameterTypesMatchArguments(ParameterInfo[] parameters, object?[] arguments) + // Resolves the data-source method overload that best matches the supplied Arguments. + // + // When every argument is non-null we know its runtime type, so we delegate to the runtime + // binder via Type.GetMethod(name, flags, binder, types, modifiers). The binder applies normal + // overload-resolution rules (most-specific match wins), which both honours the derived-type / + // interface widening this fix intends AND disambiguates competing overloads such as + // GetData(object) vs GetData(string) — where a plain IsAssignableFrom scan would match both and + // throw on SingleOrDefault. + // + // When any argument is null it has no runtime type, so it cannot be expressed in the binder's + // Type[]; we fall back to a name-only single-overload lookup (handled by the caller). + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Method data sources require runtime discovery. AOT users should use Factory property.")] + private MethodInfo? ResolveDataSourceMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type targetType) { - if (parameters.Length != arguments.Length) + var arguments = Arguments; + if (arguments.Length == 0) { - return false; + // No arguments: a parameterless overload is the natural target; defer to the + // caller's name-only lookup which finds it (or any single named overload). + return null; } - for (var i = 0; i < parameters.Length; i++) + var argumentTypes = new Type[arguments.Length]; + for (var i = 0; i < arguments.Length; i++) { var argumentType = arguments[i]?.GetType(); - if (argumentType is null || !parameters[i].ParameterType.IsAssignableFrom(argumentType)) + if (argumentType is null) { - return false; + // A null argument has no runtime type the binder can match on. + // Fall back to the name-only lookup performed by the caller. + return null; } + + argumentTypes[i] = argumentType; } - return true; + // Let the runtime binder pick the best overload for these exact argument types. + // Ambiguous matches surface as AmbiguousMatchException (genuinely ambiguous code), + // never as the spurious SingleOrDefault throw the old scan produced. + return targetType.GetMethod(MethodNameProvidingDataSource, BindingFlags, binder: null, argumentTypes, modifiers: null); } // MethodInfo.Invoke does not auto-fill optional parameters the way a C# call site does. From 5e771c5d3f2a8de7731555b9f7865d36468cc74c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 23:56:59 +0100 Subject: [PATCH 4/4] fix: resolve zero-arg data-source method via binder Type.EmptyTypes GetMethod(name, flags) returns null (not throws) when the name is ambiguous, so a parameterless data-source method coexisting with other overloads silently resolved to null. Route the zero-argument case through the same binder GetMethod(name, flags, binder, Type[], modifiers) path: the empty Type[] selects the parameterless overload even when other-arity overloads share the name. Null-argument fallback unchanged. --- .../TestData/MethodDataSourceAttribute.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 134196041b..a22e8a55d6 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -334,17 +334,19 @@ public MethodDataSourceAttribute( // // When any argument is null it has no runtime type, so it cannot be expressed in the binder's // Type[]; we fall back to a name-only single-overload lookup (handled by the caller). + // + // With zero arguments the empty Type[] flows through the same binder path and selects the + // parameterless overload directly — including when the name is shared with other-arity + // overloads, which the caller's name-only GetMethod(name, flags) would treat as ambiguous + // and silently return null for. [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Method data sources require runtime discovery. AOT users should use Factory property.")] private MethodInfo? ResolveDataSourceMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type targetType) { var arguments = Arguments; - if (arguments.Length == 0) - { - // No arguments: a parameterless overload is the natural target; defer to the - // caller's name-only lookup which finds it (or any single named overload). - return null; - } + // For zero arguments this produces an empty Type[], which the binder GetMethod overload + // resolves to the parameterless overload — even when other-arity overloads share the name. + // (The plain name-only GetMethod(name, flags) cannot: it returns null on an ambiguous name.) var argumentTypes = new Type[arguments.Length]; for (var i = 0; i < arguments.Length; i++) {