Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 20 additions & 1 deletion TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,6 +128,25 @@ public MethodDataSourceAttribute(
MethodNameProvidingDataSource = methodNameProvidingDataSource;
}

/// <summary>
/// Creates an <see cref="InstanceMethodDataSourceAttribute"/> 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.
/// <see cref="Factory"/> is intentionally not copied: it is only populated by the source generator,
/// so it is always null on attributes discovered via reflection.
/// </summary>
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<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
Expand Down
57 changes: 57 additions & 0 deletions TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,63 @@ public static IDataSourceAttribute[] ExtractDataSources(ICustomAttributeProvider
return dataSources.ToArray();
}

/// <summary>
/// Extracts method-level data sources, upgrading plain <see cref="MethodDataSourceAttribute"/>s that
/// target an instance member to <see cref="InstanceMethodDataSourceAttribute"/> 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).
/// </summary>
[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))
{
dataSources[i] = methodDataSource.ToInstanceVariant();
}
}

return dataSources;
}

[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;

// 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))
{
var isStatic = member switch
{
MethodBase method => method.IsStatic,
PropertyInfo property => property.GetMethod?.IsStatic == true,
FieldInfo field => field.IsStatic,
_ => true
};

if (!isStatic)
{
return true;
}
}

return false;
}

public static Attribute[] GetAllAttributes(Type testClass, MethodInfo testMethod)
{
return _allAttributesCache.GetOrAdd((testClass, testMethod), key =>
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ namespace
"rty.")]
[.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" +
"rty.")]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__21))]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__22))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
}
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ namespace
"rty.")]
[.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" +
"rty.")]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__21))]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__22))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
}
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ namespace
"rty.")]
[.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" +
"rty.")]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__21))]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__22))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
}
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,7 @@ namespace
public <.DataGeneratorMetadata, .<<.<object?[]?>>>>? Factory { get; set; }
public string MethodNameProvidingDataSource { get; }
public bool SkipIfEmpty { get; set; }
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__21))]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__22))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
}
[(.Class | .Method | .Property | .Parameter, AllowMultiple=true)]
Expand Down
56 changes: 56 additions & 0 deletions TUnit.TestProject/Bugs/6162/Tests.cs
Original file line number Diff line number Diff line change
@@ -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<SimpleDependencyInjectionAttribute.Scope>
{
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<string> 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);
Loading