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 .<<.