From 4de473307d2d106bbaa1af5e91b1d6af3cb545a0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:56:27 +0000 Subject: [PATCH 1/3] fix: initialize IAsyncInitializer on data sources before constructor injection When a data source implementing IAsyncInitializer is passed to the test class constructor, its InitializeAsync() method was not being called before the constructor ran. This caused the fixture to be uninitialized when accessed in the constructor. The fix adds InitializeObjectForExecutionAsync() to ObjectLifecycleService which initializes an object and its nested IAsyncInitializer objects. TestBuilder now calls this method for each class data item before creating the test instance. Fixes #4432 Co-Authored-By: Claude Opus 4.5 --- TUnit.Engine/Building/TestBuilder.cs | 6 +- .../Services/ObjectLifecycleService.cs | 15 + ...nstructorInjectionAsyncInitializerTests.cs | 358 ++++++++++++++++++ 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 74f05c6bf5..a4f36c1d5f 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -66,8 +66,10 @@ private static async Task InitializeClassDataAsync(object?[] classData) private async Task CreateInstance(TestMetadata metadata, Type[] resolvedClassGenericArgs, object?[] classData, TestBuilderContext builderContext) { - // Initialize any deferred IAsyncInitializer objects in class data - await InitializeClassDataAsync(classData); + foreach (var data in classData) + { + await _objectLifecycleService.InitializeObjectForExecutionAsync(data); + } // First try to create instance with ClassConstructor attribute // Use attributes from context if available diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 16d1fdb0f2..34269e62bc 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -151,6 +151,21 @@ public Task InitializeTestObjectsAsync(TestContext testContext, CancellationToke return InitializeTrackedObjectsAsync(testContext, cancellationToken); } + /// + /// Initializes an object and its nested IAsyncInitializer objects for execution. + /// Used to initialize data source objects before they are passed to the test constructor. + /// + public async Task InitializeObjectForExecutionAsync(object? obj, CancellationToken cancellationToken = default) + { + if (obj is null) + { + return; + } + + await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); + await ObjectInitializer.InitializeAsync(obj, cancellationToken); + } + /// /// Sets already-cached property values on a test class instance. /// This is used to apply cached property values to new instances created during retries. diff --git a/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs b/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs new file mode 100644 index 0000000000..7581d8b606 --- /dev/null +++ b/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs @@ -0,0 +1,358 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4432; + +#region Fixtures + +/// +/// Simple fixture that implements IAsyncInitializer. +/// +public class SimpleFixture : IAsyncInitializer +{ + public int? Value { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(10); + Value = 123; + } +} + +/// +/// Fixture with a nested object that also implements IAsyncInitializer. +/// +public class OuterFixture : IAsyncInitializer +{ + public int? OuterValue { get; private set; } + + [ClassDataSource] + public required InnerFixture Inner { get; init; } + + public async Task InitializeAsync() + { + await Task.Delay(10); + OuterValue = 100; + } +} + +public class InnerFixture : IAsyncInitializer +{ + public int? InnerValue { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(10); + InnerValue = 200; + } +} + +/// +/// Fixture with deeply nested IAsyncInitializer objects. +/// +public class Level1Fixture : IAsyncInitializer +{ + public int? Level1Value { get; private set; } + + [ClassDataSource] + public required Level2Fixture Level2 { get; init; } + + public async Task InitializeAsync() + { + await Task.Delay(5); + Level1Value = 1; + } +} + +public class Level2Fixture : IAsyncInitializer +{ + public int? Level2Value { get; private set; } + + [ClassDataSource] + public required Level3Fixture Level3 { get; init; } + + public async Task InitializeAsync() + { + await Task.Delay(5); + Level2Value = 2; + } +} + +public class Level3Fixture : IAsyncInitializer +{ + public int? Level3Value { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(5); + Level3Value = 3; + } +} + +/// +/// First fixture for multiple parameter tests. +/// +public class FirstFixture : IAsyncInitializer +{ + public string? Name { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(10); + Name = "First"; + } +} + +/// +/// Second fixture for multiple parameter tests. +/// +public class SecondFixture : IAsyncInitializer +{ + public string? Name { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(10); + Name = "Second"; + } +} + +/// +/// Fixture for shared instance tests - tracks initialization count. +/// +public class SharedFixture : IAsyncInitializer +{ + private static int _initCount; + public static int InitCount => _initCount; + + public int MyInitCount { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(5); + MyInitCount = Interlocked.Increment(ref _initCount); + } + + public static void ResetCount() => _initCount = 0; +} + +#endregion + +#region Test Classes + +/// +/// Basic test: Single fixture injected via constructor. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class BasicConstructorInjectionTests +{ + private readonly int? _valueAtConstruction; + + public BasicConstructorInjectionTests(SimpleFixture fix) + { + _valueAtConstruction = fix.Value; + } + + [Test] + public async Task ValueShouldBeInitializedAtConstruction() + { + await Assert.That(_valueAtConstruction).IsEqualTo(123); + } +} + +/// +/// Nested test: Fixture with nested IAsyncInitializer property. +/// Both outer and inner should be initialized before constructor. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class NestedAsyncInitializerTests +{ + private readonly int? _outerValueAtConstruction; + private readonly int? _innerValueAtConstruction; + + public NestedAsyncInitializerTests(OuterFixture outer) + { + _outerValueAtConstruction = outer.OuterValue; + _innerValueAtConstruction = outer.Inner.InnerValue; + } + + [Test] + public async Task OuterFixtureShouldBeInitialized() + { + await Assert.That(_outerValueAtConstruction).IsEqualTo(100); + } + + [Test] + public async Task InnerFixtureShouldBeInitialized() + { + await Assert.That(_innerValueAtConstruction).IsEqualTo(200); + } +} + +/// +/// Deep nesting test: Three levels of nested IAsyncInitializer. +/// All levels should be initialized depth-first before constructor. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class DeepNestedAsyncInitializerTests +{ + private readonly int? _level1Value; + private readonly int? _level2Value; + private readonly int? _level3Value; + + public DeepNestedAsyncInitializerTests(Level1Fixture level1) + { + _level1Value = level1.Level1Value; + _level2Value = level1.Level2.Level2Value; + _level3Value = level1.Level2.Level3.Level3Value; + } + + [Test] + public async Task Level1ShouldBeInitialized() + { + await Assert.That(_level1Value).IsEqualTo(1); + } + + [Test] + public async Task Level2ShouldBeInitialized() + { + await Assert.That(_level2Value).IsEqualTo(2); + } + + [Test] + public async Task Level3ShouldBeInitialized() + { + await Assert.That(_level3Value).IsEqualTo(3); + } +} + +/// +/// Multiple parameters test: Two fixtures injected via constructor. +/// Both should be initialized before constructor runs. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class MultipleConstructorParametersTests +{ + private readonly string? _firstName; + private readonly string? _secondName; + + public MultipleConstructorParametersTests(FirstFixture first, SecondFixture second) + { + _firstName = first.Name; + _secondName = second.Name; + } + + [Test] + public async Task FirstFixtureShouldBeInitialized() + { + await Assert.That(_firstName).IsEqualTo("First"); + } + + [Test] + public async Task SecondFixtureShouldBeInitialized() + { + await Assert.That(_secondName).IsEqualTo("Second"); + } +} + +/// +/// Combined injection test: Constructor parameter + property injection. +/// Both should be initialized. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class CombinedInjectionTests +{ + private readonly string? _constructorFixtureName; + + [ClassDataSource] + public required SecondFixture PropertyFixture { get; init; } + + public CombinedInjectionTests(FirstFixture constructorFixture) + { + _constructorFixtureName = constructorFixture.Name; + } + + [Test] + public async Task ConstructorFixtureShouldBeInitialized() + { + await Assert.That(_constructorFixtureName).IsEqualTo("First"); + } + + [Test] + public async Task PropertyFixtureShouldBeInitialized() + { + await Assert.That(PropertyFixture.Name).IsEqualTo("Second"); + } +} + +/// +/// Shared fixture test (PerClass): Same fixture instance shared across tests in the class. +/// Should be initialized only once. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource(Shared = SharedType.PerClass)] +public class SharedPerClassTests +{ + private readonly int _initCountAtConstruction; + private readonly SharedFixture _fixture; + + public SharedPerClassTests(SharedFixture fixture) + { + _initCountAtConstruction = fixture.MyInitCount; + _fixture = fixture; + } + + [Test] + public async Task FixtureShouldBeInitializedOnce_Test1() + { + // Should be initialized (non-zero) + await Assert.That(_initCountAtConstruction).IsGreaterThan(0); + } + + [Test] + public async Task FixtureShouldBeInitializedOnce_Test2() + { + // Same instance, same init count + await Assert.That(_fixture.MyInitCount).IsEqualTo(_initCountAtConstruction); + } + + [After(Class)] + public static void ResetCounter() + { + SharedFixture.ResetCount(); + } +} + +/// +/// Non-shared fixture test (SharedType.None): Each test gets its own instance. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource(Shared = SharedType.None)] +public class NonSharedFixtureTests +{ + private readonly int? _valueAtConstruction; + + public NonSharedFixtureTests(SimpleFixture fixture) + { + _valueAtConstruction = fixture.Value; + } + + [Test] + public async Task Test1_FixtureShouldBeInitialized() + { + await Assert.That(_valueAtConstruction).IsEqualTo(123); + } + + [Test] + public async Task Test2_FixtureShouldBeInitialized() + { + await Assert.That(_valueAtConstruction).IsEqualTo(123); + } +} + +#endregion From f4d227d1215dbff8f1b30f21650e9847a2e95eea Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:57:00 +0000 Subject: [PATCH 2/3] fix: handle array TypedConstants in PropertyInjectionSourceGenerator When processing attributes with array constructor arguments (like params Type[]), the source generator was accessing .Value directly which throws InvalidOperationException for arrays. Now properly checks for array kind and iterates over .Values instead. This ensures concrete generic types within array arguments are also discovered. Co-Authored-By: Claude Opus 4.5 --- .../PropertyInjectionSourceGenerator.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs index 29ccd8c038..47200a9693 100644 --- a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs @@ -359,6 +359,23 @@ private static ImmutableArray ExtractConcreteGenericTy // Check constructor arguments for type parameters foreach (var ctorArg in attr.ConstructorArguments) { + // Handle arrays - iterate over values to find concrete generic types + if (ctorArg.Kind == TypedConstantKind.Array) + { + foreach (var arrayElement in ctorArg.Values) + { + if (arrayElement.Value is INamedTypeSymbol elementType && IsConcreteGenericType(elementType)) + { + var model = CreateConcreteGenericModel(elementType, dataSourceInterface, asyncInitializerInterface); + if (model != null) + { + results.Add(model); + } + } + } + continue; + } + if (ctorArg.Value is INamedTypeSymbol argType && IsConcreteGenericType(argType)) { var model = CreateConcreteGenericModel(argType, dataSourceInterface, asyncInitializerInterface); From 1535414082a31e8394945755b7520c6a0121b7ea Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:02:14 +0000 Subject: [PATCH 3/3] test: add additional test scenarios for #4432 - NonInitializerFixtureTests: regression test for fixtures without IAsyncInitializer - MethodLevelInjectionTests: test method-level data source injection - NonGenericMultiTypeTests: test non-generic ClassDataSource with params Type[] (tests source generator array handling fix) Co-Authored-By: Claude Opus 4.5 --- ...nstructorInjectionAsyncInitializerTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs b/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs index 7581d8b606..e441eee13b 100644 --- a/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs +++ b/TUnit.TestProject/Bugs/4432/ConstructorInjectionAsyncInitializerTests.cs @@ -136,6 +136,29 @@ public async Task InitializeAsync() public static void ResetCount() => _initCount = 0; } +/// +/// Fixture that does NOT implement IAsyncInitializer. +/// Used to verify we don't break non-initializer fixtures. +/// +public class NonInitializerFixture +{ + public string Value { get; } = "StaticValue"; +} + +/// +/// Fixture for method-level injection tests. +/// +public class MethodLevelFixture : IAsyncInitializer +{ + public int? MethodValue { get; private set; } + + public async Task InitializeAsync() + { + await Task.Delay(5); + MethodValue = 999; + } +} + #endregion #region Test Classes @@ -355,4 +378,68 @@ public async Task Test2_FixtureShouldBeInitialized() } } +/// +/// Regression test: Fixture without IAsyncInitializer should still work. +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource] +public class NonInitializerFixtureTests +{ + private readonly string _valueAtConstruction; + + public NonInitializerFixtureTests(NonInitializerFixture fixture) + { + _valueAtConstruction = fixture.Value; + } + + [Test] + public async Task NonInitializerFixtureShouldWork() + { + await Assert.That(_valueAtConstruction).IsEqualTo("StaticValue"); + } +} + +/// +/// Method-level data source injection test. +/// +[EngineTest(ExpectedResult.Pass)] +public class MethodLevelInjectionTests +{ + [Test] + [ClassDataSource] + public async Task MethodLevelFixtureShouldBeInitialized(MethodLevelFixture fixture) + { + await Assert.That(fixture.MethodValue).IsEqualTo(999); + } +} + +/// +/// Non-generic ClassDataSource with multiple types (tests source generator array handling). +/// +[EngineTest(ExpectedResult.Pass)] +[ClassDataSource(typeof(FirstFixture), typeof(SecondFixture))] +public class NonGenericMultiTypeTests +{ + private readonly string? _firstName; + private readonly string? _secondName; + + public NonGenericMultiTypeTests(FirstFixture first, SecondFixture second) + { + _firstName = first.Name; + _secondName = second.Name; + } + + [Test] + public async Task FirstFixtureShouldBeInitialized() + { + await Assert.That(_firstName).IsEqualTo("First"); + } + + [Test] + public async Task SecondFixtureShouldBeInitialized() + { + await Assert.That(_secondName).IsEqualTo("Second"); + } +} + #endregion