Skip to content

Data-source objects for skipped tests/rows are never disposed #7156

Closed

Description

@thomhurst

Summary

Confirmed on main (04786b3) in both modes, for skipped tests. Disposable data-source objects created for a test that ends up skipped are never disposed. This covers a [Skip] test with a non-shared ClassDataSource argument and a TestDataRow with Skip set. The test builder creates the objects at discovery, but skipped tests never register them with the object tracker, so nothing ever disposes them.

Repro

public abstract class TrackedDisposable : IDisposable
{
    private static int _next;
    public int Id { get; } = Interlocked.Increment(ref _next);
    protected TrackedDisposable() => Log($"CREATE {GetType().Name}#{Id}");
    public void Dispose() => Log($"DISPOSE {GetType().Name}#{Id}");
    public override string ToString() => $"{GetType().Name}#{Id}";
    private static void Log(string s) => File.AppendAllText("suspect.log", s + Environment.NewLine);
}

public class SkipMethodClassDs : TrackedDisposable;
public class NormalMethodClassDs : TrackedDisposable;
public class SkipRowMds : TrackedDisposable;

public class SkippedRowsLeak
{
    [Test, Skip("skipped")]
    [ClassDataSource<SkipMethodClassDs>(Shared = SharedType.None)]
    public void SkippedWithClassDs(SkipMethodClassDs d) { }

    [Test]
    [ClassDataSource<NormalMethodClassDs>(Shared = SharedType.None)]
    public void NormalWithClassDs(NormalMethodClassDs d) { }

    public static IEnumerable<TestDataRow<SkipRowMds>> Rows()
    {
        yield return new TestDataRow<SkipRowMds>(new SkipRowMds(), Skip: "row skipped");
    }

    [Test]
    [MethodDataSource(nameof(Rows))]
    public void SkippedRow(SkipRowMds d) { }
}

Observed (same in source-gen and --reflection)

CREATE SkipMethodClassDs#1
CREATE NormalMethodClassDs#2
CREATE SkipRowMds#3
DISPOSE NormalMethodClassDs#2

The test displays as SkippedWithClassDs(SkipMethodClassDs#1), which shows the object was built. SkipMethodClassDs and SkipRowMds are never disposed. The non-skipped control is disposed.

Expected

Objects created for a row whose test is skipped are disposed, for example when the skipped test completes or at session end. As in #7123, shared or keyed instances that other tests still hold must not be disposed early, so this should go through the object tracker's reference counting.

Where

  • src/TUnit.Engine/Services/TestFilterService.cs:121-128: when SkipReason is set, RegisterTest returns before RegisterTestArgumentsAsync, so the row's objects are never tracked. Skipping argument registration is intentional: it avoids initializing expensive shared fixtures. But the non-shared objects the builder already created are then orphaned.
  • src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs:77-83: the skip short-circuit. The UntrackObjects call at line 224 then has nothing to release.
  • src/TUnit.Engine/Building/TestBuilder.cs:300-306: class and method data factories run for every row, including rows that end up skipped.

Also checked: rows dropped as incompatible with a concrete generic instantiation (unconfirmed)

TestBuilder.cs:338-345 drops a row with continue when IsDataCompatibleWithExpectedTypes returns false. By that point methodDataFactory() (line 306) and InitializeClassDataAsync(methodData) (line 335) have already run, and the objects are not tracked. Reading the code, those objects would leak the same way. I could not reach that branch, though. With [GenerateGenericTest(typeof(A))] [GenerateGenericTest(typeof(B))] plus either a MethodDataSource yielding mixed A/B rows or [ClassDataSource<A>] [ClassDataSource<B>], incompatible rows were not dropped. They ran and failed with a cast error, and their objects were disposed. Treat the dropped-row variant as found by code reading only.

Related

Activity

  1. added a commit that references this issue on Oct 20, 2026
    7dd613b
  2. thomhurst commented on Nov 2, 2026

    @thomhurst
    OwnerAuthor

    Confirmed the incompatible-row limitation while validating #7417 at 2ba3c66, SDK 10.0.403 / net10.0, generated mode. A custom DataSourceGeneratorAttribute<object> yielding factories for "incompatible" and 42, applied to [Test, GenerateGenericTest(typeof(int))] public void Example<T>(T value), executes the string row and fails with InvalidCastException in the generated invoker rather than filtering it. Run the test with a targeted --treenode-filter. The integer row passes. This confirms the final section's existing observation; it prevents using that integration reproduction to verify rejected-row callback isolation in #7417.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions