diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index fd1efb5da1..cfcb2d1fa9 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -48,12 +48,8 @@ public Task ResolveAndCachePropertiesAsync( TestContext testContext, CancellationToken cancellationToken = default) { - // Skip property resolution if this test is reusing the discovery instance (already initialized) - if (testContext.IsDiscoveryInstanceReused) - { - return Task.CompletedTask; - } - + // Even when the first data row reuses a discovery instance, this test still + // needs its own cached property values so shared fixtures get ref-counted. var plan = PropertyInjectionCache.GetOrCreatePlan(testClassType); if (!plan.HasProperties) diff --git a/TUnit.TestProject/Bugs/5982/FixtureLifetimeTests.cs b/TUnit.TestProject/Bugs/5982/FixtureLifetimeTests.cs new file mode 100644 index 0000000000..3d6cdc707c --- /dev/null +++ b/TUnit.TestProject/Bugs/5982/FixtureLifetimeTests.cs @@ -0,0 +1,87 @@ +namespace TUnit.TestProject.Bugs._5982; + +using TUnit.TestProject.Attributes; + +public sealed class MyFixture : IAsyncDisposable +{ + public bool Disposed { get; private set; } + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } +} + +[EngineTest(ExpectedResult.Pass)] +public class AnotherTest +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required MyFixture My { get; set; } + + [Test] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + public async Task Wait(int delay) + { + await Task.Delay(delay); + } +} + +[EngineTest(ExpectedResult.Pass)] +[NotInParallel] +public class SomeTest +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required MyFixture My { get; set; } + + [Test] + [MethodDataSource(nameof(Methods))] + public async Task TheLastCaseFails(int delay) + { + await Assert.That(My.Disposed).IsFalse(); + await Task.Delay(delay); + } + + public IEnumerable> Methods() + { + yield return () => 100; + yield return () => 100; + yield return () => 100; + yield return () => 100; + } + + [Test] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + [Arguments(100)] + public async Task Wait1(int delay) + { + await Task.Delay(delay); + } + + [Test] + public async Task Wait2() + { + await Task.Delay(100); + } + + [Test] + public async Task Wait3() + { + await Task.Delay(500); + } + + [Test] + public async Task Wait4() + { + await Task.Delay(2_000); + } +} diff --git a/TUnit.UnitTests/PropertyInjectorTests.cs b/TUnit.UnitTests/PropertyInjectorTests.cs new file mode 100644 index 0000000000..78a2e99020 --- /dev/null +++ b/TUnit.UnitTests/PropertyInjectorTests.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using TUnit.Core.Interfaces; +using TUnit.Engine.Services; + +namespace TUnit.UnitTests; + +public class PropertyInjectorTests +{ + [Test] + public async Task ReusedDiscoveryInstanceStillCachesInjectedProperties() + { + var context = CreateContext(); + context.IsDiscoveryInstanceReused = true; + + var injector = new PropertyInjector(new Lazy(() => new PassthroughInitializationCallback()), "session"); + + await injector.ResolveAndCachePropertiesAsync( + typeof(ReusedDiscoveryInstanceTestClass), + context.StateBag.Items, + context.Metadata.TestDetails.MethodMetadata, + context.InternalEvents, + context); + + await Assert.That(context.Metadata.TestDetails.TestClassInjectedPropertyArguments).Count().IsEqualTo(1); + await Assert.That(context.Metadata.TestDetails.TestClassInjectedPropertyArguments.Values.Single()) + .IsTypeOf(); + } + + private static TestContext CreateContext() where T : class + { + var classMetadata = new ClassMetadata + { + Type = typeof(T), + TypeInfo = new ConcreteType(typeof(T)), + Name = typeof(T).Name, + Namespace = typeof(T).Namespace ?? string.Empty, + Assembly = new AssemblyMetadata + { + Name = typeof(T).Assembly.GetName().Name ?? string.Empty + }, + Parent = null, + Parameters = [], + Properties = [] + }; + + var methodMetadata = MethodMetadataFactory.Create( + "Test", + typeof(T), + typeof(Task), + classMetadata); + + var beforeDiscoveryContext = new BeforeTestDiscoveryContext { TestFilter = null }; + var discoveryContext = new TestDiscoveryContext(beforeDiscoveryContext) { TestFilter = null }; + var sessionContext = new TestSessionContext(discoveryContext) + { + Id = Guid.NewGuid().ToString(), + TestFilter = null + }; + var assemblyContext = new AssemblyHookContext(sessionContext) + { + Assembly = typeof(T).Assembly + }; + var classContext = new ClassHookContext(assemblyContext) + { + ClassType = typeof(T) + }; + var builderContext = new TestBuilderContext + { + TestMetadata = methodMetadata + }; + + var context = new TestContext("Test", new EmptyServiceProvider(), classContext, builderContext, CancellationToken.None); + context.TestDetails = new TestDetails([]) + { + TestId = "Test", + TestName = "Test", + ClassType = typeof(T), + MethodName = "Test", + ClassInstance = PlaceholderInstance.Instance, + TestMethodArguments = [], + TestClassArguments = [], + MethodMetadata = methodMetadata, + ReturnType = typeof(Task), + AttributesByType = new Dictionary>() + }; + + return context; + } + + private sealed class ReusedDiscoveryInstanceFixture; + + private sealed class ReusedDiscoveryInstanceTestClass + { + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required ReusedDiscoveryInstanceFixture Fixture { get; set; } + } + + private sealed class PassthroughInitializationCallback : IInitializationCallback + { + public ValueTask EnsureInitializedAsync( + T obj, + ConcurrentDictionary? objectBag = null, + MethodMetadata? methodMetadata = null, + TestContextEvents? events = null, + CancellationToken cancellationToken = default) where T : notnull + { + return ValueTask.FromResult(obj); + } + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +}