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.