From 5954c2825b40264367e5c70c4ff7fb2ff4b2e54b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:05:57 +0000 Subject: [PATCH 1/4] fix: ensure proper fallback to reflection in object graph discovery When SourceRegistrar.IsEnabled is true globally but no source-generated metadata exists for a specific type (e.g., user's WebApplicationFactory), the code incorrectly used source-gen mode with an empty array instead of falling back to reflection. This caused nested IAsyncInitializer properties to not be discovered during object tracking, leading to them not being initialized before BeforeTest hooks run. The fix simplifies the logic: use source-gen mode only when plan.SourceGeneratedProperties actually has content, otherwise fall back to reflection. Fixes #4431 Co-Authored-By: Claude Opus 4.5 --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index a24396c037..d83c722baa 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); } } From 27cb11b3569996effa00032036c4b6c8ede80baa Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:17:44 +0000 Subject: [PATCH 2/4] fix: walk inheritance chain for IAsyncInitializer property discovery When a derived class has source-gen registration for IAsyncInitializer properties, base class properties with IAsyncInitializer were being missed because the code returned early after processing the derived class. Now TraverseInitializerProperties walks up the inheritance chain and processes source-gen metadata from all types in the hierarchy. Property name tracking ensures overridden properties in derived classes take precedence. If no type in the hierarchy has source-gen registration, we fall back to reflection which already handles inheritance correctly. Co-Authored-By: Claude Opus 4.5 --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index d83c722baa..fe0bf8b8c6 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -437,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, @@ -465,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); From bdf2fabc4a8ec7053bdc3abb59e834fca63421f3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:32:08 +0000 Subject: [PATCH 3/4] docs: add design for generic type source generation Design document for fixing the critical gap where source generator skips generic types entirely. This breaks AOT compatibility for generic test fixtures like WebApplicationFactory. The solution generates metadata for concrete instantiations of generic types discovered at compile time through: - Inheritance chains - IDataSourceAttribute type arguments - Base type arguments Relates to #4431 Co-Authored-By: Claude Opus 4.5 --- ...5-generic-type-source-generation-design.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/plans/2026-01-15-generic-type-source-generation-design.md 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. From 755f410c5616e20d35ad585a79e0f2ebc73eefef Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:13:58 +0000 Subject: [PATCH 4/4] feat: add source generation for generic types with data source properties Implements three new pipelines in PropertyInjectionSourceGenerator: - Pipeline 3: Discovers concrete generic types from inheritance chains - Pipeline 4: Discovers concrete generic types from ClassDataSource attributes - Pipeline 5: Generates InitializerPropertyRegistry for generic IAsyncInitializer types This enables AOT-compatible property injection for patterns like WebApplicationFactory with ClassDataSource properties. Fixes #4431 Co-Authored-By: Claude Opus 4.5 --- .../GenericPropertyInjectionRawTests.cs | 645 ++++++++++++++++++ .../PropertyInjectionSourceGenerator.cs | 371 ++++++++++ .../Extracted/PropertyInjectionModel.cs | 63 ++ .../GenericPropertyInjectionTests.cs | 464 +++++++++++++ 4 files changed, 1543 insertions(+) create mode 100644 TUnit.Core.SourceGenerator.Tests/GenericPropertyInjectionRawTests.cs create mode 100644 TUnit.TestProject/GenericPropertyInjectionTests.cs 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.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