Skip to content

Folded data-driven tests should use fresh TestContext per iteration, not just clear buffers #7933

@nohwnd

Description

@nohwnd

The folded execution path in TryExecuteFoldedDataDrivenTestsAsync reuses the same TestContextImplementation across all DataRow iterations. PR #7926 fixes the immediate symptom (O(n²) output in TRX) by adding GetAndClearOut/Err/Trace, but the underlying problem is the shared context.

The unfolded path (Path 1, DataType == ITestDataSource) creates a fresh TestMethodRunner per DataRow — each gets its own TestContextImplementation, so there's no shared state between rows. The folded path loops all rows on the same TestMethodInfo with the same context. Every field on TestContextImplementation that accumulates state is a potential repeat of this bug.

Concrete example

Compare the two paths in TestMethodRunner.RunTestMethodAsync():

Unfolded (safe): each DataRow is a separate test case discovered individually. Each enters RunTestMethodAsync with its own TestMethodRunner → own TestMethodInfo → own TestContextImplementation. No sharing.

Folded: one test case enters RunTestMethodAsync, falls through to TryExecuteFoldedDataDrivenTestsAsync, which loops:

foreach (object?[] data in dataSource)
{
    TestResult[] testResults = await ExecuteTestWithDataSourceAsync(testDataSource, data, ...);
    results.AddRange(testResults);
}

All iterations share _testMethodInfo → same TestContext → same _stdOutStringBuilder, _stdErrStringBuilder, _traceStringBuilder. The GetAndClear fix clears these three, but any future accumulated field would need the same treatment.

Repro

[DynamicData] with a non-serializable type triggers folding (discovery can't serialize the data → TryUnfoldITestDataSource returns false):

public sealed class Payload
{
    public int Id { get; init; }
    public Func<int> GetId { get; init; } = null!;
    public override string ToString() => $"Payload({Id})";
}

[TestClass]
public sealed class VerboseTests
{
    public TestContext TestContext { get; set; } = null!;

    public static IEnumerable<Payload[]> TestData
    {
        get
        {
            for (int i = 1; i <= 50; i++)
            {
                int capturedId = i;
                yield return [new Payload { Id = i, GetId = () => capturedId }];
            }
        }
    }

    [TestMethod]
    [DynamicData(nameof(TestData))]
    public void VerboseDataDrivenTest(Payload p)
    {
        for (int i = 0; i < 10; i++)
            Console.WriteLine($"[Row {p.Id:D3}] line {i:D3}: padding abcdefghijklmnop");

        for (int i = 0; i < 10; i++)
            TestContext.WriteLine($"[Row {p.Id:D3}] ctx  {i:D3}: padding abcdefghijklmnop");
    }
}

With MSTest 4.0.2, both VSTest and MTP produce a ~1.4 MB TRX (vs ~168 KB expected). Row 1 StdOut = 2 KB, Row 50 = 54 KB.

Suggestion

Instead of clearing individual fields after reading, create a fresh TestContextImplementation (or at least reset all accumulated state) at the start of each iteration in TryExecuteFoldedDataDrivenTestsAsync. This makes the folded path structurally equivalent to the unfolded path — new state bugs become impossible rather than requiring per-field GetAndClear methods.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions