Skip to content

Conversation

@thomhurst
Copy link
Owner

@thomhurst thomhurst commented Jan 15, 2026

Summary

Comprehensive fix for generic type support in property injection, ensuring AOT compatibility for patterns like WebApplicationFactory<TProgram>.

Changes

  1. Source generation for generic types - Added three new pipelines to discover and generate metadata for concrete generic type instantiations:

    • Pipeline 3: Discovers generic types from inheritance chains (e.g., MyTests : GenericBase<MyProgram>)
    • Pipeline 4: Discovers generic types from ClassDataSource<T> attributes
    • Pipeline 5: Generates InitializerPropertyRegistry for generic IAsyncInitializer types
  2. Fix incorrect source-gen vs reflection fallback - When SourceRegistrar.IsEnabled is true globally but no source-generated metadata exists for a specific type, the code now properly falls back to reflection

  3. Walk inheritance chain for IAsyncInitializer properties - When a derived class has source-gen registration, base class IAsyncInitializer properties are now also discovered

Problem

When using ClassDataSource with generic types like CustomWebApplicationFactory<TProgram> that have IAsyncInitializer properties, the source generator was skipping these types entirely because they were generic. This forced a fallback to reflection which breaks AOT compatibility.

Example pattern that was broken:

public class CustomWebApplicationFactory<TProgram> : IAsyncInitializer
    where TProgram : class
{
    [ClassDataSource<InMemoryDatabase>(Shared = SharedType.PerTestSession)]
    public InMemoryDatabase? TestDatabase { get; init; }

    public async Task InitializeAsync()
    {
        // TestDatabase was null - not injected!
    }
}

Solution

The source generator now:

  1. Walks the inheritance chain of test classes to find concrete generic instantiations
  2. Examines ClassDataSource<T> attribute type arguments for generic types
  3. Generates PropertySourceRegistry.Register and InitializerPropertyRegistry.Register calls for these concrete types

Test plan

  • 12 source generator unit tests pass
  • 14 integration tests pass (including 4 issue-specific scenarios)
  • All existing TUnit.Core.SourceGenerator.Tests pass (376 tests)
  • Manual verification with exact patterns from issue [Bug]: TUnit.AspNetCore execution order #4431

Test coverage includes:

  • Generic base class with ClassDataSource property
  • Multiple concrete instantiations of same generic
  • Deep inheritance chains with generics
  • Generic IAsyncInitializer with ClassDataSource
  • Multiple properties on generic base
  • Multiple type parameters
  • Deep inheritance with properties at each level
  • Mix of generic base and derived class properties
  • Nested generic type arguments (e.g., List<string>)
  • WebApplicationFactory pattern with nested dependencies

Fixes #4431

🤖 Generated with Claude Code

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 <[email protected]>
@thomhurst
Copy link
Owner Author

Summary

Fixes incorrect source-gen vs reflection fallback logic in object graph discovery to properly handle types without source-generated metadata.

Critical Issues

None found ✅

Suggestions

1. Consider updating the comment for clarity

The comment at line 345-348 (now lines 345-347 after the change) could be updated to better reflect the new unified behavior:

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

This is already quite clear, but you might consider simplifying it to:

// Use source-gen only when metadata is available for this specific type.
// Falls back to reflection when source-gen is globally enabled but this type
// wasn't processed by the generator (e.g., user types like WebApplicationFactory).

2. Verify dual-mode behavior

According to TUnit's Rule #1 (Dual-Mode Implementation), changes to metadata collection should be tested in both source-gen and reflection modes. While the fix looks correct, consider:

  • Adding a test case that verifies a type with SourceRegistrar.IsEnabled = true but no source-generated metadata properly falls back to reflection
  • This would prevent regression of issue [Bug]: TUnit.AspNetCore execution order #4431

3. Alignment with PropertyInjectionPlanBuilder

The change correctly aligns with the existing fallback logic in PropertyInjectionPlanBuilder.Build() (lines 120-142), which already checks plan.HasProperties rather than SourceRegistrar.IsEnabled. Good consistency! 👍

Verdict

APPROVE - The fix correctly addresses the root cause by ensuring both modes check for actual metadata presence rather than global flags. The simplification also improves code clarity by removing the dual-mode branching logic.

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 <[email protected]>
@thomhurst
Copy link
Owner Author

Summary

Fixes incorrect source-gen vs reflection fallback logic and adds inheritance chain traversal for IAsyncInitializer properties.

Critical Issues

None found ✅

Suggestions

1. Consider edge case: Multiple inheritance levels with mixed source-gen registration

In TraverseInitializerProperties (lines 437-456), the code walks the inheritance chain and processes source-gen metadata from each type. However, there's a subtle edge case:

Current behavior: If ANY type in the hierarchy has source-gen registration, reflection is never used (even for types without registration in the chain).

Example scenario:

  • BaseClass has NO source-gen registration
  • DerivedClass has source-gen registration
  • DerivedClass inherits an IAsyncInitializer property from BaseClass

What happens: The loop finds registeredProperties != null for DerivedClass, sets hasAnySourceGenRegistration = true, then checks BaseClass which returns null from registry (so no properties are added from BaseClass). The reflection fallback is skipped because hasAnySourceGenRegistration = true.

Question: Is this the intended behavior? Should base class properties without source-gen metadata still be discovered via reflection when the derived class has source-gen? Or does the source generator always emit metadata for ALL inherited IAsyncInitializer properties (including from base classes)?

If the source generator handles inheritance correctly and always includes base class properties in the derived class's metadata, then this is fine. Otherwise, you might need to track hasAnySourceGenRegistration per-type rather than for the entire hierarchy.

2. Property name collision handling in TraverseRegisteredInitializerPropertiesWithTracking

The property name deduplication at line 491 uses only property name:

if (!processedPropertyNames.Add(propInfo.PropertyName))

This correctly handles same-named properties (overrides/new), but might incorrectly skip properties if:

  • Base class has property Foo
  • Derived class has different property also named Foo (using new keyword)

Though this is an extremely rare edge case and the current implementation (derived class processed first) means the derived class's Foo takes precedence, which is likely correct behavior.

Verdict

APPROVE - No critical issues

The fixes address real bugs and the implementation is sound. The suggestions are edge cases that may already be handled correctly by the source generator's metadata emission, but worth confirming.

thomhurst and others added 2 commits January 15, 2026 17:32
Design document for fixing the critical gap where source generator
skips generic types entirely. This breaks AOT compatibility for
generic test fixtures like WebApplicationFactory<TProgram>.

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 <[email protected]>
…ties

Implements three new pipelines in PropertyInjectionSourceGenerator:
- Pipeline 3: Discovers concrete generic types from inheritance chains
- Pipeline 4: Discovers concrete generic types from ClassDataSource<T> attributes
- Pipeline 5: Generates InitializerPropertyRegistry for generic IAsyncInitializer types

This enables AOT-compatible property injection for patterns like
WebApplicationFactory<TProgram> with ClassDataSource properties.

Fixes #4431

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@thomhurst thomhurst changed the title fix: ensure proper fallback to reflection in object graph discovery fix: add source generation for generic types with data source properties Jan 15, 2026
@thomhurst
Copy link
Owner Author

Summary

Adds source generation for concrete generic type instantiations to support WebApplicationFactory-style patterns in AOT scenarios.

Critical Issues

None found ✅

Analysis

✅ Dual-Mode Compliance (Rule #1)

PASS - Changes maintain dual-mode operation:

Source Generator (TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs:70-106)

  • New Pipelines 3-5 discover concrete generic types at compile-time
  • Generates PropertySourceRegistry and InitializerPropertyRegistry for generic types
  • Only processes concrete generics (skips open generic types at lines 104-105, 206-207)

Reflection Fallback (TUnit.Core/Discovery/ObjectGraphDiscoverer.cs:345-356)

  • Critical fix at ObjectGraphDiscoverer.cs:345-356: Changed from SourceRegistrar.IsEnabled check to plan.SourceGeneratedProperties.Length > 0
  • This properly implements fallback when source-gen is globally enabled but no metadata exists for a specific type (the exact issue from [Bug]: TUnit.AspNetCore execution order #4431)
  • Inheritance chain walking added (lines 437-466) ensures base class IAsyncInitializer properties are discovered even when derived class has source-gen registration

✅ AOT Compatibility (Rule #5)

PASS - All code is AOT-compatible:

  • Source generator uses only compile-time type analysis (no reflection)
  • Runtime code uses source-generated metadata first, falls back to reflection gracefully
  • New ConcreteGenericTypeModel uses only primitive/string fields (good for incremental caching)

✅ Snapshot Testing (Rule #2)

N/A - No existing snapshot files modified. This PR adds new functionality without changing existing source generator output format.

✅ Performance (Rule #4)

PASS - No performance concerns:

  • Generic type discovery reuses existing source generator infrastructure (incremental pipelines)
  • Inheritance chain walking is bounded and only occurs during initialization
  • Deduplication prevents redundant metadata generation (PropertyInjectionSourceGenerator.cs:76-86)

✅ No VSTest Dependencies (Rule #3)

PASS - No VSTest dependencies introduced.

Code Quality Observations

Strengths

  1. Comprehensive test coverage: 12 unit tests + 14 integration tests covering edge cases (deep inheritance, multiple type parameters, nested generics)
  2. Clear documentation: Inline comments explain the "why" (e.g., ObjectGraphDiscoverer.cs:345-347)
  3. Proper error handling: Maintains existing exception propagation for data source failures
  4. Design doc included: docs/plans/2026-01-15-generic-type-source-generation-design.md provides architectural context

Minor Observations (Non-blocking)

  1. Large PR size: 1,855 additions across 6 files. Well-structured but requires careful review
  2. Complex logic: Generic type discovery involves nested loops and recursion - tests provide good coverage

Verdict

APPROVE - No critical issues. This is a well-designed solution to issue #4431 that properly implements TUnit's dual-mode architecture for generic types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: TUnit.AspNetCore execution order

2 participants