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

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