Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<int> Disposed = [];

public TrackedService() {
Id = Interlocked.Increment(ref _next);
}

public int Id {
get;
}

public void Dispose() {
lock (Disposed) {
Disposed.Add(Id);
}
}
}

/// <summary>
/// The container is torn down when its test has run, not when the run ends.
/// </summary>
/// <remarks>
/// Until 2026-09-05 <c>ModuleTestCase</c> handed the provider to the case's <c>DisposalTracker</c>,
/// 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 <c>finally</c> around the test, and
/// <c>IterationLifetimeTests</c> in the NUnit project holds it to that.
/// <para>
/// 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.
/// </para>
/// </remarks>
public class ContainerLifetimeTests {

private static readonly object Sync = new();

private static readonly List<int> 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);
}
}
}
65 changes: 63 additions & 2 deletions src/DependencyModules.xUnit/Impl/ModuleTestCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,22 @@ namespace DependencyModules.xUnit.Impl;
/// Represents a specialized implementation of <see cref="XunitTestCase"/>
/// tailored for module-based test scenarios within the xUnit framework.
/// </summary>
public class ModuleTestCase : XunitTestCase {
/// <remarks>
/// 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 <see cref="XunitTestCase.DisposalTracker"/>, and
/// xUnit disposes a test case only once every case in the assembly has run - in
/// <c>InProcessFrontController.FindAndRun</c>, after <c>Run</c> returns - so every container a run
/// built stayed alive, with every singleton in it, until the run ended. NUnit's
/// <c>ModuleTestCommand</c> has always disposed in a <c>finally</c> around the test; this is the
/// same lifetime for xUnit.
/// </remarks>
public class ModuleTestCase : XunitTestCase, ISelfExecutingXunitTestCase {

/// <summary>
/// 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.
/// </summary>
private readonly List<IServiceProvider> _providers = [];

#pragma warning disable CS0618 // Type or member is obsolete
/// <summary>
Expand Down Expand Up @@ -99,7 +114,9 @@ private async Task<StartupValues> 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<ITestStartupAttribute>()) {
await startupAttribute.StartupAsync(context, provider);
Expand Down Expand Up @@ -213,6 +230,50 @@ private void SetupModules(ServiceCollection serviceCollection, IEnumerable<Attri
DependencyRegistry<object>.LoadModules(serviceCollection, modules.ToArray());
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <see cref="XunitRunnerHelper.RunXunitTestCase"/> 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
/// <see cref="XunitTestCaseRunner"/>. 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.
/// </remarks>
public async ValueTask<RunSummary> 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;
}
}
}

/// <remarks>
/// By default, this method returns a single <see cref="XunitTest" /> that is appropriate
/// for a one-to-one mapping between test and test case. Override this method to change the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, System.Collections.Generic.HashSet<string>>? traits = null, object?[]? testMethodArguments = null, string? sourceFilePath = null, int? sourceLineNumber = default, int? timeout = default) { }
public override System.Threading.Tasks.ValueTask<System.Collections.Generic.IReadOnlyCollection<Xunit.v3.IXunitTest>> CreateTests() { }
public override void PreInvoke() { }
public System.Threading.Tasks.ValueTask<Xunit.v3.RunSummary> 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
{
Expand Down
Loading