Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
07f55b8
fix(engine): deliver builder-context events to the test built from ea…
thomhurst Nov 1, 2026
e443c3b
fix(engine): raise OnDispose once per test, after retries and on skip…
thomhurst Nov 1, 2026
fe27744
fix(engine): leave services a DI scope owns to that scope, not the ob…
thomhurst Nov 1, 2026
efa0f2d
fix(engine): dispose failed rows' scopes and leave unowned DI service…
thomhurst Nov 1, 2026
30ae748
fix(engine): leave initializer objects held by DI-scoped services to …
thomhurst Nov 1, 2026
ffc8ce7
fix(core): keep TUnit-injected properties tracker-owned beneath a sco…
thomhurst Nov 1, 2026
b716030
chore(core): drop the BOM the PR added to ObjectTracker.cs (#7205)
thomhurst Nov 1, 2026
b6262eb
fix(engine): dispose DI scopes of tests skipped or cancelled before t…
thomhurst Nov 1, 2026
f9d9f42
fix(engine): dispose scopes of tests removed by circular-dependency d…
thomhurst Nov 1, 2026
87684c5
fix(engine): dispose DI scopes created for static properties at sessi…
thomhurst Nov 1, 2026
9ff1b5f
fix(core): resolve shared builder events through the source on every …
thomhurst Nov 1, 2026
bcf4acc
fix(engine): dispose DI scopes of filter-excluded tests and failed fe…
thomhurst Nov 2, 2026
d05db01
fix(engine): release discarded rows' scopes and per-test scopes when …
thomhurst Nov 2, 2026
6137a42
fix(engine): keep cleanup of discarded rows and failed initialization…
thomhurst Nov 2, 2026
68a8ac9
fix(engine): keep deferred discovery cleanup on its own session and d…
thomhurst Nov 2, 2026
bca3622
test: remove synthetic contexts of ExternallyOwnedSingletonTests from…
thomhurst Nov 2, 2026
3336621
fix(engine): keep the cleanup of failed rows and partition-evaluated …
thomhurst Nov 2, 2026
b1a2ba2
fix(engine): defer the cleanup of the method-data-source instance's i…
thomhurst Nov 2, 2026
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
2 changes: 1 addition & 1 deletion docs/docs/migration/testcontext-interface-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Several properties have been moved from the main `TestContext` class into their
#### `ITestEvents` - Test Event Integration

**New interface** exposing nullable event properties for lazy initialization:
- `OnDispose` - Event raised when test context is disposed
- `OnDispose` - Event raised once when the test context is disposed: after the test's final attempt and after its class instance is disposed, including for tests that never run (skipped, cancelled, failed while building, or excluded by a filter)
- `OnTestRegistered` - Event raised when test is registered
- `OnInitialize` - Event raised before test initialization
- `OnTestStart` - Event raised before test method execution
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/writing-tests/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ public class UserServiceTests(IUserRepository repo, IEmailService email)

The same pattern works with any DI container. Replace `IServiceScope` with whatever scope type the container uses (e.g., `ILifetimeScope` for Autofac) and implement `CreateScope` / `Create` accordingly.

### Scope Lifetime and Disposal

- **When the scope is disposed.** TUnit disposes a disposable scope (`IDisposable` or `IAsyncDisposable`) once, when its test ends. A retried test keeps the same scope and the same resolved services for every attempt, and the scope is disposed after the last attempt. Each `[Repeat]` iteration is a separate test with its own scope, so services and their state do not carry over between repeats. A test that is skipped, cancelled before it starts, or fails while its data is built still has its scope disposed. A scope created for a static property is disposed when the test session ends.
- **Who disposes resolved services.** A disposable scope owns what `Create` returns. TUnit disposes the scope and expects the scope to dispose the services it resolved, so TUnit does not dispose those services itself. The same goes for `IAsyncInitializer` objects in their properties: TUnit initializes them but leaves their disposal to the scope. Singletons that come from the root container are left to the root container.
- **Non-disposable scopes.** A scope that is not disposable cannot dispose anything, so TUnit disposes the disposable objects that `Create` returns when the test ends, as it does for other data sources.

If your scope type is disposable but does not dispose the services it resolves, dispose them in the scope's own `Dispose` or `DisposeAsync`.

## Choosing Between the Two

| Need | Use |
Expand Down
10 changes: 10 additions & 0 deletions src/TUnit.Core/AbstractExecutableTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ public abstract class AbstractExecutableTest
// published, because their State is already Failed (#7119).
internal bool HasStartedExecution { get; set; }

// 0 until the test's OnDispose callbacks have been raised. They are raised once per test, by
// TestCoordinator for a test that reached it and by the scheduler paths that skip or cancel a test
// before it does (#7205).
private int _onDisposeRaised;

/// <summary>
/// Returns true for the first caller only, which then raises the test's OnDispose callbacks.
/// </summary>
internal bool TryClaimOnDispose() => Interlocked.Exchange(ref _onDisposeRaised, 1) == 0;

public required TestContext Context
{
get;
Expand Down
41 changes: 41 additions & 0 deletions src/TUnit.Core/AsyncEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,47 @@ public AsyncEvent<TEventArgs> InsertAtFront(Func<object, TEventArgs, ValueTask>
return e;
}

/// <summary>
/// Removes every handler that is not in <paramref name="kept"/> and returns the removed handlers in
/// invocation order, or <see langword="null"/> when none was removed.
/// </summary>
internal List<Invocation>? DetachAllExcept(IReadOnlyCollection<Invocation>? kept)
{
if (_handlers is null)
{
return null;
}

if (kept is not { Count: > 0 })
{
var all = _handlers;
_handlers = null;
return all.Count > 0 ? all : null;
}

var keptSet = new HashSet<Invocation>(kept);
List<Invocation>? detached = null;
List<Invocation> remaining = new(kept.Count);
foreach (var handler in _handlers)
{
if (keptSet.Contains(handler))
{
remaining.Add(handler);
}
else
{
(detached ??= []).Add(handler);
}
}

if (detached is not null)
{
_handlers = remaining;
}

return detached;
}

private int FindInsertionIndex(int order)
{
int left = 0, right = (_handlers ??= []).Count;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Tracking;

namespace TUnit.Core;

Expand All @@ -11,6 +12,10 @@ public abstract class DependencyInjectionDataSourceAttribute<TScope> : UntypedDa
// Create a new scope for each test execution
var scope = CreateScope(dataGeneratorMetadata);

// Only a disposable scope can dispose what it resolves. Otherwise the object tracker keeps
// disposing the returned objects, as it does for any other data source.
var scopeOwnsCreatedObjects = scope is IDisposable or IAsyncDisposable;

// Set up disposal for this specific scope in the current test context
dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) =>
{
Expand All @@ -33,12 +38,42 @@ public abstract class DependencyInjectionDataSourceAttribute<TScope> : UntypedDa
MethodMetadata method => method.Type,
_ => throw new InvalidOperationException($"Unknown member type: {m.GetType()}")
})
.Select(x => Create(scope, x))
.Select(x =>
{
var created = Create(scope, x);

if (scopeOwnsCreatedObjects)
{
// The scope owns what it resolves and disposes it with itself above, so the
// object tracker must not dispose it a second time when the test ends.
ObjectTracker.MarkExternallyOwned(created);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

return created;
})
.ToArray();
};
}

/// <summary>
/// Creates the scope that resolves one test's arguments. A disposable scope is disposed when the
/// test ends, after its last attempt.
/// </summary>
/// <remarks>
/// A scope that implements <see cref="IDisposable"/> or <see cref="IAsyncDisposable"/> owns the objects
/// <see cref="Create"/> returns: TUnit disposes the scope and does not dispose those objects itself, so
/// the scope's disposal must dispose them.
/// </remarks>
public abstract TScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata);

/// <summary>
/// Resolves an argument of type <paramref name="type"/> from <paramref name="scope"/>.
/// </summary>
/// <remarks>
/// When the scope is disposable, it owns what this method returns, including the <see cref="Interfaces.IAsyncInitializer"/>
/// objects held in their properties: TUnit disposes the scope, and the scope is expected to dispose the
/// objects it resolved, so TUnit does not dispose them itself. TUnit still initializes them.
/// When the scope is not disposable, TUnit disposes disposable returned objects when the test ends.
/// </remarks>
public abstract object? Create(TScope scope, Type type);
}
50 changes: 41 additions & 9 deletions src/TUnit.Core/Attributes/TestData/GenericClassRowPartition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,50 @@ internal async Task<Evaluation> EvaluateAsync(DataGeneratorMetadata dataGenerato
var rowsByIndex = new List<EvaluatedRow>?[dataSources.Length];
var anyRowInfers = false;

foreach (var index in partitionedIndices)
// The rows are read against a context of their own, so the cleanup their sources subscribe (a
// dependency injection scope's disposal) lands on no test's context, and none of it is lost when
// a row's first fetch returns the evaluated values without subscribing again. It runs at session
// end, as it does for the rows reflection discovery evaluates to infer type arguments (#7205).
var accessor = dataGeneratorMetadata.TestBuilderContext;
var previousContext = accessor?.Current;
TestBuilderContext? evaluationContext = null;
if (accessor is not null && previousContext is not null)
{
var rows = new List<EvaluatedRow>();
await foreach (var rowFactory in dataSources[index].GetDataRowsAsync(dataGeneratorMetadata).ConfigureAwait(false))
evaluationContext = new TestBuilderContext
{
var row = await rowFactory().ConfigureAwait(false) is { } fetched ? EvaluateWrappedFactory(fetched) : null;
var infers = InfersTypeArguments(row, ClassTypeArguments);
anyRowInfers |= infers;
rows.Add(new EvaluatedRow(rowFactory, row, infers));
}
TestMetadata = previousContext.TestMetadata,
DataSourceAttribute = previousContext.DataSourceAttribute,
ClassDataSourceAttribute = previousContext.ClassDataSourceAttribute,
InitializedAttributes = previousContext.InitializedAttributes,
ClassConstructor = previousContext.ClassConstructor
};
evaluationContext.ShareStateBagWith(previousContext);
accessor.Current = evaluationContext;
}

rowsByIndex[index] = rows;
try
{
foreach (var index in partitionedIndices)
{
var rows = new List<EvaluatedRow>();
await foreach (var rowFactory in dataSources[index].GetDataRowsAsync(dataGeneratorMetadata).ConfigureAwait(false))
{
var row = await rowFactory().ConfigureAwait(false) is { } fetched ? EvaluateWrappedFactory(fetched) : null;
var infers = InfersTypeArguments(row, ClassTypeArguments);
anyRowInfers |= infers;
rows.Add(new EvaluatedRow(rowFactory, row, infers));
}

rowsByIndex[index] = rows;
}
}
finally
{
if (evaluationContext is not null)
{
accessor!.Current = previousContext!;
TestSessionContext.DeferOnDisposeToSessionEnd(evaluationContext);
}
}

return new Evaluation(rowsByIndex, anyRowInfers);
Expand Down
65 changes: 57 additions & 8 deletions src/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using TUnit.Core.Interfaces;
using TUnit.Core.Interfaces.SourceGenerator;
using TUnit.Core.PropertyInjection;
using TUnit.Core.Tracking;

namespace TUnit.Core.Discovery;

Expand Down Expand Up @@ -199,7 +200,7 @@ private SortedList<int, HashSet<object>> TrackObjectsCore(TestContext testContex

TryAddObjectFunc tryAdd = (obj, depth) => TryAddToHashSet(visitedObjects, depth, obj);
RootObjectCallback onRootObjectAdded = discoverNested
? obj => DiscoverNestedObjectsForTracking(obj, visitedObjects, 1, cancellationToken)
? obj => DiscoverNestedObjectsForTracking(obj, visitedObjects, 1, heldByExternalOwner: false, cancellationToken)
: static _ => { };

// Collect root-level objects and discover nested objects for tracking
Expand Down Expand Up @@ -318,10 +319,15 @@ void Recurse(object value, int depth)
/// Discovers nested objects for tracking (uses HashSet pattern for compatibility with TestContext.TrackedObjects).
/// Uses consolidated TraverseInjectableProperties and TraverseInitializerProperties methods.
/// </summary>
/// <param name="heldByExternalOwner">
/// True when <paramref name="obj"/> was found beneath an object that another owner (a dependency
/// injection scope) disposes. Such an object is treated as externally owned even when it is not marked.
/// </param>
private void DiscoverNestedObjectsForTracking(
object obj,
IDictionary<int, HashSet<object>> visitedObjects,
int currentDepth,
bool heldByExternalOwner,
CancellationToken cancellationToken)
{
if (!CheckRecursionDepth(obj, currentDepth))
Expand All @@ -338,13 +344,15 @@ private void DiscoverNestedObjectsForTracking(
return;
}

DiscoverNestedObjectsForTrackingCore(obj, visitedObjects, currentDepth, cancellationToken);
var externallyOwned = heldByExternalOwner || ObjectTracker.IsExternallyOwned(obj);
DiscoverNestedObjectsForTrackingCore(obj, visitedObjects, currentDepth, externallyOwned, cancellationToken);
}

private void DiscoverNestedObjectsForTrackingCore(
object obj,
IDictionary<int, HashSet<object>> visitedObjects,
int currentDepth,
bool externallyOwned,
CancellationToken cancellationToken)
{
// Tracking mode add callback: TryAddToHashSet only
Expand All @@ -353,17 +361,58 @@ bool TryAddTracking(object value, int depth)
return TryAddToHashSet(visitedObjects, depth, value);
}

// Recursive callback
// Recursive callback. TUnit creates the values of injectable properties, so the tracker owns them.
void Recurse(object value, int depth)
{
DiscoverNestedObjectsForTracking(value, visitedObjects, depth, cancellationToken);
DiscoverNestedObjectsForTracking(value, visitedObjects, depth, heldByExternalOwner: false, cancellationToken);
}

// Traverse injectable properties (useSourceRegistrarCheck = true for tracking mode)
TraverseInjectableProperties(obj, TryAddTracking, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: true);
if (!externallyOwned)
{
// Traverse injectable properties (useSourceRegistrarCheck = true for tracking mode)
TraverseInjectableProperties(obj, TryAddTracking, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: true);

// Also discover nested IAsyncInitializer objects from ALL properties
TraverseInitializerProperties(obj, TryAddTracking, Recurse, currentDepth, cancellationToken);
// Also discover nested IAsyncInitializer objects from ALL properties
TraverseInitializerProperties(obj, TryAddTracking, Recurse, currentDepth, cancellationToken);
return;
}

// TUnit injected these values even though obj is externally owned, so the tracker still owns them.
// The initializer traversal below visits the same properties again and must not mark them.
HashSet<object>? injectedValues = null;

bool TryAddInjected(object value, int depth)
{
(injectedValues ??= new HashSet<object>(ReferenceComparer)).Add(value);
return TryAddToHashSet(visitedObjects, depth, value);
}

TraverseInjectableProperties(obj, TryAddInjected, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: true);

// The owner of obj (a dependency injection scope) created the rest of what obj holds and disposes
// it, so the tracker must not dispose it a second time (#7205). The objects are still tracked, so
// they are initialized.
bool TryAddOwned(object value, int depth)
{
if (injectedValues?.Contains(value) == true)
{
return false;
}

if (value is IDisposable or IAsyncDisposable)
{
ObjectTracker.MarkExternallyOwned(value);
}

return TryAddToHashSet(visitedObjects, depth, value);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

void RecurseOwned(object value, int depth)
{
DiscoverNestedObjectsForTracking(value, visitedObjects, depth, heldByExternalOwner: true, cancellationToken);
}

TraverseInitializerProperties(obj, TryAddOwned, RecurseOwned, currentDepth, cancellationToken);
}

/// <summary>
Expand Down
14 changes: 14 additions & 0 deletions src/TUnit.Core/Interfaces/ITestEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ public interface ITestEvents
/// <summary>
/// Gets the event that is raised when the test context is disposed.
/// </summary>
/// <remarks>
/// Raised once per test, after its final attempt and after the test class instance is disposed, so a
/// handler must not use the instance. It is also raised for a test that never runs: one that is
/// skipped, cancelled before it starts, fails while its data is built, or is excluded by a filter.
/// <para>
/// A data source can subscribe through <see cref="TestBuilderContext.Events"/> while it builds data that
/// no single test owns: a static property, a class row that no test takes, a row read during discovery to
/// infer a generic class's type arguments, or a property of the temporary instance that supplies instance
/// method rows. Those handlers are raised once when TUnit releases that data, at the latest when the
/// session ends. No test exists for them, so they receive a <see langword="null"/> <see cref="TestContext"/>
/// argument (and the builder or session context as the sender), and a handler subscribed there must not read
/// the test context.
/// </para>
/// </remarks>
AsyncEvent<TestContext>? OnDispose { get; }

/// <summary>
Expand Down
Loading
Loading