diff --git a/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs b/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs index 51e7fd407d..bcaa40d58b 100644 --- a/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs @@ -8,25 +8,27 @@ public abstract class DependencyInjectionDataSourceAttribute : UntypedDa { protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) { - var scope = CreateScope(dataGeneratorMetadata); - - if (dataGeneratorMetadata.TestBuilderContext != null) + yield return () => { - dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) => + // Create a new scope for each test execution + var scope = CreateScope(dataGeneratorMetadata); + + // Set up disposal for this specific scope in the current test context + if (dataGeneratorMetadata.TestBuilderContext != null) { - if (scope is IAsyncDisposable asyncDisposable) + dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) => { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - } - else if (scope is IDisposable disposable) - { - disposable.Dispose(); - } - }; - } + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (scope is IDisposable disposable) + { + disposable.Dispose(); + } + }; + } - yield return () => - { return dataGeneratorMetadata.MembersToGenerate .Select(m => m.Type) .Select(x => Create(scope, x)) diff --git a/TUnit.TestProject/DependencyInjectionScopeIsolationTest.cs b/TUnit.TestProject/DependencyInjectionScopeIsolationTest.cs new file mode 100644 index 0000000000..0acf347239 --- /dev/null +++ b/TUnit.TestProject/DependencyInjectionScopeIsolationTest.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +/// +/// Service with state to verify scope isolation between test executions +/// +public sealed class ScopedTestService +{ + private static int _counter = 0; + public int InstanceNumber { get; } = Interlocked.Increment(ref _counter); +} + +/// +/// DI data source attribute that provides scoped services +/// +public class ScopedServiceDataSourceAttribute : DependencyInjectionDataSourceAttribute +{ + private static readonly IServiceProvider ServiceProvider = CreateServiceProvider(); + + public override IServiceScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata) + { + return ServiceProvider.CreateAsyncScope(); + } + + public override object? Create(IServiceScope scope, Type type) + { + return ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, type); + } + + private static IServiceProvider CreateServiceProvider() + { + return new ServiceCollection() + .AddScoped() + .BuildServiceProvider(); + } +} + +/// +/// Test class that verifies dependency injection scope isolation between test executions. +/// This test ensures that each test execution with multiple arguments gets its own DI scope +/// and scoped services are not shared between test invocations. +/// +[EngineTest(ExpectedResult.Pass)] +[ScopedServiceDataSource] +[UnconditionalSuppressMessage("Usage", "TUnit0042:Global hooks should not be mixed with test classes to avoid confusion. Place them in their own class.")] +public class DependencyInjectionScopeIsolationTest(ScopedTestService scopedService) +{ + private static readonly ConcurrentBag _instanceNumbers = new(); + + [Test] + [Arguments(1)] + [Arguments(2)] + [Arguments(3)] + public async Task ScopedServices_ShouldBeIsolated_PerTestExecution(int testNumber) + { + // Record the instance number for this test execution + _instanceNumbers.Add(scopedService.InstanceNumber); + + // Basic assertions that service is properly injected + await Assert.That(scopedService).IsNotNull(); + await Assert.That(scopedService.InstanceNumber).IsGreaterThan(0); + } + + [After(HookType.Class)] + public static async Task VerifyAllInstancesWereUnique() + { + // Verify that we got 3 different instances (one per test execution) + // This ensures that each test execution received its own DI scope + var uniqueInstances = _instanceNumbers.Distinct().ToList(); + await Assert.That(uniqueInstances.Count).IsEqualTo(3); + + // Also verify we recorded exactly 3 instances total + await Assert.That(_instanceNumbers.Count).IsEqualTo(3); + } +} \ No newline at end of file