diff --git a/TUnit.Core.SourceGenerator.Tests/GenericPropertyInjectionRawTests.cs b/TUnit.Core.SourceGenerator.Tests/GenericPropertyInjectionRawTests.cs new file mode 100644 index 0000000000..57d0cd7019 --- /dev/null +++ b/TUnit.Core.SourceGenerator.Tests/GenericPropertyInjectionRawTests.cs @@ -0,0 +1,645 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Core.SourceGenerator.Generators; +using TUnit.Core.SourceGenerator.Tests.Extensions; + +namespace TUnit.Core.SourceGenerator.Tests; + +/// +/// Raw tests to verify what the PropertyInjectionSourceGenerator produces for generic types. +/// These tests don't use Verify, just direct assertions. +/// +internal class GenericPropertyInjectionRawTests +{ + /// + /// Helper to run the generator and return generated files. + /// + private static async Task RunGeneratorAsync(string source) + { + var generator = new PropertyInjectionSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [CSharpSyntaxTree.ParseText(source)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ) + .WithReferences(ReferencesHelper.References); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics); + + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + await Assert.That(errors).IsEmpty() + .Because($"Generator errors: {string.Join("\n", errors.Select(e => e.GetMessage()))}"); + + return newCompilation.SyntaxTrees + .Select(t => t.GetText().ToString()) + .Where(t => !t.Contains("namespace TestProject")) + .ToArray(); + } + + [Test] + public async Task BasicGenericBase_WithDataSourceProperty_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericFixtureBase where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } + } + + public class MyTests : GenericFixtureBase + { + public class TestProgram { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class InMemoryDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + var hasGenericMetadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericMetadata) + .IsTrue() + .Because("Should generate property source for GenericFixtureBase"); + } + + [Test] + public async Task MultiplePropertiesOnGenericBase_AllPropertiesGenerated() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericFixtureBase where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public Database1? FirstDb { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public Database2? SecondDb { get; init; } + } + + public class MyTests : GenericFixtureBase + { + public class TestProgram { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class Database1 : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class Database2 : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should have property injection for both properties + var genericPropertyFile = generatedFiles.FirstOrDefault(f => + f.Contains("GenericFixtureBase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(genericPropertyFile).IsNotNull() + .Because("Should generate property source for GenericFixtureBase"); + + // Both properties should be registered + await Assert.That(genericPropertyFile!.Contains("FirstDb")).IsTrue() + .Because("FirstDb property should be registered"); + await Assert.That(genericPropertyFile.Contains("SecondDb")).IsTrue() + .Because("SecondDb property should be registered"); + } + + [Test] + public async Task MultipleTypeParameters_GeneratesCorrectMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericFixtureBase where T1 : class where T2 : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } + } + + public class MyTests : GenericFixtureBase + { + public class Program1 { } + public class Program2 { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class InMemoryDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + var hasGenericMetadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("Program1") && + f.Contains("Program2") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericMetadata) + .IsTrue() + .Because("Should generate property source for GenericFixtureBase"); + } + + [Test] + public async Task DeepInheritanceChain_GeneratesMetadataForAllLevels() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GrandparentBase where T : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public GrandparentDb? GrandparentDatabase { get; init; } + } + + public abstract class ParentBase : GrandparentBase where T : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public ParentDb? ParentDatabase { get; init; } + } + + public class MyTests : ParentBase + { + public class TestProgram { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class GrandparentDb : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class ParentDb : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should have metadata for both GrandparentBase and ParentBase + var hasGrandparentMetadata = generatedFiles.Any(f => + f.Contains("GrandparentBase") && + f.Contains("PropertySourceRegistry.Register")); + + var hasParentMetadata = generatedFiles.Any(f => + f.Contains("ParentBase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGrandparentMetadata) + .IsTrue() + .Because("Should generate property source for GrandparentBase"); + + await Assert.That(hasParentMetadata) + .IsTrue() + .Because("Should generate property source for ParentBase"); + } + + [Test] + public async Task GenericTypeAsDataSourceTypeArgument_GeneratesPropertySource() + { + // This test verifies that a generic IAsyncInitializer discovered from ClassDataSource + // type argument generates PropertySourceRegistry for the test class (MyTests). + // Note: InitializerPropertyRegistry is only generated when the type has properties + // returning other IAsyncInitializer types. + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public class GenericFixture : IAsyncInitializer where T : class + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class MyTests + { + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public GenericFixture? Fixture { get; init; } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate PropertySourceRegistry for MyTests (which has the ClassDataSource property) + var hasPropertySourceMetadata = generatedFiles.Any(f => + f.Contains("MyTests") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasPropertySourceMetadata) + .IsTrue() + .Because("Should generate property source for MyTests with generic ClassDataSource type"); + } + + [Test] + public async Task GenericIAsyncInitializerWithNestedProperties_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public class GenericFixture : IAsyncInitializer where T : class + { + public NestedInitializer? Nested { get; init; } + public Task InitializeAsync() => Task.CompletedTask; + } + + public class NestedInitializer : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class MyTests + { + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public GenericFixture? Fixture { get; init; } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate InitializerPropertyRegistry with the Nested property + var genericInitializerFile = generatedFiles.FirstOrDefault(f => + f.Contains("GenericFixture") && + f.Contains("InitializerPropertyRegistry.Register")); + + await Assert.That(genericInitializerFile).IsNotNull() + .Because("Should generate initializer property source for GenericFixture"); + + await Assert.That(genericInitializerFile!.Contains("Nested")).IsTrue() + .Because("Nested property should be discovered in GenericFixture"); + } + + [Test] + public async Task MultipleConcreteInstantiationsOfSameGeneric_GeneratesDistinctMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericFixtureBase where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } + } + + public class Tests1 : GenericFixtureBase + { + public class Program1 { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class Tests2 : GenericFixtureBase + { + public class Program2 { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class InMemoryDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should have metadata for both concrete instantiations + var hasProgram1Metadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("Program1") && + f.Contains("PropertySourceRegistry.Register")); + + var hasProgram2Metadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("Program2") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasProgram1Metadata) + .IsTrue() + .Because("Should generate property source for GenericFixtureBase"); + + await Assert.That(hasProgram2Metadata) + .IsTrue() + .Because("Should generate property source for GenericFixtureBase"); + } + + [Test] + public async Task MixOfGenericAndNonGenericProperties_BothGenerated() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericFixtureBase where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public Database1? BaseDatabase { get; init; } + } + + public class MyTests : GenericFixtureBase + { + public class TestProgram { } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public Database2? DerivedDatabase { get; init; } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class Database1 : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class Database2 : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should have property injection for the generic base type + var hasGenericBaseMetadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("BaseDatabase") && + f.Contains("PropertySourceRegistry.Register")); + + // Should have property injection for the concrete derived type + var hasDerivedMetadata = generatedFiles.Any(f => + f.Contains("MyTests") && + f.Contains("DerivedDatabase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericBaseMetadata) + .IsTrue() + .Because("Should generate property source for generic base class"); + + await Assert.That(hasDerivedMetadata) + .IsTrue() + .Because("Should generate property source for derived class"); + } + + [Test] + public async Task NestedGenericTypeArgument_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + using System.Collections.Generic; + + namespace TestProject; + + public abstract class GenericFixtureBase where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } + } + + public class MyTests : GenericFixtureBase> + { + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class InMemoryDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate metadata for GenericFixtureBase> + var hasGenericMetadata = generatedFiles.Any(f => + f.Contains("GenericFixtureBase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericMetadata) + .IsTrue() + .Because("Should generate property source for GenericFixtureBase>"); + } + + [Test] + public async Task OpenGenericIntermediateClass_ConcreteAtLeaf_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public abstract class GenericBase where T : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } + } + + // Intermediate class keeps the type parameter open + public abstract class IntermediateBase : GenericBase where T : class + { + } + + // Leaf class makes it concrete + public class MyTests : IntermediateBase + { + public class TestProgram { } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + + public class InMemoryDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate metadata for GenericBase + var hasGenericMetadata = generatedFiles.Any(f => + f.Contains("GenericBase") && + f.Contains("TestProgram") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericMetadata) + .IsTrue() + .Because("Should generate property source for GenericBase through intermediate class"); + } + + /// + /// Issue #4431 - Tests the exact WebApplicationFactory pattern from the GitHub issue. + /// A generic factory class with ClassDataSource property, used via ClassDataSource itself. + /// + [Test] + public async Task Issue4431_WebApplicationFactoryPattern_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + // Generic WebApplicationFactory-style class with its own ClassDataSource dependency + public class CustomWebApplicationFactory : IAsyncInitializer + where TProgram : class + { + // This property needs to be discovered and injected + [ClassDataSource(Shared = SharedType.PerTestSession)] + public TestContainer? Container { get; init; } + + public Task InitializeAsync() => Task.CompletedTask; + } + + public class TestContainer : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class MyProgram { } + + // Test class using the factory + public class MyTests + { + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public CustomWebApplicationFactory? Factory { get; init; } + + [Test] + public Task MyTest() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate PropertySourceRegistry for CustomWebApplicationFactory + var hasFactoryPropertySource = generatedFiles.Any(f => + f.Contains("CustomWebApplicationFactory") && + f.Contains("MyProgram") && + f.Contains("PropertySourceRegistry.Register")); + + // Should also generate InitializerPropertyRegistry for the nested Container property + var hasFactoryInitializerRegistry = generatedFiles.Any(f => + f.Contains("CustomWebApplicationFactory") && + f.Contains("InitializerPropertyRegistry.Register")); + + await Assert.That(hasFactoryPropertySource) + .IsTrue() + .Because("Should generate property source for CustomWebApplicationFactory"); + + await Assert.That(hasFactoryInitializerRegistry) + .IsTrue() + .Because("Should generate initializer property registry for CustomWebApplicationFactory with Container property"); + } + + /// + /// Issue #4431 Comment - Tests the multi-parameter generic base inheritance pattern. + /// + [Test] + public async Task Issue4431_MultiParameterGenericBase_GeneratesMetadata() + { + var source = """ + using TUnit.Core; + using TUnit.Core.Interfaces; + + namespace TestProject; + + public class CustomFactory : IAsyncInitializer where T : class + { + public Task InitializeAsync() => Task.CompletedTask; + } + + // Base class with multiple type parameters + public abstract class WebAppFactoryBase + where TFactory : CustomFactory + where TProgram : class + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public TestDatabase? Database { get; init; } + } + + public class TestDatabase : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + + public class MyProgram { } + + // Concrete test class + public class MyTests : WebAppFactoryBase, MyProgram> + { + [Test] + public Task MyTest() => Task.CompletedTask; + } + """; + + var generatedFiles = await RunGeneratorAsync(source); + + // Should generate PropertySourceRegistry for WebAppFactoryBase with concrete type args + var hasGenericBaseMetadata = generatedFiles.Any(f => + f.Contains("WebAppFactoryBase") && + f.Contains("PropertySourceRegistry.Register")); + + await Assert.That(hasGenericBaseMetadata) + .IsTrue() + .Because("Should generate property source for WebAppFactoryBase, MyProgram>"); + } +} diff --git a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs index e8c25558fb..29ccd8c038 100644 --- a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs @@ -67,6 +67,41 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return; GenerateInitializerPropertySource(ctx, model); }); + + // Pipeline 3: Discover concrete generic types from inheritance chains and IDataSourceAttribute type arguments + var concreteGenericTypes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is TypeDeclarationSyntax or PropertyDeclarationSyntax, + transform: static (ctx, _) => ExtractConcreteGenericTypes(ctx)) + .Where(static x => x.Length > 0) + .SelectMany(static (types, _) => types); + + // Collect and deduplicate by fully qualified type name + var distinctConcreteGenerics = concreteGenericTypes + .Collect() + .SelectMany(static (types, _) => DeduplicateConcreteGenericTypes(types)); + + var concreteGenericsWithEnabled = distinctConcreteGenerics.Combine(enabledProvider); + + // Pipeline 4 & 5: Generate source for concrete generic types + context.RegisterSourceOutput(concreteGenericsWithEnabled, static (ctx, data) => + { + var (model, isEnabled) = data; + if (!isEnabled) + return; + + // Generate property data source if the type has data source properties + if (model.HasDataSourceProperties && model.DataSourceProperties.AsArray().Length > 0) + { + GenerateGenericPropertyInjectionSource(ctx, model); + } + + // Generate initializer property source if the type has initializer properties + if (model.InitializerProperties.AsArray().Length > 0) + { + GenerateGenericInitializerPropertySource(ctx, model); + } + }); } #region Property Data Source Extraction @@ -250,6 +285,243 @@ private static IEnumerable GroupPropertiesByClass( #endregion + #region Concrete Generic Type Discovery + + /// + /// Extracts concrete generic types from the current syntax node (type declarations and properties). + /// Looks for concrete generic types in: + /// 1. Inheritance chains (base types) + /// 2. IDataSourceAttribute type arguments + /// + private static ImmutableArray ExtractConcreteGenericTypes(GeneratorSyntaxContext context) + { + var semanticModel = context.SemanticModel; + var results = new List(); + + var dataSourceInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.IDataSourceAttribute"); + var asyncInitializerInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.Interfaces.IAsyncInitializer"); + + if (dataSourceInterface == null || asyncInitializerInterface == null) + return ImmutableArray.Empty; + + // Discovery from type declarations (inheritance chains) + if (context.Node is TypeDeclarationSyntax typeDecl) + { + if (semanticModel.GetDeclaredSymbol(typeDecl) is INamedTypeSymbol typeSymbol) + { + // Walk inheritance chain to find concrete generic base types + var baseType = typeSymbol.BaseType; + while (baseType != null && baseType.SpecialType != SpecialType.System_Object) + { + if (IsConcreteGenericType(baseType)) + { + var model = CreateConcreteGenericModel(baseType, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + baseType = baseType.BaseType; + } + + // Check implemented interfaces for concrete generics + foreach (var iface in typeSymbol.AllInterfaces) + { + if (IsConcreteGenericType(iface)) + { + var model = CreateConcreteGenericModel(iface, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + } + } + } + + // Discovery from property declarations (IDataSourceAttribute type arguments) + if (context.Node is PropertyDeclarationSyntax propertyDecl) + { + if (semanticModel.GetDeclaredSymbol(propertyDecl) is IPropertySymbol propertySymbol) + { + foreach (var attr in propertySymbol.GetAttributes()) + { + if (attr.AttributeClass == null) + continue; + + // Check if attribute implements IDataSourceAttribute + if (!attr.AttributeClass.AllInterfaces.Contains(dataSourceInterface, SymbolEqualityComparer.Default)) + continue; + + // Check attribute type arguments for concrete generic types + DiscoverGenericTypesFromTypeArguments(attr.AttributeClass, dataSourceInterface, asyncInitializerInterface, results); + + // Check constructor arguments for type parameters + foreach (var ctorArg in attr.ConstructorArguments) + { + if (ctorArg.Value is INamedTypeSymbol argType && IsConcreteGenericType(argType)) + { + var model = CreateConcreteGenericModel(argType, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + } + } + + // Also check if the property type itself is a concrete generic + if (propertySymbol.Type is INamedTypeSymbol propertyType && IsConcreteGenericType(propertyType)) + { + var model = CreateConcreteGenericModel(propertyType, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + } + } + + return results.Count > 0 ? results.ToImmutableArray() : ImmutableArray.Empty; + } + + /// + /// Recursively discovers concrete generic types from type arguments. + /// + private static void DiscoverGenericTypesFromTypeArguments( + INamedTypeSymbol typeSymbol, + INamedTypeSymbol dataSourceInterface, + INamedTypeSymbol asyncInitializerInterface, + List results) + { + if (!typeSymbol.IsGenericType) + return; + + foreach (var typeArg in typeSymbol.TypeArguments) + { + if (typeArg is INamedTypeSymbol namedTypeArg) + { + if (IsConcreteGenericType(namedTypeArg)) + { + var model = CreateConcreteGenericModel(namedTypeArg, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + + // Recurse into nested type arguments + if (namedTypeArg.IsGenericType) + { + DiscoverGenericTypesFromTypeArguments(namedTypeArg, dataSourceInterface, asyncInitializerInterface, results); + } + } + } + } + + /// + /// Checks if a type is a concrete instantiation of a generic type (not an open generic). + /// + private static bool IsConcreteGenericType(INamedTypeSymbol type) + { + return type.IsGenericType && !type.IsUnboundGenericType && type.TypeArguments.All(t => t.TypeKind != TypeKind.TypeParameter); + } + + /// + /// Creates a ConcreteGenericTypeModel for a discovered concrete generic type. + /// Returns null if the type doesn't have any relevant properties or interfaces. + /// + private static ConcreteGenericTypeModel? CreateConcreteGenericModel( + INamedTypeSymbol concreteType, + INamedTypeSymbol dataSourceInterface, + INamedTypeSymbol asyncInitializerInterface) + { + // Check if this type implements IAsyncInitializer + var implementsIAsyncInitializer = concreteType.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default); + + // Find data source properties (walk inheritance chain) + var dataSourceProperties = new List(); + var initializerProperties = new List(); + + var currentType = concreteType; + while (currentType != null && currentType.SpecialType != SpecialType.System_Object) + { + foreach (var member in currentType.GetMembers()) + { + if (member is not IPropertySymbol property) + continue; + + // Check for IDataSourceAttribute on property + if (property.SetMethod != null) + { + foreach (var attr in property.GetAttributes()) + { + if (attr.AttributeClass != null && + attr.AttributeClass.AllInterfaces.Contains(dataSourceInterface, SymbolEqualityComparer.Default)) + { + dataSourceProperties.Add(ExtractPropertyModel(property, attr)); + break; + } + } + } + + // Check if property returns IAsyncInitializer (only if the type itself implements IAsyncInitializer) + if (implementsIAsyncInitializer && property.GetMethod != null && !property.IsStatic && !property.IsIndexer) + { + if (property.Type is INamedTypeSymbol propertyType) + { + if (propertyType.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default) || + SymbolEqualityComparer.Default.Equals(propertyType, asyncInitializerInterface)) + { + initializerProperties.Add(new InitializerPropertyModel + { + PropertyName = property.Name, + PropertyTypeFullyQualified = property.Type.GloballyQualified() + }); + } + } + } + } + + currentType = currentType.BaseType; + } + + // Only return a model if the type has something relevant + var hasDataSourceProperties = dataSourceProperties.Count > 0; + var hasInitializerProperties = initializerProperties.Count > 0; + + if (!hasDataSourceProperties && !hasInitializerProperties) + return null; + + return new ConcreteGenericTypeModel + { + ConcreteTypeFullyQualified = concreteType.GloballyQualified(), + SafeTypeName = GetSafeClassName(concreteType), + ImplementsIAsyncInitializer = implementsIAsyncInitializer, + HasDataSourceProperties = hasDataSourceProperties, + DataSourceProperties = new EquatableArray(dataSourceProperties.ToArray()), + InitializerProperties = new EquatableArray(initializerProperties.ToArray()) + }; + } + + /// + /// Deduplicates concrete generic type models by their fully qualified name. + /// + private static IEnumerable DeduplicateConcreteGenericTypes( + ImmutableArray types) + { + var seen = new HashSet(); + foreach (var type in types) + { + if (seen.Add(type.ConcreteTypeFullyQualified)) + { + yield return type; + } + } + } + + #endregion + #region Code Generation private static void GeneratePropertyInjectionSource(SourceProductionContext context, ClassPropertyInjectionModel model) @@ -404,6 +676,105 @@ private static void GenerateInitializerPropertySource(SourceProductionContext co context.AddSource(fileName, sb.ToString()); } + /// + /// Generates property injection source for a concrete generic type. + /// Similar to GeneratePropertyInjectionSource but uses ConcreteGenericTypeModel. + /// + private static void GenerateGenericPropertyInjectionSource(SourceProductionContext context, ConcreteGenericTypeModel model) + { + var sourceClassName = $"PropertyInjectionSource_Generic_{Math.Abs(model.ConcreteTypeFullyQualified.GetHashCode()):x}"; + var fileName = $"{model.SafeTypeName}_Generic_PropertyInjection.g.cs"; + + var sb = new StringBuilder(); + WriteFileHeader(sb); + + // Module initializer + sb.AppendLine($"internal static class {model.SafeTypeName}_Generic_PropertyInjectionInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine(" public static void Initialize()"); + sb.AppendLine(" {"); + sb.AppendLine($" global::TUnit.Core.PropertySourceRegistry.Register(typeof({model.ConcreteTypeFullyQualified}), new {sourceClassName}());"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + sb.AppendLine(); + + // Property source class + sb.AppendLine($"internal sealed class {sourceClassName} : IPropertySource"); + sb.AppendLine("{"); + sb.AppendLine($" public Type Type => typeof({model.ConcreteTypeFullyQualified});"); + sb.AppendLine(" public bool ShouldInitialize => true;"); + sb.AppendLine(); + + // Generate UnsafeAccessor methods for init-only properties + foreach (var prop in model.DataSourceProperties) + { + if (prop.IsInitOnly) + { + var backingFieldName = $"<{prop.PropertyName}>k__BackingField"; + sb.AppendLine("#if NET8_0_OR_GREATER"); + sb.AppendLine($" [global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = \"{backingFieldName}\")]"); + sb.AppendLine($" private static extern ref {prop.PropertyTypeFullyQualified} Get{prop.PropertyName}BackingField({prop.ContainingTypeFullyQualified} instance);"); + sb.AppendLine("#endif"); + sb.AppendLine(); + } + } + + // GetPropertyMetadata method + sb.AppendLine(" public IEnumerable GetPropertyMetadata()"); + sb.AppendLine(" {"); + + foreach (var prop in model.DataSourceProperties) + { + GeneratePropertyMetadata(sb, prop, model.ConcreteTypeFullyQualified); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource(fileName, sb.ToString()); + } + + /// + /// Generates initializer property source for a concrete generic type. + /// Similar to GenerateInitializerPropertySource but uses ConcreteGenericTypeModel. + /// + private static void GenerateGenericInitializerPropertySource(SourceProductionContext context, ConcreteGenericTypeModel model) + { + var fileName = $"{model.SafeTypeName}_Generic_InitializerProperties.g.cs"; + + var sb = new StringBuilder(); + sb.AppendLine("using System;"); + sb.AppendLine("using TUnit.Core.Discovery;"); + sb.AppendLine(); + sb.AppendLine("namespace TUnit.Generated;"); + sb.AppendLine(); + + sb.AppendLine($"internal static class {model.SafeTypeName}_Generic_InitializerPropertiesInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine(" public static void Initialize()"); + sb.AppendLine(" {"); + sb.AppendLine($" InitializerPropertyRegistry.Register(typeof({model.ConcreteTypeFullyQualified}), new InitializerPropertyInfo[]"); + sb.AppendLine(" {"); + + foreach (var prop in model.InitializerProperties) + { + sb.AppendLine(" new InitializerPropertyInfo"); + sb.AppendLine(" {"); + sb.AppendLine($" PropertyName = \"{prop.PropertyName}\","); + sb.AppendLine($" PropertyType = typeof({prop.PropertyTypeFullyQualified}),"); + sb.AppendLine($" GetValue = static obj => (({model.ConcreteTypeFullyQualified})obj).{prop.PropertyName}"); + sb.AppendLine(" },"); + } + + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource(fileName, sb.ToString()); + } + #endregion #region Helper Methods diff --git a/TUnit.Core.SourceGenerator/Models/Extracted/PropertyInjectionModel.cs b/TUnit.Core.SourceGenerator/Models/Extracted/PropertyInjectionModel.cs index 8ab6acbf34..71af56a4bd 100644 --- a/TUnit.Core.SourceGenerator/Models/Extracted/PropertyInjectionModel.cs +++ b/TUnit.Core.SourceGenerator/Models/Extracted/PropertyInjectionModel.cs @@ -220,3 +220,66 @@ public override int GetHashCode() } } } + +/// +/// Model for a concrete instantiation of a generic type discovered at compile time. +/// Used for generating source metadata for generic types (e.g., CustomWebApplicationFactory<Program>). +/// +internal sealed record ConcreteGenericTypeModel : IEquatable +{ + /// + /// Fully qualified name of the concrete type (e.g., "global::MyNamespace.GenericClass<System.String>") + /// + public required string ConcreteTypeFullyQualified { get; init; } + + /// + /// Safe type name for use in file names and class names + /// + public required string SafeTypeName { get; init; } + + /// + /// Whether this type implements IAsyncInitializer + /// + public required bool ImplementsIAsyncInitializer { get; init; } + + /// + /// Whether this type (or its base types) has properties with IDataSourceAttribute + /// + public required bool HasDataSourceProperties { get; init; } + + /// + /// Properties with IDataSourceAttribute (from this type and base types) + /// + public required EquatableArray DataSourceProperties { get; init; } + + /// + /// Properties that return IAsyncInitializer types + /// + public required EquatableArray InitializerProperties { get; init; } + + public bool Equals(ConcreteGenericTypeModel? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return ConcreteTypeFullyQualified == other.ConcreteTypeFullyQualified + && SafeTypeName == other.SafeTypeName + && ImplementsIAsyncInitializer == other.ImplementsIAsyncInitializer + && HasDataSourceProperties == other.HasDataSourceProperties + && DataSourceProperties.Equals(other.DataSourceProperties) + && InitializerProperties.Equals(other.InitializerProperties); + } + + public override int GetHashCode() + { + unchecked + { + var hash = ConcreteTypeFullyQualified.GetHashCode(); + hash = (hash * 397) ^ SafeTypeName.GetHashCode(); + hash = (hash * 397) ^ ImplementsIAsyncInitializer.GetHashCode(); + hash = (hash * 397) ^ HasDataSourceProperties.GetHashCode(); + hash = (hash * 397) ^ DataSourceProperties.GetHashCode(); + hash = (hash * 397) ^ InitializerProperties.GetHashCode(); + return hash; + } + } +} diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index a24396c037..fe0bf8b8c6 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -342,24 +342,18 @@ private static void TraverseInjectableProperties( return; } - // The two modes differ in how they choose source-gen vs reflection: - // - Standard mode: Uses plan.SourceGeneratedProperties.Length > 0 - // - Tracking mode: Uses SourceRegistrar.IsEnabled - bool useSourceGen = useSourceRegistrarCheck - ? SourceRegistrar.IsEnabled - : plan.SourceGeneratedProperties.Length > 0; + // Both modes should use source-gen ONLY if we actually have source-generated properties. + // This ensures fallback to reflection when source-gen is enabled but no metadata was generated + // for a specific type (e.g., user's WebApplicationFactory that wasn't processed by the generator). + bool useSourceGen = plan.SourceGeneratedProperties.Length > 0; if (useSourceGen) { TraverseSourceGeneratedProperties(obj, plan.SourceGeneratedProperties, tryAdd, recurse, currentDepth, cancellationToken); } - else + else if (plan.ReflectionProperties.Length > 0) { - var reflectionProps = useSourceRegistrarCheck - ? plan.ReflectionProperties - : (plan.ReflectionProperties.Length > 0 ? plan.ReflectionProperties : []); - - TraverseReflectionProperties(obj, reflectionProps, tryAdd, recurse, currentDepth, cancellationToken); + TraverseReflectionProperties(obj, plan.ReflectionProperties, tryAdd, recurse, currentDepth, cancellationToken); } } @@ -443,25 +437,47 @@ private static void TraverseInitializerProperties( return; } - // Try source-generated metadata first (AOT-compatible) - var registeredProperties = InitializerPropertyRegistry.GetProperties(type); - if (registeredProperties != null) + // Track processed property names to handle overrides correctly + // (derived class properties take precedence over base class properties) + var processedPropertyNames = new HashSet(StringComparer.Ordinal); + var hasAnySourceGenRegistration = false; + + // Walk up the inheritance chain to find all IAsyncInitializer properties + // This ensures base class properties are discovered even when derived class has source-gen registration + var currentType = type; + while (currentType != null && currentType != typeof(object)) { - TraverseRegisteredInitializerProperties(obj, type, registeredProperties, tryAdd, recurse, currentDepth, cancellationToken); - return; + cancellationToken.ThrowIfCancellationRequested(); + + var registeredProperties = InitializerPropertyRegistry.GetProperties(currentType); + if (registeredProperties != null) + { + hasAnySourceGenRegistration = true; + TraverseRegisteredInitializerPropertiesWithTracking( + obj, currentType, registeredProperties, processedPropertyNames, + tryAdd, recurse, currentDepth, cancellationToken); + } + + currentType = currentType.BaseType; } - // Fall back to reflection (non-AOT path) - TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); + // If no source-gen registration was found in the entire hierarchy, fall back to reflection + // Reflection path already handles inheritance correctly via GetProperties without DeclaredOnly + if (!hasAnySourceGenRegistration) + { + TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); + } } /// - /// Traverses IAsyncInitializer properties using source-generated metadata (AOT-compatible). + /// Traverses source-generated IAsyncInitializer properties with property name tracking. + /// Skips properties that have already been processed (handles overrides in derived classes). /// - private static void TraverseRegisteredInitializerProperties( + private static void TraverseRegisteredInitializerPropertiesWithTracking( object obj, Type type, InitializerPropertyInfo[] properties, + HashSet processedPropertyNames, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, @@ -471,6 +487,12 @@ private static void TraverseRegisteredInitializerProperties( { cancellationToken.ThrowIfCancellationRequested(); + // Skip if already processed (overridden in derived class) + if (!processedPropertyNames.Add(propInfo.PropertyName)) + { + continue; + } + try { var value = propInfo.GetValue(obj); diff --git a/TUnit.TestProject/GenericPropertyInjectionTests.cs b/TUnit.TestProject/GenericPropertyInjectionTests.cs new file mode 100644 index 0000000000..1dd445383b --- /dev/null +++ b/TUnit.TestProject/GenericPropertyInjectionTests.cs @@ -0,0 +1,464 @@ +#pragma warning disable TUnit0042 + +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +#region Base Classes and Fixtures + +/// +/// Generic base class with a property that has a ClassDataSource attribute. +/// This simulates WebApplicationFactory-style fixtures. +/// +public abstract class GenericFixtureBase where TProgram : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Postgres { get; init; } +} + +/// +/// Simulates a database fixture that implements IAsyncInitializer. +/// +public class InMemoryDatabase : IAsyncInitializer, IAsyncDisposable +{ + public bool IsInitialized { get; private set; } + public string? ConnectionString { get; private set; } + + public Task InitializeAsync() + { + Console.WriteLine(@"Initializing InMemoryDatabase"); + IsInitialized = true; + ConnectionString = "Server=localhost;Database=test"; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Console.WriteLine(@"Disposing InMemoryDatabase"); + return default; + } +} + +/// +/// Intermediate class in the inheritance chain. +/// +public abstract class IntermediateBase : GenericFixtureBase where T : class +{ +} + +/// +/// Generic class that implements IAsyncInitializer with nested IAsyncInitializer properties. +/// This tests Pipeline 5 (generic IAsyncInitializer property generation). +/// +public class GenericInitializerFixture : IAsyncInitializer where T : class +{ + public InMemoryDatabase? Database { get; init; } + public bool IsInitialized { get; private set; } + + public Task InitializeAsync() + { + Console.WriteLine($"Initializing GenericInitializerFixture<{typeof(T).Name}>"); + IsInitialized = true; + return Task.CompletedTask; + } +} + +/// +/// Generic base with multiple properties having data source attributes. +/// +public abstract class MultiPropertyGenericBase where T : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? FirstDb { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public SecondaryDatabase? SecondDb { get; init; } +} + +/// +/// Secondary database fixture for testing multiple properties. +/// +public class SecondaryDatabase : IAsyncInitializer +{ + public bool IsInitialized { get; private set; } + + public Task InitializeAsync() + { + Console.WriteLine(@"Initializing SecondaryDatabase"); + IsInitialized = true; + return Task.CompletedTask; + } +} + +/// +/// Generic base with multiple type parameters. +/// +public abstract class MultiTypeParamBase where T1 : class where T2 : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Database { get; init; } +} + +/// +/// Grandparent in deep inheritance chain. +/// +public abstract class GrandparentBase where T : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? GrandparentDb { get; init; } +} + +/// +/// Parent in deep inheritance chain with its own property. +/// +public abstract class ParentBase : GrandparentBase where T : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public SecondaryDatabase? ParentDb { get; init; } +} + +#endregion + +#region Test Classes + +/// +/// Tests to verify that property injection works correctly for classes +/// that inherit from generic base classes. +/// This is the scenario from issue #4431. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(GenericPropertyInjectionTests))] +public class GenericPropertyInjectionTests : GenericFixtureBase +{ + [Test] + public async Task GenericBase_PropertyInjection_Works() + { + // The Postgres property should be injected and initialized + // before this test runs + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.IsInitialized).IsTrue(); + await Assert.That(Postgres.ConnectionString).IsEqualTo("Server=localhost;Database=test"); + } + + public class TestProgram { } +} + +/// +/// Test that verifies multiple concrete instantiations of the same generic. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(MultipleGenericInstantiationTests))] +public class MultipleGenericInstantiationTests : GenericFixtureBase +{ + [Test] + public async Task DifferentTypeArg_AlsoWorks() + { + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.IsInitialized).IsTrue(); + } + + public class OtherProgram { } +} + +/// +/// Test for deeply nested inheritance with generics. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(DeepInheritanceGenericTests))] +public class DeepInheritanceGenericTests : IntermediateBase +{ + [Test] + public async Task DeepInheritance_PropertyInjection_Works() + { + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.IsInitialized).IsTrue(); + } + + public class DeepProgram { } +} + +/// +/// Test that uses a generic IAsyncInitializer via ClassDataSource. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(GenericInitializerPropertyTests))] +public class GenericInitializerPropertyTests +{ + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public GenericInitializerFixture? Fixture { get; init; } + + [Test] + public async Task GenericInitializer_IsDiscovered() + { + await Assert.That(Fixture).IsNotNull(); + await Assert.That(Fixture!.IsInitialized).IsTrue(); + } +} + +/// +/// Test for multiple properties on a generic base class. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(MultiplePropertiesGenericTests))] +public class MultiplePropertiesGenericTests : MultiPropertyGenericBase +{ + [Test] + public async Task MultipleProperties_AllInjected() + { + await Assert.That(FirstDb).IsNotNull(); + await Assert.That(FirstDb!.IsInitialized).IsTrue(); + + await Assert.That(SecondDb).IsNotNull(); + await Assert.That(SecondDb!.IsInitialized).IsTrue(); + } + + public class TestProgram { } +} + +/// +/// Test for generic base with multiple type parameters. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(MultiTypeParameterTests))] +public class MultiTypeParameterTests : MultiTypeParamBase +{ + [Test] + public async Task MultiTypeParams_PropertyInjection_Works() + { + await Assert.That(Database).IsNotNull(); + await Assert.That(Database!.IsInitialized).IsTrue(); + } + + public class Program1 { } + public class Program2 { } +} + +/// +/// Test for deep inheritance with properties at each level. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(DeepInheritanceMultiLevelPropertyTests))] +public class DeepInheritanceMultiLevelPropertyTests : ParentBase +{ + [Test] + public async Task DeepInheritance_AllLevelProperties_Injected() + { + // Property from grandparent + await Assert.That(GrandparentDb).IsNotNull(); + await Assert.That(GrandparentDb!.IsInitialized).IsTrue(); + + // Property from parent + await Assert.That(ParentDb).IsNotNull(); + await Assert.That(ParentDb!.IsInitialized).IsTrue(); + } + + public class TestProgram { } +} + +/// +/// Test for mix of generic base properties and derived class properties. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(MixedGenericAndDerivedPropertiesTests))] +public class MixedGenericAndDerivedPropertiesTests : GenericFixtureBase +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public SecondaryDatabase? DerivedDatabase { get; init; } + + [Test] + public async Task MixedProperties_BothInjected() + { + // Property from generic base + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.IsInitialized).IsTrue(); + + // Property from derived class + await Assert.That(DerivedDatabase).IsNotNull(); + await Assert.That(DerivedDatabase!.IsInitialized).IsTrue(); + } + + public class TestProgram { } +} + +/// +/// Test using a nested generic type as the type argument. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(NestedGenericTypeArgumentTests))] +public class NestedGenericTypeArgumentTests : GenericFixtureBase> +{ + [Test] + public async Task NestedGenericTypeArg_PropertyInjection_Works() + { + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.IsInitialized).IsTrue(); + } +} + +/// +/// Test for generic IAsyncInitializer with nested IAsyncInitializer properties. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(GenericInitializerWithNestedPropertyTests))] +public class GenericInitializerWithNestedPropertyTests +{ + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public GenericInitializerFixture? Fixture { get; init; } + + [Test] + public async Task GenericInitializer_NestedProperty_Works() + { + await Assert.That(Fixture).IsNotNull(); + await Assert.That(Fixture!.IsInitialized).IsTrue(); + // The Database property should be discovered via InitializerPropertyRegistry + // This verifies Pipeline 5 is working for generic types + } +} + +#endregion + +#region Issue 4431 - Exact Scenarios from GitHub Issue + +/// +/// Simulates the WebApplicationFactory scenario from issue #4431. +/// This is a generic factory that requires async initialization. +/// +public class CustomWebApplicationFactory : IAsyncInitializer, IAsyncDisposable + where TProgram : class +{ + public bool IsInitialized { get; private set; } + public string? ConfiguredConnectionString { get; private set; } + + // Simulates a dependency like a test container + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? TestDatabase { get; init; } + + public async Task InitializeAsync() + { + Console.WriteLine($"CustomWebApplicationFactory<{typeof(TProgram).Name}>.InitializeAsync starting"); + + // In the real scenario, this would configure the web host with the test database + // The test database should already be initialized at this point + if (TestDatabase == null) + { + throw new InvalidOperationException("TestDatabase was not injected!"); + } + + if (!TestDatabase.IsInitialized) + { + throw new InvalidOperationException("TestDatabase was not initialized before factory!"); + } + + ConfiguredConnectionString = TestDatabase.ConnectionString; + IsInitialized = true; + + Console.WriteLine($"CustomWebApplicationFactory<{typeof(TProgram).Name}>.InitializeAsync completed"); + await Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Console.WriteLine($"CustomWebApplicationFactory<{typeof(TProgram).Name}>.DisposeAsync"); + return default; + } +} + +/// +/// Issue #4431 - Test that replicates the WebApplicationFactory scenario. +/// The factory should be initialized AFTER its dependencies (like test containers). +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(Issue4431_WebApplicationFactoryScenarioTests))] +public class Issue4431_WebApplicationFactoryScenarioTests +{ + public class TestProgram { } + + [ClassDataSource>(Shared = SharedType.PerTestSession)] + public CustomWebApplicationFactory? Factory { get; init; } + + [Test] + public async Task WebApplicationFactory_InitializedAfterDependencies() + { + // Factory should be injected and initialized + await Assert.That(Factory).IsNotNull(); + await Assert.That(Factory!.IsInitialized).IsTrue(); + + // Factory's dependencies should have been initialized first + await Assert.That(Factory.TestDatabase).IsNotNull(); + await Assert.That(Factory.TestDatabase!.IsInitialized).IsTrue(); + + // Factory should have configured itself with the test database + await Assert.That(Factory.ConfiguredConnectionString).IsEqualTo("Server=localhost;Database=test"); + } +} + +/// +/// Issue #4431 Comment - ParentTest scenario. +/// This is a non-generic class with ClassDataSource property - should work. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(Issue4431_ParentTestScenario))] +public class Issue4431_ParentTestScenario +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? Postgres { get; init; } + + [Test] + public async Task ParentTest_Should_Succeed() + { + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.ConnectionString).IsNotNull(); + } +} + +/// +/// Issue #4431 Comment - SecondChildTest scenario. +/// This inherits from a non-generic base with ClassDataSource property - should work. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(Issue4431_SecondChildTestScenario))] +public class Issue4431_SecondChildTestScenario : Issue4431_ParentTestScenario +{ + [Test] + public async Task SecondChildTest_Should_Succeed() + { + // Inherited property should be injected + await Assert.That(Postgres).IsNotNull(); + await Assert.That(Postgres!.ConnectionString).IsNotNull(); + } +} + +/// +/// Abstract base class that simulates WebApplicationFactory-style inheritance. +/// This is the exact pattern from the issue's attached zip file. +/// +public abstract class WebAppFactoryBase + where TFactory : CustomWebApplicationFactory + where TProgram : class +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public InMemoryDatabase? SharedDatabase { get; init; } +} + +/// +/// Issue #4431 - Complex generic inheritance scenario from the attached zip. +/// Tests a class inheriting from a generic base with multiple type parameters. +/// +[EngineTest(ExpectedResult.Pass)] +[NotInParallel(nameof(Issue4431_ComplexGenericInheritanceTests))] +public class Issue4431_ComplexGenericInheritanceTests + : WebAppFactoryBase, Issue4431_ComplexGenericInheritanceTests.MyProgram> +{ + public class MyProgram { } + + [Test] + public async Task ComplexGenericInheritance_PropertyInjection_Works() + { + // Property from generic base should be injected + await Assert.That(SharedDatabase).IsNotNull(); + await Assert.That(SharedDatabase!.IsInitialized).IsTrue(); + } +} + +#endregion diff --git a/docs/plans/2026-01-15-generic-type-source-generation-design.md b/docs/plans/2026-01-15-generic-type-source-generation-design.md new file mode 100644 index 0000000000..d7e1777a29 --- /dev/null +++ b/docs/plans/2026-01-15-generic-type-source-generation-design.md @@ -0,0 +1,312 @@ +# Generic Type Source Generation Design + +**Date:** 2026-01-15 +**Status:** Proposed +**Issue:** #4431 +**PR:** #4434 + +## Problem Statement + +The `PropertyInjectionSourceGenerator` currently skips generic types entirely: + +```csharp +// PropertyInjectionSourceGenerator.cs lines 103-105 +if (containingType.IsUnboundGenericType || containingType.TypeParameters.Length > 0) + return null; +``` + +This means generic types like `CustomWebApplicationFactory` never get source-generated metadata for: +- Properties with `IDataSourceAttribute` (e.g., `[ClassDataSource]`) +- Nested `IAsyncInitializer` properties + +**Impact:** +- In non-AOT scenarios, the reflection fallback works but is suboptimal +- In AOT scenarios, this is completely broken - no metadata means no initialization + +## Solution Overview + +Generate source metadata for **concrete instantiations** of generic types discovered at compile time, while keeping the reflection fallback for runtime-only types. + +### Discovery Sources + +1. **Inheritance chains** - `class MyTests : GenericBase` +2. **`IDataSourceAttribute` type arguments** - `[SomeDataSource>]` +3. **Base type arguments** - Walking up inheritance hierarchies + +### Key Principle + +Once we discover a concrete type like `CustomWebApplicationFactory`, we treat it identically to a non-generic type for code generation. + +## Architecture + +### Current State + +``` +PropertyInjectionSourceGenerator +├── Pipeline 1: Property Data Sources (non-generic types only) +└── Pipeline 2: IAsyncInitializer Properties (non-generic types only) +``` + +### Proposed State + +``` +PropertyInjectionSourceGenerator +├── Pipeline 1: Property Data Sources (non-generic types) +├── Pipeline 2: IAsyncInitializer Properties (non-generic types) +├── Pipeline 3: Concrete Generic Type Discovery +│ └── Scans compilation for all concrete instantiations +├── Pipeline 4: Generic Property Data Sources +│ └── Generates PropertySource for concrete generic types +└── Pipeline 5: Generic IAsyncInitializer Properties + └── Generates InitializerPropertyRegistry for concrete generic types +``` + +## Detailed Design + +### Pipeline 3: Concrete Generic Type Discovery + +**Model:** + +```csharp +record ConcreteGenericTypeModel +{ + INamedTypeSymbol ConcreteType { get; } // e.g., CustomWebApplicationFactory + INamedTypeSymbol GenericDefinition { get; } // e.g., CustomWebApplicationFactory<> + string SafeTypeName { get; } // For file naming +} +``` + +**Discovery Implementation:** + +```csharp +var concreteGenericTypes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is TypeDeclarationSyntax or PropertyDeclarationSyntax, + transform: static (ctx, _) => ExtractConcreteGenericTypes(ctx)) + .Where(static x => x.Length > 0) + .SelectMany(static (x, _) => x) + .Collect() + .Select(static (types, _) => types.Distinct(SymbolEqualityComparer.Default)); +``` + +**Discovery from Inheritance:** + +```csharp +private static IEnumerable DiscoverFromInheritance(INamedTypeSymbol type) +{ + var baseType = type.BaseType; + while (baseType != null && baseType.SpecialType != SpecialType.System_Object) + { + if (baseType.IsGenericType && !baseType.IsUnboundGenericType) + { + yield return baseType; // Concrete generic like GenericBase + } + baseType = baseType.BaseType; + } +} +``` + +**Discovery from IDataSourceAttribute:** + +```csharp +private static IEnumerable DiscoverFromAttributes( + IPropertySymbol property, + INamedTypeSymbol dataSourceInterface) +{ + foreach (var attr in property.GetAttributes()) + { + if (attr.AttributeClass?.AllInterfaces.Contains(dataSourceInterface) != true) + continue; + + // Check attribute type arguments + if (attr.AttributeClass is { IsGenericType: true, TypeArguments.Length: > 0 }) + { + foreach (var typeArg in attr.AttributeClass.TypeArguments) + { + if (typeArg is INamedTypeSymbol { IsGenericType: true, IsUnboundGenericType: false } concreteGeneric) + { + yield return concreteGeneric; + } + } + } + } +} +``` + +### Pipeline 4 & 5: Generation for Concrete Generic Types + +Reuses the same generation logic as Pipelines 1 & 2, just with concrete generic types. + +**Example Generated Output:** + +```csharp +// For CustomWebApplicationFactory +internal static class CustomWebApplicationFactory_Program_PropertyInjectionInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + PropertySourceRegistry.Register( + typeof(CustomWebApplicationFactory), + new CustomWebApplicationFactory_Program_PropertySource()); + } +} + +internal sealed class CustomWebApplicationFactory_Program_PropertySource : IPropertySource +{ + public Type Type => typeof(CustomWebApplicationFactory); + public bool ShouldInitialize => true; + + public IEnumerable GetPropertyMetadata() + { + yield return new PropertyInjectionMetadata + { + PropertyName = "Postgres", + PropertyType = typeof(InMemoryPostgres), + ContainingType = typeof(CustomWebApplicationFactory), + CreateDataSource = () => new ClassDataSourceAttribute + { + Shared = SharedType.PerTestSession + }, + SetProperty = (instance, value) => + ((CustomWebApplicationFactory)instance).Postgres = (InMemoryPostgres)value + }; + } +} +``` + +### Deduplication + +The same concrete type might be discovered from multiple sources. Deduplication uses `SymbolEqualityComparer.Default` on the collected types before generation. + +**Safe File Naming:** + +```csharp +private static string GetSafeTypeName(INamedTypeSymbol concreteType) +{ + var fullName = concreteType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return fullName + .Replace("global::", "") + .Replace("<", "_") + .Replace(">", "") + .Replace(",", "_") + .Replace(".", "_") + .Replace(" ", ""); +} +``` + +### Inheritance Chain Handling + +When discovering `CustomWebApplicationFactory`, also generate for generic base types: + +``` +CustomWebApplicationFactory + └── TestWebApplicationFactory (generate metadata) + └── WebApplicationFactory (generate metadata if relevant) +``` + +## Implementation Plan + +### Phase 1: Core Discovery Infrastructure +1. Create `ConcreteGenericTypeDiscoverer` helper class +2. Implement discovery from inheritance chains +3. Implement discovery from `IDataSourceAttribute` type arguments +4. Add deduplication logic + +### Phase 2: Extend PropertyInjectionSourceGenerator +1. Add Pipeline 3: Concrete generic type collection +2. Add Pipeline 4: Generic property data source generation +3. Add Pipeline 5: Generic IAsyncInitializer property generation +4. Update safe file naming for generic type arguments + +### Phase 3: Handle Inheritance Chains +1. Walk up base types when discovering concrete generic type +2. Construct concrete version of each generic base type +3. Generate metadata for each hierarchy level + +### Phase 4: Testing +1. Source generator unit tests for generic type scenarios +2. Integration tests for end-to-end behavior +3. Specific test for issue #4431 scenario +4. AOT compatibility verification + +### Phase 5: Cleanup +1. Update PR #4434 with complete fix +2. Update documentation if needed + +## Testing Strategy + +### Unit Tests (Source Generator) + +```csharp +// Generic class with IDataSourceAttribute property +[Fact] +public async Task GenericClass_WithDataSourceProperty_GeneratesMetadata(); + +// Generic class implementing IAsyncInitializer +[Fact] +public async Task GenericClass_ImplementingIAsyncInitializer_GeneratesMetadata(); + +// Discovery via inheritance +[Fact] +public async Task Discovery_ViaInheritance_FindsConcreteType(); + +// Discovery via IDataSourceAttribute type argument +[Fact] +public async Task Discovery_ViaDataSourceAttribute_FindsConcreteType(); + +// Nested generics +[Fact] +public async Task Discovery_NestedGenerics_FindsAllConcreteTypes(); + +// Inheritance chain walking +[Fact] +public async Task Discovery_WalksInheritanceChain_FindsBaseTypes(); + +// Deduplication +[Fact] +public async Task Discovery_DuplicateUsages_GeneratesOnce(); +``` + +### Integration Tests (Engine) + +```csharp +// Issue #4431 scenario +[Fact] +public async Task GenericWebApplicationFactory_InitializesNestedInitializers(); + +// Shared data source with generic fixture +[Fact] +public async Task GenericFixture_SharedDataSource_InitializedBeforeTest(); + +// Multiple instantiations +[Fact] +public async Task SameGeneric_DifferentTypeArgs_BothWork(); +``` + +### AOT Verification + +```csharp +// Verify source-gen metadata exists +[Fact] +public async Task GenericTypes_HaveSourceGenMetadata_NoReflectionFallback(); +``` + +## File Changes + +- `PropertyInjectionSourceGenerator.cs` - Major changes (new pipelines) +- New: `ConcreteGenericTypeDiscoverer.cs` - Discovery helper +- New: `ConcreteGenericTypeModel.cs` - Model for discovered types +- New tests in `TUnit.Core.SourceGenerator.Tests` +- New tests in `TUnit.Engine.Tests` + +## Backward Compatibility + +- Fully backward compatible +- Non-generic types continue to work unchanged +- Generic types that previously fell back to reflection now get source-gen metadata +- Reflection fallback remains for runtime-only types (non-AOT scenarios) + +## Open Questions + +None - design is complete and ready for implementation.