diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index c07fac7b96..c58e163fed 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -113,11 +113,12 @@ public ValueTask UntrackObjects(TestContext testContext, List cleanup private async ValueTask UntrackObjectsAsync(List cleanupExceptions, SortedList> trackedObjects) { - // SortedList keeps keys in ascending order; iterate by index in reverse for descending depth. - var keys = trackedObjects.Keys; + // SortedList keeps keys in ascending order; iterate in ascending order (shallowest depth first). + // This ensures disposal happens in reverse order of initialization (which goes deepest first). + // Dependents (shallow) are disposed before their dependencies (deep). var values = trackedObjects.Values; - for (var i = keys.Count - 1; i >= 0; i--) + for (var i = 0; i < values.Count; i++) { var bucket = values[i]; List? disposalTasks = null; diff --git a/TUnit.TestProject/Bugs/NestedDisposalOrder/Tests.cs b/TUnit.TestProject/Bugs/NestedDisposalOrder/Tests.cs new file mode 100644 index 0000000000..9ac02e0961 --- /dev/null +++ b/TUnit.TestProject/Bugs/NestedDisposalOrder/Tests.cs @@ -0,0 +1,168 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs.NestedDisposalOrder; + +/// +/// Regression test: DisposeAsync() must be called in reverse order of InitializeAsync() +/// when using nested property injection. +/// +/// Dependency chain: Tests → AppServiceFixture → AppSeedFixture → ContextFactoryFixture +/// Init order: ContextFactoryFixture → AppSeedFixture → AppServiceFixture (deepest first) +/// Dispose order: AppServiceFixture → AppSeedFixture → ContextFactoryFixture (shallowest first = reverse) +/// + +public static class NestedDisposalOrderTracker +{ + private static readonly List _initOrder = []; + private static readonly List _disposeOrder = []; + private static readonly Lock _lock = new(); + + public static void RecordInit(string name) + { + lock (_lock) + { + _initOrder.Add(name); + } + } + + public static void RecordDispose(string name) + { + lock (_lock) + { + _disposeOrder.Add(name); + } + } + + public static IReadOnlyList GetInitOrder() + { + lock (_lock) + { + return _initOrder.ToList(); + } + } + + public static IReadOnlyList GetDisposeOrder() + { + lock (_lock) + { + return _disposeOrder.ToList(); + } + } + + public static void Reset() + { + lock (_lock) + { + _initOrder.Clear(); + _disposeOrder.Clear(); + } + } +} + +public sealed class ContextFactoryFixture2 : IAsyncInitializer, IAsyncDisposable +{ + public ValueTask DisposeAsync() + { + NestedDisposalOrderTracker.RecordDispose(nameof(ContextFactoryFixture2)); + return ValueTask.CompletedTask; + } + + public Task InitializeAsync() + { + NestedDisposalOrderTracker.RecordInit(nameof(ContextFactoryFixture2)); + return Task.CompletedTask; + } +} + +public sealed class AppSeedFixture2 : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required ContextFactoryFixture2 ContextFactoryFixture { get; init; } + + public ValueTask DisposeAsync() + { + NestedDisposalOrderTracker.RecordDispose(nameof(AppSeedFixture2)); + return ValueTask.CompletedTask; + } + + public Task InitializeAsync() + { + NestedDisposalOrderTracker.RecordInit(nameof(AppSeedFixture2)); + return Task.CompletedTask; + } +} + +public sealed class AppServiceFixture2 : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AppSeedFixture2 AppSeedFixture { get; init; } + + public ValueTask DisposeAsync() + { + NestedDisposalOrderTracker.RecordDispose(nameof(AppServiceFixture2)); + return ValueTask.CompletedTask; + } + + public Task InitializeAsync() + { + NestedDisposalOrderTracker.RecordInit(nameof(AppServiceFixture2)); + return Task.CompletedTask; + } +} + +[NotInParallel] +[EngineTest(ExpectedResult.Pass)] +public class NestedDisposalOrderTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AppServiceFixture2 AppServiceFixture { get; init; } + + [Before(Class)] + public static void ResetTrackers() + { + NestedDisposalOrderTracker.Reset(); + } + + [Test] + public async Task Test1() + { + await Assert.That(true).IsTrue(); + } + + [After(TestSession)] +#pragma warning disable TUnit0042 + public static async Task VerifyDisposalOrder(TestSessionContext context) +#pragma warning restore TUnit0042 + { + var initOrder = NestedDisposalOrderTracker.GetInitOrder(); + var disposeOrder = NestedDisposalOrderTracker.GetDisposeOrder(); + + // Guard: skip assertions if this test class was not part of the test run + if (initOrder.Count == 0) + { + return; + } + + Console.WriteLine($"Init order: {string.Join(" -> ", initOrder)}"); + Console.WriteLine($"Dispose order: {string.Join(" -> ", disposeOrder)}"); + + // Init should be deepest first + await Assert.That(initOrder).HasCount().EqualTo(3); + await Assert.That(initOrder[0]).IsEqualTo(nameof(ContextFactoryFixture2)) + .Because("deepest dependency should be initialized first"); + await Assert.That(initOrder[1]).IsEqualTo(nameof(AppSeedFixture2)) + .Because("middle dependency should be initialized second"); + await Assert.That(initOrder[2]).IsEqualTo(nameof(AppServiceFixture2)) + .Because("top-level dependency should be initialized last"); + + // Dispose should be reverse of init (shallowest first) + await Assert.That(disposeOrder).HasCount().EqualTo(3); + await Assert.That(disposeOrder[0]).IsEqualTo(nameof(AppServiceFixture2)) + .Because("top-level (shallowest) should be disposed first"); + await Assert.That(disposeOrder[1]).IsEqualTo(nameof(AppSeedFixture2)) + .Because("middle dependency should be disposed second"); + await Assert.That(disposeOrder[2]).IsEqualTo(nameof(ContextFactoryFixture2)) + .Because("deepest dependency should be disposed last"); + } +}