Skip to content
Merged
1 change: 1 addition & 0 deletions docs/docs/writing-tests/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ The same pattern works with any DI container. Replace `IServiceScope` with whate

### Scope Lifetime and Disposal

- **Who disposes resolved services.** By default, a disposable scope owns everything returned by `Create`, and TUnit leaves disposal of those services to the scope. A non-disposable scope leaves service disposal to TUnit. If your disposable scope does not track or dispose the services it resolves, override `protected override bool ScopeOwnsCreatedObjects(TScope scope) => false;` (using your scope type for `TScope`). TUnit then tracks and disposes those services, while still disposing the scope itself. The scope is disposed before the directly resolved services; nested initializer objects are disposed after the services that hold them. Services must not depend on the scope remaining alive during their disposal.
- **When the scope is disposed.** TUnit disposes a disposable scope (`IDisposable` or `IAsyncDisposable`) once, when its test ends, after the test class instance is disposed. 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, skipped because a dependency failed, cancelled before it starts, or removed by circular-dependency detection still has its scope disposed.
- **Lifetime change for custom callbacks.** Discovery-owned `OnDispose` callbacks now run at session cleanup with a null `TestContext`. Custom handlers must avoid dereferencing that argument when their data source is used for static properties, generic inference, or temporary method-data-source instances. Overlapping sessions share generated and reflection static-property values and retain their scopes until the final owning session finishes.
- **Scopes shared during discovery.** Scopes used to initialize static properties, infer generic class arguments, or construct a temporary instance for an instance method data source live until session cleanup. This includes the temporary instance’s constructor and injected-property scopes: several method rows can borrow their services, so the first test must not dispose them. These scopes are cleaned up even when no test executes. Large data-driven suites can retain more services and other scope-owned resources until session cleanup. Their `OnDispose` handlers receive a null `TestContext`, because no single test owns them.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Discovery;
using TUnit.Core.Tracking;

namespace TUnit.Core;
Expand All @@ -12,60 +12,117 @@ 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;

// Register before resolving so a failed factory still releases its scope.
var cleanup = dataGeneratorMetadata.TestBuilderContext.Current.Events.GetOrCreateDataRowScopes();
var owner = cleanup.Register(scope);

return dataGeneratorMetadata.MembersToGenerate
.Select(m => m switch
{
PropertyMetadata prop => prop.Type,
ParameterMetadata param => param.Type,
ClassMetadata cls => cls.Type,
MethodMetadata method => method.Type,
_ => throw new InvalidOperationException($"Unknown member type: {m.GetType()}")
})
.Select(x =>
// Custom scopes can leave disposal of their resolved objects to the tracker.
var scopeOwnsCreatedObjects = ScopeOwnsCreatedObjects(scope);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
if (!scopeOwnsCreatedObjects && scope is (IDisposable or IAsyncDisposable))
{
// The scope can enter tracking through an initializer property, even when
// Create never returns it directly. Row cleanup owns only the scope itself.
ObjectTracker.MarkRowOwnedScope(scope);
}

var values = new object?[dataGeneratorMetadata.MembersToGenerate.Length];
var resolvedCount = 0;
try
{
foreach (var member in dataGeneratorMetadata.MembersToGenerate)
{
var created = Create(scope, x);
var type = member switch
{
PropertyMetadata prop => prop.Type,
ParameterMetadata param => param.Type,
ClassMetadata cls => cls.Type,
MethodMetadata method => method.Type,
_ => throw new InvalidOperationException($"Unknown member type: {member.GetType()}")
};
var created = Create(scope, type);
values[resolvedCount++] = created;
cleanup.RegisterObject(owner, created);

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.
// The scope owns the resolved value and its initializer graph.
ObjectTracker.MarkExternallyOwned(created);
}
}

return values;
}
catch
{
if (!scopeOwnsCreatedObjects)
{
// No row reaches normal tracking after a factory failure. Discover the same
// initializer graph, including beneath non-disposable roots, without initializing it.
var discoverer = new ObjectGraphDiscoverer();
var objectsByDepth = new SortedList<int, HashSet<object>>();
for (var i = 0; i < resolvedCount; i++)
{
if (values[i] is not { } root)
continue;

var graph = discoverer.DiscoverTrackableObjectGraph(root, CancellationToken.None, retainPartialGraphOnError: true);
foreach (var pair in graph)
{
if (!objectsByDepth.TryGetValue(pair.Key, out var bucket))
objectsByDepth.Add(pair.Key, bucket = new(Helpers.ReferenceEqualityComparer.Instance));
bucket.UnionWith(pair.Value);
}
}

return created;
})
.ToArray();
// As in normal tracking, release roots before their nested dependencies.
var retained = new HashSet<object>(Helpers.ReferenceEqualityComparer.Instance);
foreach (var bucket in objectsByDepth.Values)
foreach (var value in bucket)
{
// Shared roots and nested aliases need only one reference per failed row.
if (value is (IDisposable or IAsyncDisposable) && !ReferenceEquals(value, scope)
&& !ObjectTracker.IsExternallyOwned(value) && retained.Add(value))
{
cleanup.Register(ObjectTracker.RetainForRowCleanup(value));
}
}
}

throw;
}
};
}

/// <summary>
/// Determines whether the scope owns and disposes the objects returned by <see cref="Create"/>.
/// </summary>
/// <remarks>
/// Defaults to true for <see cref="IDisposable"/> or <see cref="IAsyncDisposable"/> scopes.
/// Override to return false when the scope does not dispose the objects it resolves; TUnit will
/// track and dispose those objects instead. The scope itself is still disposed when disposable.
/// This hook is evaluated once per scope; overrides should be cheap and side-effect free.
/// </remarks>
protected virtual bool ScopeOwnsCreatedObjects(TScope scope) => scope is IDisposable or IAsyncDisposable;

/// <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.
/// the scope's disposal must dispose them. Override <see cref="ScopeOwnsCreatedObjects"/> to change this ownership.
/// </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"/>
/// When <see cref="ScopeOwnsCreatedObjects"/> returns true, the scope 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.
/// When it returns false, TUnit disposes disposable returned objects when the test ends.
/// </remarks>
public abstract object? Create(TScope scope, Type type);
}
Loading
Loading