diff --git a/CLAUDE.md b/CLAUDE.md index 1ed9227dd1..925f3571f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,17 @@ dotnet test TUnit.Core.SourceGenerator.Tests **Rule**: Only run TUnit.TestProject with explicit `--treenode-filter` to target specific tests or classes. +**IMPORTANT: Run filters ONE AT A TIME!** Using OR patterns (`Pattern1|Pattern2`) can match thousands of unintended tests. Always run one specific filter per command: + +```bash +# ❌ WRONG - OR patterns can match too broadly +--treenode-filter "/*/*/ClassA/*|/*/*/ClassB/*" + +# ✅ CORRECT - Run separate commands for each class +dotnet run -- --treenode-filter "/*/*/ClassA/*" +dotnet run -- --treenode-filter "/*/*/ClassB/*" +``` + --- ### Most Common Commands diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index f2389fda28..a4591d65b4 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -476,6 +476,12 @@ private static void TraverseInitializerProperties( /// Collects root-level objects (class args, method args, properties) from test details. /// Eliminates duplicate loops in DiscoverObjectGraph and DiscoverAndTrackObjects. /// + /// + /// For injected properties, only DIRECT test class properties (including inherited) are added at depth 0. + /// Nested properties (properties of injected objects) are discovered through normal + /// graph traversal at appropriate depths (1+), ensuring correct initialization order + /// for nested IAsyncInitializer dependencies. See GitHub issue #4032. + /// private static void CollectRootObjects( TestDetails testDetails, TryAddObjectFunc tryAdd, @@ -488,8 +494,70 @@ private static void CollectRootObjects( // Process method arguments ProcessRootCollection(testDetails.TestMethodArguments, tryAdd, onRootObjectAdded, cancellationToken); - // Process injected property values - ProcessRootCollection(testDetails.TestClassInjectedPropertyArguments.Values, tryAdd, onRootObjectAdded, cancellationToken); + // Build set of types in the test class hierarchy (for identifying direct properties) + var hierarchyTypes = GetTypeHierarchy(testDetails.ClassType); + + // Process ONLY direct test class injected properties at depth 0. + // Nested properties will be discovered through normal graph traversal at depth 1+. + // This ensures proper initialization order for nested IAsyncInitializer dependencies. + foreach (var kvp in testDetails.TestClassInjectedPropertyArguments) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (kvp.Value == null) + { + continue; + } + + // Check if this property belongs to the test class hierarchy (not nested object properties) + // Cache key format: "{DeclaringType.FullName}.{PropertyName}" + if (IsDirectProperty(kvp.Key, hierarchyTypes)) + { + if (tryAdd(kvp.Value, 0)) + { + onRootObjectAdded(kvp.Value); + } + } + } + } + + /// + /// Gets all types in the inheritance hierarchy from the given type up to (but not including) object. + /// + private static HashSet GetTypeHierarchy(Type type) + { + var result = new HashSet(); + var currentType = type; + + while (currentType != null && currentType != typeof(object)) + { + if (currentType.FullName != null) + { + result.Add(currentType.FullName); + } + + currentType = currentType.BaseType; + } + + return result; + } + + /// + /// Determines if a cache key represents a direct property (belonging to test class hierarchy) + /// vs a nested property (belonging to an injected object). + /// Cache key format: "{DeclaringType.FullName}.{PropertyName}" + /// + private static bool IsDirectProperty(string cacheKey, HashSet hierarchyTypes) + { + // Find the last dot to separate type from property name + var lastDotIndex = cacheKey.LastIndexOf('.'); + if (lastDotIndex <= 0) + { + return true; // Malformed key, treat as direct + } + + var declaringTypeName = cacheKey.Substring(0, lastDotIndex); + return hierarchyTypes.Contains(declaringTypeName); } /// diff --git a/TUnit.TestProject/Bugs/4032/NestedAsyncInitializerTests.cs b/TUnit.TestProject/Bugs/4032/NestedAsyncInitializerTests.cs new file mode 100644 index 0000000000..f04d018ba2 --- /dev/null +++ b/TUnit.TestProject/Bugs/4032/NestedAsyncInitializerTests.cs @@ -0,0 +1,417 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4032; + +/// +/// Regression tests for issue #4032: IAsyncInitializer lifecycle change broke complex test infrastructure orchestration. +/// The problem was that nested IAsyncInitializer dependencies weren't initialized before the parent's InitializeAsync was called. +/// This broke patterns like WebApplicationFactory + Testcontainers where the container must be initialized +/// before the WebApplicationFactory tries to access the container's connection string. +/// + +#region Mock Classes + +/// +/// Simulates a Testcontainers PostgreSQL container. +/// Must be initialized before any parent object tries to access GetConnectionString(). +/// +public class MockDatabaseContainer : IAsyncInitializer +{ + private static int _instanceCount; + private static int _initializeCount; + private static readonly List _initializationOrder = []; + + public static int InstanceCount => _instanceCount; + public static int InitializeCount => _initializeCount; + public static IReadOnlyList InitializationOrder => _initializationOrder; + + public int InstanceId { get; } + public bool IsInitialized { get; private set; } + public string? ConnectionString { get; private set; } + + public MockDatabaseContainer() + { + InstanceId = Interlocked.Increment(ref _instanceCount); + } + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + IsInitialized = true; + ConnectionString = $"Host=localhost;Database=test_{InstanceId};User=test;Password=test"; + + lock (_initializationOrder) + { + _initializationOrder.Add(InstanceId); + } + + Console.WriteLine($"[MockDatabaseContainer #{InstanceId}] InitializeAsync called - ConnectionString: {ConnectionString}"); + return Task.CompletedTask; + } + + public string GetConnectionString() + { + if (!IsInitialized || ConnectionString == null) + { + throw new InvalidOperationException( + $"MockDatabaseContainer #{InstanceId} is not initialized! " + + "This indicates a bug in the IAsyncInitializer lifecycle ordering. " + + "Nested dependencies must be initialized before parent objects."); + } + + return ConnectionString; + } + + public static void Reset() + { + _instanceCount = 0; + _initializeCount = 0; + lock (_initializationOrder) + { + _initializationOrder.Clear(); + } + } +} + +/// +/// Simulates a WebApplicationFactory that depends on a database container. +/// The container MUST be initialized before this class's InitializeAsync is called, +/// because InitializeAsync accesses the container's connection string. +/// +public class MockWebApplicationFactory : IAsyncInitializer +{ + private static int _instanceCount; + private static int _initializeCount; + + public static int InstanceCount => _instanceCount; + public static int InitializeCount => _initializeCount; + + public int InstanceId { get; } + public bool IsInitialized { get; private set; } + public string? ConfiguredConnectionString { get; private set; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required MockDatabaseContainer Database { get; init; } + + public MockWebApplicationFactory() + { + InstanceId = Interlocked.Increment(ref _instanceCount); + } + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + + // This is the critical part: we need to access the container's connection string + // during initialization. If the container isn't initialized yet, this will throw. + ConfiguredConnectionString = Database.GetConnectionString(); + IsInitialized = true; + + Console.WriteLine($"[MockWebApplicationFactory #{InstanceId}] InitializeAsync called - Using connection: {ConfiguredConnectionString}"); + return Task.CompletedTask; + } + + public static void Reset() + { + _instanceCount = 0; + _initializeCount = 0; + } +} + +#endregion + +#region Tests + +/// +/// Tests that nested IAsyncInitializer dependencies are initialized before the parent's InitializeAsync. +/// This is the exact pattern from issue #4032. +/// +[NotInParallel] +[EngineTest(ExpectedResult.Pass)] +public class NestedAsyncInitializerOrderTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required MockWebApplicationFactory Factory { get; init; } + + [Before(Class)] + public static void ResetCounters() + { + MockDatabaseContainer.Reset(); + MockWebApplicationFactory.Reset(); + } + + [Test] + public async Task Factory_ShouldBeInitialized() + { + // The factory should be initialized (which means the database must have been initialized first) + await Assert.That(Factory.IsInitialized).IsTrue() + .Because("the factory's InitializeAsync should have been called"); + } + + [Test] + public async Task Factory_ShouldHaveConfiguredConnectionString() + { + // The factory should have captured the connection string during initialization + await Assert.That(Factory.ConfiguredConnectionString).IsNotNull() + .Because("the factory should have accessed the database connection string during InitializeAsync"); + } + + [Test] + public async Task Database_ShouldBeInitializedBeforeFactory() + { + // The database should be initialized + await Assert.That(Factory.Database.IsInitialized).IsTrue() + .Because("the database container should be initialized before the factory accesses it"); + } + + [Test] + public async Task Database_InitializeCount_ShouldBeOne() + { + // The database should only be initialized once (shared per test session) + await Assert.That(MockDatabaseContainer.InitializeCount).IsEqualTo(1) + .Because("the database container is shared PerTestSession and should only be initialized once"); + } + + [Test] + public async Task Factory_InitializeCount_ShouldBeOne() + { + // The factory should only be initialized once (shared per test session) + await Assert.That(MockWebApplicationFactory.InitializeCount).IsEqualTo(1) + .Because("the factory is shared PerTestSession and should only be initialized once"); + } +} + +/// +/// Tests deep nesting (3 levels) of IAsyncInitializer dependencies. +/// Uses a shared static list to track initialization order reliably. +/// +public static class DeepNestingInitializationTracker +{ + private static readonly List _initializationOrder = []; + private static readonly object _lock = new(); + + public static void RecordInitialization(string name) + { + lock (_lock) + { + _initializationOrder.Add(name); + } + } + + public static IReadOnlyList GetOrder() + { + lock (_lock) + { + return _initializationOrder.ToList(); + } + } + + public static void Reset() + { + lock (_lock) + { + _initializationOrder.Clear(); + } + } +} + +public class DeepNestedDependency : IAsyncInitializer +{ + private static int _instanceCount; + private static int _initializeCount; + + public static int InstanceCount => _instanceCount; + public static int InitializeCount => _initializeCount; + + public int InstanceId { get; } + public bool IsInitialized { get; private set; } + public string? Value { get; private set; } + + public DeepNestedDependency() + { + InstanceId = Interlocked.Increment(ref _instanceCount); + } + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + IsInitialized = true; + Value = $"DeepValue_{InstanceId}"; + DeepNestingInitializationTracker.RecordInitialization("Deep"); + Console.WriteLine($"[DeepNestedDependency #{InstanceId}] InitializeAsync called"); + return Task.CompletedTask; + } + + public string GetValue() + { + if (!IsInitialized || Value == null) + { + throw new InvalidOperationException($"DeepNestedDependency #{InstanceId} is not initialized!"); + } + + return Value; + } + + public static void Reset() + { + _instanceCount = 0; + _initializeCount = 0; + } +} + +public class MiddleDependency : IAsyncInitializer +{ + private static int _instanceCount; + private static int _initializeCount; + + public static int InstanceCount => _instanceCount; + public static int InitializeCount => _initializeCount; + + public int InstanceId { get; } + public bool IsInitialized { get; private set; } + public string? CombinedValue { get; private set; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DeepNestedDependency DeepDependency { get; init; } + + public MiddleDependency() + { + InstanceId = Interlocked.Increment(ref _instanceCount); + } + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + + // Access the deep dependency - must be initialized first + CombinedValue = $"Middle_{InstanceId}_{DeepDependency.GetValue()}"; + IsInitialized = true; + DeepNestingInitializationTracker.RecordInitialization("Middle"); + Console.WriteLine($"[MiddleDependency #{InstanceId}] InitializeAsync called - CombinedValue: {CombinedValue}"); + return Task.CompletedTask; + } + + public string GetCombinedValue() + { + if (!IsInitialized || CombinedValue == null) + { + throw new InvalidOperationException($"MiddleDependency #{InstanceId} is not initialized!"); + } + + return CombinedValue; + } + + public static void Reset() + { + _instanceCount = 0; + _initializeCount = 0; + } +} + +public class TopLevelDependency : IAsyncInitializer +{ + private static int _instanceCount; + private static int _initializeCount; + + public static int InstanceCount => _instanceCount; + public static int InitializeCount => _initializeCount; + + public int InstanceId { get; } + public bool IsInitialized { get; private set; } + public string? FinalValue { get; private set; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required MiddleDependency MiddleDependency { get; init; } + + public TopLevelDependency() + { + InstanceId = Interlocked.Increment(ref _instanceCount); + } + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + + // Access the middle dependency - must be initialized first + FinalValue = $"Top_{InstanceId}_{MiddleDependency.GetCombinedValue()}"; + IsInitialized = true; + DeepNestingInitializationTracker.RecordInitialization("Top"); + Console.WriteLine($"[TopLevelDependency #{InstanceId}] InitializeAsync called - FinalValue: {FinalValue}"); + return Task.CompletedTask; + } + + public static void Reset() + { + _instanceCount = 0; + _initializeCount = 0; + } +} + +/// +/// Tests 3-level deep nesting of IAsyncInitializer dependencies. +/// Order must be: DeepNestedDependency -> MiddleDependency -> TopLevelDependency +/// +[NotInParallel] +[EngineTest(ExpectedResult.Pass)] +public class DeepNestedAsyncInitializerOrderTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required TopLevelDependency TopLevel { get; init; } + + [Before(Class)] + public static void ResetCounters() + { + DeepNestedDependency.Reset(); + MiddleDependency.Reset(); + TopLevelDependency.Reset(); + DeepNestingInitializationTracker.Reset(); + } + + [Test] + public async Task AllLevels_ShouldBeInitialized() + { + await Assert.That(TopLevel.IsInitialized).IsTrue(); + await Assert.That(TopLevel.MiddleDependency.IsInitialized).IsTrue(); + await Assert.That(TopLevel.MiddleDependency.DeepDependency.IsInitialized).IsTrue(); + } + + [Test] + public async Task InitializationOrder_ShouldBeDeepestFirst() + { + // Get the recorded initialization order + var order = DeepNestingInitializationTracker.GetOrder(); + + // Deep should be initialized first, then Middle, then Top + await Assert.That(order).HasCount().EqualTo(3) + .Because("there should be exactly 3 initializations"); + + await Assert.That(order[0]).IsEqualTo("Deep") + .Because("the deepest dependency should be initialized first"); + + await Assert.That(order[1]).IsEqualTo("Middle") + .Because("the middle dependency should be initialized second"); + + await Assert.That(order[2]).IsEqualTo("Top") + .Because("the top-level dependency should be initialized last"); + } + + [Test] + public async Task FinalValue_ShouldContainAllLevels() + { + // The final value should contain data from all three levels + await Assert.That(TopLevel.FinalValue).Contains("Top_"); + await Assert.That(TopLevel.FinalValue).Contains("Middle_"); + await Assert.That(TopLevel.FinalValue).Contains("DeepValue_"); + } + + [Test] + public async Task EachLevel_ShouldBeInitializedOnce() + { + await Assert.That(DeepNestedDependency.InitializeCount).IsEqualTo(1); + await Assert.That(MiddleDependency.InitializeCount).IsEqualTo(1); + await Assert.That(TopLevelDependency.InitializeCount).IsEqualTo(1); + } +} + +#endregion