From 460cf1b31f776465ebcfb6ce33c87fe95fa22982 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:13:54 +0100 Subject: [PATCH 1/2] fix: resolve inherited instance data source members for MethodDataSource (#6162) [MethodDataSource] targeting an instance member combined with [InheritsTests] and a class-level DI data source crashed with "No parameterless constructor defined" because the engine fell back to Activator.CreateInstance. Source-gen mode: GenerateMethodDataSourceAttribute resolved the data source member via GetMembers on the derived test class only, which misses members declared on base classes. The member lookup now walks the base-type chain so inherited members get the InstanceMethodDataSourceAttribute conversion and a compiled Factory, the same as members declared directly on the test class. Reflection mode: plain MethodDataSourceAttributes targeting an instance member were never upgraded to InstanceMethodDataSourceAttribute, so the engine never pre-created a properly-constructed instance. ExtractMethodDataSources now performs the same conversion the source generator does at compile time. Adds a regression test reproducing the issue: an abstract base class with an instance property data source, inherited by a sealed class whose instances are produced by a DependencyInjectionDataSourceAttribute. Fixes #6162 --- .../Generators/TestMetadataGenerator.cs | 18 ++++- .../Discovery/ReflectionAttributeExtractor.cs | 75 +++++++++++++++++++ .../Discovery/ReflectionTestDataCollector.cs | 2 +- TUnit.TestProject/Bugs/6162/Tests.cs | 56 ++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 TUnit.TestProject/Bugs/6162/Tests.cs diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 49686ba154..8636ed7d52 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -1560,8 +1560,22 @@ private static void GenerateMethodDataSourceAttribute(CodeWriter writer, Attribu return; } - // Find the data source method, property, or field - var dataSourceMember = targetType.GetMembers(methodName!).FirstOrDefault(); + // Find the data source method, property, or field. + // Walk base types too: with [InheritsTests] (or a data member declared on a base class) + // the member may not be declared directly on the test class. Missing it here would emit + // a plain MethodDataSourceAttribute without a Factory, which falls back to + // Activator.CreateInstance at runtime and fails for classes without a parameterless + // constructor (https://github.com/thomhurst/TUnit/issues/6162). + ISymbol? dataSourceMember = null; + for (var searchType = targetType; searchType is not null; searchType = searchType.BaseType) + { + var members = searchType.GetMembers(methodName!); + if (members.Length > 0) + { + dataSourceMember = members[0]; + break; + } + } var dataSourceMethod = dataSourceMember as IMethodSymbol; var dataSourceProperty = dataSourceMember as IPropertySymbol; diff --git a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs index f2faf32e0c..b4fe2e695d 100644 --- a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs +++ b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs @@ -184,6 +184,81 @@ public static IDataSourceAttribute[] ExtractDataSources(ICustomAttributeProvider return dataSources.ToArray(); } + /// + /// Extracts method-level data sources, upgrading plain s that + /// target an instance member to so the engine creates + /// a properly-constructed instance instead of Activator.CreateInstance, mirroring the conversion the + /// source generator performs at compile time (https://github.com/thomhurst/TUnit/issues/6162). + /// + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Reflection mode requires dynamic access")] + public static IDataSourceAttribute[] ExtractMethodDataSources( + MethodInfo testMethod, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] + Type testClass) + { + var dataSources = ExtractDataSources(testMethod); + + for (var i = 0; i < dataSources.Length; i++) + { + if (dataSources[i] is MethodDataSourceAttribute methodDataSource + and not InstanceMethodDataSourceAttribute + && TargetsInstanceMember(methodDataSource, testClass)) + { + var converted = methodDataSource.ClassProvidingDataSource is { } classProvidingDataSource + ? new InstanceMethodDataSourceAttribute(classProvidingDataSource, methodDataSource.MethodNameProvidingDataSource) + : new InstanceMethodDataSourceAttribute(methodDataSource.MethodNameProvidingDataSource); + + converted.Arguments = methodDataSource.Arguments; + converted.SkipIfEmpty = methodDataSource.SkipIfEmpty; + + dataSources[i] = converted; + } + } + + return dataSources; + } + + // Must stay in sync with MethodDataSourceAttribute.BindingFlags so the static/instance + // pre-check here agrees with the member GetDataRowsAsync resolves at data-generation time. + private const BindingFlags DataSourceMemberBindingFlags = BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Static + | BindingFlags.Instance + | BindingFlags.FlattenHierarchy; + + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection mode requires dynamic access")] + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection mode requires dynamic access")] + private static bool TargetsInstanceMember(MethodDataSourceAttribute methodDataSource, Type testClass) + { + var targetType = methodDataSource.ClassProvidingDataSource ?? testClass; + var memberName = methodDataSource.MethodNameProvidingDataSource; + + try + { + if (targetType.GetMethod(memberName, DataSourceMemberBindingFlags) is { } method) + { + return !method.IsStatic; + } + } + catch (AmbiguousMatchException) + { + // Ambiguous overloads - leave the attribute as-is and let runtime resolution handle it + return false; + } + + if (targetType.GetProperty(memberName, DataSourceMemberBindingFlags) is { } property) + { + return property.GetMethod?.IsStatic != true; + } + + if (targetType.GetField(memberName, DataSourceMemberBindingFlags) is { } field) + { + return !field.IsStatic; + } + + return false; + } + public static Attribute[] GetAllAttributes(Type testClass, MethodInfo testMethod) { return _allAttributesCache.GetOrAdd((testClass, testMethod), key => diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 4270a16ede..a67f48226e 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1090,7 +1090,7 @@ private static TestMetadata BuildTestMetadata( TestClassType = typeForGenericResolution, // Use resolved type for generic resolution (may be constructed generic base) TestMethodName = testMethod.Name, Dependencies = ReflectionAttributeExtractor.ExtractDependencies(testClass, testMethod), - DataSources = ReflectionAttributeExtractor.ExtractDataSources(testMethod), + DataSources = ReflectionAttributeExtractor.ExtractMethodDataSources(testMethod, testClass), ClassDataSources = classData != null ? [new StaticDataSourceAttribute(new[] { classData })] : ReflectionAttributeExtractor.ExtractDataSources(testClass), diff --git a/TUnit.TestProject/Bugs/6162/Tests.cs b/TUnit.TestProject/Bugs/6162/Tests.cs new file mode 100644 index 0000000000..6e573630c7 --- /dev/null +++ b/TUnit.TestProject/Bugs/6162/Tests.cs @@ -0,0 +1,56 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._6162; + +// Repro for https://github.com/thomhurst/TUnit/issues/6162 +// An instance MethodDataSource declared on an abstract base class, combined with +// [InheritsTests] on a derived class whose instances are produced by a +// DependencyInjectionDataSourceAttribute (no parameterless constructor), +// previously fell back to Activator.CreateInstance and failed with +// "No parameterless constructor defined". + +public interface IExportService +{ + string Export(string path); +} + +public sealed class ExportService : IExportService +{ + public string Export(string path) => $"exported:{path}"; +} + +public sealed class SimpleDependencyInjectionAttribute : DependencyInjectionDataSourceAttribute +{ + public sealed class Scope; + + public override Scope CreateScope(DataGeneratorMetadata dataGeneratorMetadata) => new(); + + public override object? Create(Scope scope, Type type) + { + if (type == typeof(IExportService)) + { + return new ExportService(); + } + + return null; + } +} + +public abstract class BaseExportTests(IExportService exportService) +{ + public IEnumerable DocumentPaths => ["doc1", "doc2"]; + + [Test] + [MethodDataSource(nameof(DocumentPaths))] + public async Task Export_ReturnsResult(string path) + { + var result = exportService.Export(path); + + await Assert.That(result).IsEqualTo($"exported:{path}"); + } +} + +[EngineTest(ExpectedResult.Pass)] +[InheritsTests] +[SimpleDependencyInjection] +public sealed class InheritedExportTests(IExportService exportService) : BaseExportTests(exportService); From 64f49e7948e4c6935c33bb6cae9a80dbd9668f78 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:38:32 +0100 Subject: [PATCH 2/2] refactor: address review feedback on instance data source conversion - Move the InstanceMethodDataSourceAttribute conversion onto MethodDataSourceAttribute (internal ToInstanceVariant) so property copying lives with the type instead of in the extractor. - Promote MethodDataSourceAttribute.BindingFlags to internal and reference it from ReflectionAttributeExtractor, removing the duplicated constant. - Replace the GetMethod + AmbiguousMatchException catch with GetMember enumeration: overloaded names no longer skip the conversion, and any instance overload conservatively triggers it. - Public API snapshots: compiler-generated async state machine renamed d__21 -> d__22 (member ordinal shift from the new internal method); no public surface change. --- .../TestData/MethodDataSourceAttribute.cs | 21 +++++++- .../Discovery/ReflectionAttributeExtractor.cs | 50 ++++++------------- ...Has_No_API_Changes.DotNet10_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 +- 6 files changed, 40 insertions(+), 39 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 8cf3c022ba..582e750493 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -60,7 +60,7 @@ public class MethodDataSourceAttribute< [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] public class MethodDataSourceAttribute : Attribute, IDataSourceAttribute { - private const BindingFlags BindingFlags = System.Reflection.BindingFlags.Public + internal const BindingFlags BindingFlags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance @@ -128,6 +128,25 @@ public MethodDataSourceAttribute( MethodNameProvidingDataSource = methodNameProvidingDataSource; } + /// + /// Creates an copy of this attribute, preserving all + /// user-settable state. Used by reflection-mode discovery to mirror the conversion the source generator + /// performs at compile time for data sources that target instance members. + /// is intentionally not copied: it is only populated by the source generator, + /// so it is always null on attributes discovered via reflection. + /// + internal InstanceMethodDataSourceAttribute ToInstanceVariant() + { + var converted = ClassProvidingDataSource is { } classProvidingDataSource + ? new InstanceMethodDataSourceAttribute(classProvidingDataSource, MethodNameProvidingDataSource) + : new InstanceMethodDataSourceAttribute(MethodNameProvidingDataSource); + + converted.Arguments = Arguments; + converted.SkipIfEmpty = SkipIfEmpty; + + return converted; + } + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Method data sources require runtime discovery. AOT users should use Factory property.")] [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Method data sources require runtime discovery. AOT users should use Factory property.")] public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) diff --git a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs index b4fe2e695d..c47792c537 100644 --- a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs +++ b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs @@ -204,28 +204,13 @@ public static IDataSourceAttribute[] ExtractMethodDataSources( and not InstanceMethodDataSourceAttribute && TargetsInstanceMember(methodDataSource, testClass)) { - var converted = methodDataSource.ClassProvidingDataSource is { } classProvidingDataSource - ? new InstanceMethodDataSourceAttribute(classProvidingDataSource, methodDataSource.MethodNameProvidingDataSource) - : new InstanceMethodDataSourceAttribute(methodDataSource.MethodNameProvidingDataSource); - - converted.Arguments = methodDataSource.Arguments; - converted.SkipIfEmpty = methodDataSource.SkipIfEmpty; - - dataSources[i] = converted; + dataSources[i] = methodDataSource.ToInstanceVariant(); } } return dataSources; } - // Must stay in sync with MethodDataSourceAttribute.BindingFlags so the static/instance - // pre-check here agrees with the member GetDataRowsAsync resolves at data-generation time. - private const BindingFlags DataSourceMemberBindingFlags = BindingFlags.Public - | BindingFlags.NonPublic - | BindingFlags.Static - | BindingFlags.Instance - | BindingFlags.FlattenHierarchy; - [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection mode requires dynamic access")] [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection mode requires dynamic access")] private static bool TargetsInstanceMember(MethodDataSourceAttribute methodDataSource, Type testClass) @@ -233,27 +218,24 @@ private static bool TargetsInstanceMember(MethodDataSourceAttribute methodDataSo var targetType = methodDataSource.ClassProvidingDataSource ?? testClass; var memberName = methodDataSource.MethodNameProvidingDataSource; - try + // GetMember returns all matching members (it never throws AmbiguousMatchException for + // overloads, unlike GetMethod). Conservatively treat the data source as instance-targeting + // if ANY matching member is an instance member, so the engine pre-creates a properly + // constructed test class instance for it. + foreach (var member in targetType.GetMember(memberName, MethodDataSourceAttribute.BindingFlags)) { - if (targetType.GetMethod(memberName, DataSourceMemberBindingFlags) is { } method) + var isStatic = member switch { - return !method.IsStatic; - } - } - catch (AmbiguousMatchException) - { - // Ambiguous overloads - leave the attribute as-is and let runtime resolution handle it - return false; - } + MethodBase method => method.IsStatic, + PropertyInfo property => property.GetMethod?.IsStatic == true, + FieldInfo field => field.IsStatic, + _ => true + }; - if (targetType.GetProperty(memberName, DataSourceMemberBindingFlags) is { } property) - { - return property.GetMethod?.IsStatic != true; - } - - if (targetType.GetField(memberName, DataSourceMemberBindingFlags) is { } field) - { - return !field.IsStatic; + if (!isStatic) + { + return true; + } } return false; diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 0c948f5566..737d24ea26 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1029,7 +1029,7 @@ namespace "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__21))] + [.(typeof(.MethodDataSourceAttribute.d__22))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index c63d5e723c..d9a94a0daa 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1029,7 +1029,7 @@ namespace "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__21))] + [.(typeof(.MethodDataSourceAttribute.d__22))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 61ab10d179..51f10f7b11 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1029,7 +1029,7 @@ namespace "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__21))] + [.(typeof(.MethodDataSourceAttribute.d__22))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 0da677c22b..3ab1c1d521 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -992,7 +992,7 @@ namespace public <.DataGeneratorMetadata, .<<.>>>? Factory { get; set; } public string MethodNameProvidingDataSource { get; } public bool SkipIfEmpty { get; set; } - [.(typeof(.MethodDataSourceAttribute.d__21))] + [.(typeof(.MethodDataSourceAttribute.d__22))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]