From fbf11510d412348f66fb218cd6c644d8f9d68709 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sat, 5 Sep 2026 08:10:17 -0400 Subject: [PATCH] Dispose a test's container when its case has run ModuleTestCase handed each provider to the case's DisposalTracker, and xUnit disposes a test case only in InProcessFrontController.FindAndRun, after every case in the assembly has run. Every container a run built stayed alive, with every singleton in it, until the run ended. The case now executes itself and disposes what it built in a finally once its run returns, per case; NUnit's ModuleTestCommand has always done this in its own finally. Co-Authored-By: Claude Fable 5.1 --- .../TestFramework/ContainerLifetimeTests.cs | 72 +++++++++++++++++++ .../Impl/ModuleTestCase.cs | 65 ++++++++++++++++- .../PublicApiTests.XUnitApi.verified.txt | 3 +- 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 integ-tests/SutProject.Tests/TestFramework/ContainerLifetimeTests.cs diff --git a/integ-tests/SutProject.Tests/TestFramework/ContainerLifetimeTests.cs b/integ-tests/SutProject.Tests/TestFramework/ContainerLifetimeTests.cs new file mode 100644 index 0000000..f022f05 --- /dev/null +++ b/integ-tests/SutProject.Tests/TestFramework/ContainerLifetimeTests.cs @@ -0,0 +1,72 @@ +using DependencyModules.Runtime.Attributes; +using DependencyModules.xUnit.Attributes; +using Xunit; + +namespace SutProject.Tests.TestFramework; + +[DependencyModule(OnlyRealm = true)] +public partial class LifetimeModule { } + +[ScopedService(Realm = typeof(LifetimeModule))] +public class TrackedService : IDisposable { + + private static int _next; + + public static readonly List Disposed = []; + + public TrackedService() { + Id = Interlocked.Increment(ref _next); + } + + public int Id { + get; + } + + public void Dispose() { + lock (Disposed) { + Disposed.Add(Id); + } + } +} + +/// +/// The container is torn down when its test has run, not when the run ends. +/// +/// +/// Until 2026-09-05 ModuleTestCase handed the provider to the case's DisposalTracker, +/// and xUnit disposes a test case only after every case in the assembly has run. Every container +/// a run built, and every singleton in it, lived until the run ended; a probe that handed three +/// providers to an assembly fixture found all three alive at its disposal. The NUnit integration +/// has always released the container in a finally around the test, and +/// IterationLifetimeTests in the NUnit project holds it to that. +/// +/// Two tests in one class, which xUnit runs one after the other in an order it does not promise: +/// whichever runs second sees the first's service, and asserts that its container has already +/// been disposed. Per case rather than per row: the rows of a data-driven test share the case and +/// are released together when the last row has run. +/// +/// +public class ContainerLifetimeTests { + + private static readonly object Sync = new(); + + private static readonly List Seen = []; + + [ModuleTest(typeof(LifetimeModule))] + public void TheContainerOfATestThatHasRunIsDisposed(TrackedService service) => AssertEarlierDisposed(service); + + [ModuleTest(typeof(LifetimeModule))] + public void WhicheverOfTheTwoRanFirst(TrackedService service) => AssertEarlierDisposed(service); + + private static void AssertEarlierDisposed(TrackedService current) { + lock (Sync) { + foreach (var earlier in Seen) { + Assert.Contains(earlier, TrackedService.Disposed); + } + + Assert.DoesNotContain(current.Id, TrackedService.Disposed); + + Seen.Add(current.Id); + } + } +} diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index 1ac6a98..188750e 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -13,7 +13,22 @@ namespace DependencyModules.xUnit.Impl; /// Represents a specialized implementation of /// tailored for module-based test scenarios within the xUnit framework. /// -public class ModuleTestCase : XunitTestCase { +/// +/// Self-executing, so that the container a test ran against is disposed when the test case has +/// run. The provider used to go into the case's , and +/// xUnit disposes a test case only once every case in the assembly has run - in +/// InProcessFrontController.FindAndRun, after Run returns - so every container a run +/// built stayed alive, with every singleton in it, until the run ended. NUnit's +/// ModuleTestCommand has always disposed in a finally around the test; this is the +/// same lifetime for xUnit. +/// +public class ModuleTestCase : XunitTestCase, ISelfExecutingXunitTestCase { + + /// + /// One per container this case built: one for a plain test, one per row for a data-driven + /// one. Runtime state only, never serialized with the case. + /// + private readonly List _providers = []; #pragma warning disable CS0618 // Type or member is obsolete /// @@ -99,7 +114,9 @@ private async Task SetupServiceCollection() { var provider = BuildServiceProvider(context, serviceCollection, knownAttributes); - DisposalTracker.Add(provider); + // Kept here rather than handed to DisposalTracker, which xUnit empties at the end of the + // run; see the remarks on the class. + _providers.Add(provider); foreach (var startupAttribute in knownAttributes.OfType()) { await startupAttribute.StartupAsync(context, provider); @@ -213,6 +230,50 @@ private void SetupModules(ServiceCollection serviceCollection, IEnumerable.LoadModules(serviceCollection, modules.ToArray()); } + /// + /// Runs the case the way xUnit would have, and disposes every container it built once the + /// run has returned - the tests passed, failed, were skipped or were cancelled alike. + /// + /// + /// is what the method runner calls for a + /// case that does not execute itself: it creates the tests, turns a failure or a dynamic skip + /// during creation into the case's result, and hands the tests to + /// . Wrapping that call is the whole of the difference. + /// Disposal is per case, which for every test but a data-driven one is per test; the rows of + /// a data-driven test share the case and are released together when the last has run. + /// + public async ValueTask Run( + ExplicitOption explicitOption, + IMessageBus messageBus, + object?[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) { + try { + return await XunitRunnerHelper.RunXunitTestCase( + this, messageBus, cancellationTokenSource, aggregator, explicitOption, constructorArguments); + } + finally { + await DisposeProviders(); + } + } + + private async ValueTask DisposeProviders() { + var providers = _providers.ToArray(); + + _providers.Clear(); + + foreach (var provider in providers) { + switch (provider) { + case IAsyncDisposable asyncDisposable: + await asyncDisposable.DisposeAsync(); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } + } + /// /// By default, this method returns a single that is appropriate /// for a one-to-one mapping between test and test case. Override this method to change the diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt index dff157e..fcee8ac 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt @@ -22,12 +22,13 @@ namespace DependencyModules.xUnit.Impl { Xunit.v3.IXunitTestMethod XunitTestMethod { get; } } - public class ModuleTestCase : Xunit.v3.XunitTestCase + public class ModuleTestCase : Xunit.v3.XunitTestCase, Xunit.Sdk.ITestCase, Xunit.Sdk.ITestCaseMetadata, Xunit.v3.ISelfExecutingXunitTestCase, Xunit.v3.IXunitTestCase { public ModuleTestCase() { } public ModuleTestCase(Xunit.v3.IXunitTestMethod testMethod, string testCaseDisplayName, string uniqueID, bool @explicit, System.Type[]? skipExceptions = null, string? skipReason = null, System.Type? skipType = null, string? skipUnless = null, string? skipWhen = null, System.Collections.Generic.Dictionary>? traits = null, object?[]? testMethodArguments = null, string? sourceFilePath = null, int? sourceLineNumber = default, int? timeout = default) { } public override System.Threading.Tasks.ValueTask> CreateTests() { } public override void PreInvoke() { } + public System.Threading.Tasks.ValueTask Run(Xunit.Sdk.ExplicitOption explicitOption, Xunit.v3.IMessageBus messageBus, object?[] constructorArguments, Xunit.v3.ExceptionAggregator aggregator, System.Threading.CancellationTokenSource cancellationTokenSource) { } } public class ModuleTestDiscoverer : Xunit.v3.IXunitTestCaseDiscoverer {