Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
perf: Implement full end-to-end streaming for test discovery
Replaced collection-based test discovery with true streaming throughout the entire pipeline:

## Changes:
- Modified ITestSource interface to return IAsyncEnumerable instead of List<TestMetadata>
- Updated source generator to yield tests instead of collecting in lists
- Implemented streaming in AotTestDataCollector to stream tests from sources
- Added IStreamingTestDataCollector implementation to AotTestDataCollector
- Converted dynamic test collection to streaming

## Benefits:
- Tests start executing immediately as they're discovered
- Eliminated memory pressure from collecting all tests upfront
- True streaming from source generator through to execution
- Expected 100-200ms additional improvement for large test suites

This completes the streaming implementation, enabling tests to flow from discovery to execution without buffering.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
  • Loading branch information
thomhurst and claude committed Aug 7, 2025
commit 1a2b8633006bed5e0ff46ecf7c3e9083accc30f8
13 changes: 6 additions & 7 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,10 @@ private static void GenerateTestMetadata(CodeWriter writer, Compilation compilat
// Generate reflection-based field accessors for init-only properties with data source attributes
GenerateReflectionFieldAccessors(writer, testMethod.TypeSymbol, className);

writer.AppendLine("public async global::System.Threading.Tasks.ValueTask<global::System.Collections.Generic.List<global::TUnit.Core.TestMetadata>> GetTestsAsync(string testSessionId)");
writer.AppendLine("public async global::System.Collections.Generic.IAsyncEnumerable<global::TUnit.Core.TestMetadata> GetTestsAsync(string testSessionId, [global::System.Runtime.CompilerServices.EnumeratorCancellation] global::System.Threading.CancellationToken cancellationToken = default)");
writer.AppendLine("{");
writer.Indent();

writer.AppendLine("var tests = new global::System.Collections.Generic.List<global::TUnit.Core.TestMetadata>();");
writer.AppendLine();

// Check if we have generic types or methods
Expand Down Expand Up @@ -267,7 +266,7 @@ private static void GenerateTestMetadata(CodeWriter writer, Compilation compilat
GenerateTestMetadataInstance(writer, compilation, testMethod, className, combinationGuid);
}

writer.AppendLine("return tests;");
writer.AppendLine("yield break;");
writer.Unindent();
writer.AppendLine("}");

Expand Down Expand Up @@ -335,7 +334,7 @@ private static void GenerateSpecificGenericInstantiation(
writer.AppendLine("};");

writer.AppendLine("metadata.TestSessionId = testSessionId;");
writer.AppendLine("tests.Add(metadata);");
writer.AppendLine("yield return metadata;");

writer.Unindent();
writer.AppendLine("}");
Expand Down Expand Up @@ -494,7 +493,7 @@ private static void GenerateTestMetadataInstance(CodeWriter writer, Compilation
writer.AppendLine("metadata.TestSessionId = testSessionId;");
}

writer.AppendLine("tests.Add(metadata);");
writer.AppendLine("yield return metadata;");
}

private static void GenerateMetadata(CodeWriter writer, Compilation compilation, TestMethodMetadata testMethod)
Expand Down Expand Up @@ -2892,7 +2891,7 @@ private static void GenerateGenericTestWithConcreteTypes(
writer.AppendLine("};");

writer.AppendLine("genericMetadata.TestSessionId = testSessionId;");
writer.AppendLine("tests.Add(genericMetadata);");
writer.AppendLine("yield return genericMetadata;");
}

private static bool ValidateClassTypeConstraints(INamedTypeSymbol classSymbol, ITypeSymbol[] typeArguments)
Expand Down Expand Up @@ -4366,7 +4365,7 @@ private static void GenerateConcreteTestMetadataForNonGeneric(
writer.Unindent();
writer.AppendLine("};");

writer.AppendLine("tests.Add(metadata);");
writer.AppendLine("yield return metadata;");
}
}

Expand Down
2 changes: 1 addition & 1 deletion TUnit.Core/Interfaces/SourceGenerator/ITestSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

public interface ITestSource
{
ValueTask<List<TestMetadata>> GetTestsAsync(string testSessionId);
IAsyncEnumerable<TestMetadata> GetTestsAsync(string testSessionId, CancellationToken cancellationToken = default);
}
158 changes: 54 additions & 104 deletions TUnit.Engine/Building/Collectors/AotTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using TUnit.Core;
using TUnit.Engine.Building.Interfaces;

Expand All @@ -11,7 +12,7 @@ namespace TUnit.Engine.Building.Collectors;
/// AOT-compatible test data collector that uses source-generated test metadata.
/// Operates without reflection by leveraging pre-compiled test sources.
/// </summary>
internal sealed class AotTestDataCollector : ITestDataCollector
internal sealed class AotTestDataCollector : ITestDataCollector, IStreamingTestDataCollector
{
private readonly HashSet<Type>? _filterTypes;

Expand All @@ -21,144 +22,93 @@ public AotTestDataCollector(HashSet<Type>? filterTypes)
}
public async Task<IEnumerable<TestMetadata>> CollectTestsAsync(string testSessionId)
{
// Get all test sources as a list to enable indexed parallel processing
var testSourcesList = Sources.TestSources
.Where(kvp => _filterTypes == null || _filterTypes.Contains(kvp.Key))
.SelectMany(kvp => kvp.Value)
.ToList();

if (testSourcesList.Count == 0)
// Compatibility method - collects all from streaming
var tests = new List<TestMetadata>();
await foreach (var test in CollectTestsStreamingAsync(testSessionId, CancellationToken.None))
{
return [];
tests.Add(test);
}
return tests;
}

// Use indexed collection to maintain order and prevent race conditions
var resultsByIndex = new ConcurrentDictionary<int, IEnumerable<TestMetadata>>();

// Use true parallel processing with optimal concurrency
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};

Parallel.ForEach(testSourcesList.Select((source, index) => new { source, index }),
parallelOptions, item =>
{
var index = item.index;
var testSource = item.source;

try
{
// Run async method synchronously since we're in parallel processing context
var tests = testSource.GetTestsAsync(testSessionId).ConfigureAwait(false).GetAwaiter().GetResult();
resultsByIndex[index] = tests;
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to collect tests from source {testSource.GetType().Name}: {ex.Message}", ex);
}
});
public async IAsyncEnumerable<TestMetadata> CollectTestsStreamingAsync(
string testSessionId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Stream from all test sources
var testSources = Sources.TestSources
.Where(kvp => _filterTypes == null || _filterTypes.Contains(kvp.Key))
.SelectMany(kvp => kvp.Value);

// Reassemble results in original order
var allTests = new List<TestMetadata>();
for (var i = 0; i < testSourcesList.Count; i++)
// Stream tests from each source
foreach (var testSource in testSources)
{
if (resultsByIndex.TryGetValue(i, out var tests))
cancellationToken.ThrowIfCancellationRequested();

await foreach (var metadata in testSource.GetTestsAsync(testSessionId, cancellationToken))
{
allTests.AddRange(tests);
yield return metadata;
}
}

// Also collect dynamic tests from registered dynamic test sources
var dynamicTests = await CollectDynamicTests(testSessionId);
allTests.AddRange(dynamicTests);

if (allTests.Count == 0)
// Also stream dynamic tests
await foreach (var metadata in CollectDynamicTestsStreaming(testSessionId, cancellationToken))
{
// No generated tests found
return [
];
yield return metadata;
}

return allTests;
}

private async Task<List<TestMetadata>> CollectDynamicTests(string testSessionId)
private async IAsyncEnumerable<TestMetadata> CollectDynamicTestsStreaming(
string testSessionId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var dynamicTestMetadata = new List<TestMetadata>();

if (Sources.DynamicTestSources.Count == 0)
{
return dynamicTestMetadata;
yield break;
}

// Convert dynamic test sources to list for parallel processing
var dynamicSourcesList = Sources.DynamicTestSources.ToList();

// Use indexed collection to maintain order
var resultsByIndex = new ConcurrentDictionary<int, List<TestMetadata>>();

var parallelOptions = new ParallelOptions
// Stream from each dynamic test source
foreach (var source in Sources.DynamicTestSources)
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};

await Task.Run(() =>
{
Parallel.ForEach(dynamicSourcesList.Select((source, index) => new { source, index }),
parallelOptions, item =>
cancellationToken.ThrowIfCancellationRequested();

IEnumerable<DynamicTest> dynamicTests;
try
{
dynamicTests = source.CollectDynamicTests(testSessionId);
}
catch (Exception ex)
{
var index = item.index;
var source = item.source;
var testsForSource = new List<TestMetadata>();
// Create a failed test metadata for this dynamic test source
yield return CreateFailedTestMetadataForDynamicSource(source, ex);
continue;
}

try
{
var dynamicTests = source.CollectDynamicTests(testSessionId);
foreach (var dynamicTest in dynamicTests)
{
// Convert each dynamic test to test metadata
var metadataList = ConvertDynamicTestToMetadata(dynamicTest).ConfigureAwait(false).GetAwaiter().GetResult();
testsForSource.AddRange(metadataList);
}
resultsByIndex[index] = testsForSource;
}
catch (Exception ex)
foreach (var dynamicTest in dynamicTests)
{
// Convert each dynamic test to test metadata and stream
await foreach (var metadata in ConvertDynamicTestToMetadataStreaming(dynamicTest, cancellationToken))
{
// Create a failed test metadata for this dynamic test source
var failedTest = CreateFailedTestMetadataForDynamicSource(source, ex);
resultsByIndex[index] = [failedTest];
yield return metadata;
}
});
});

// Reassemble results in original order
for (var i = 0; i < dynamicSourcesList.Count; i++)
{
if (resultsByIndex.TryGetValue(i, out var tests))
{
dynamicTestMetadata.AddRange(tests);
}
}

return dynamicTestMetadata;
}

private async Task<List<TestMetadata>> ConvertDynamicTestToMetadata(DynamicTest dynamicTest)
private async IAsyncEnumerable<TestMetadata> ConvertDynamicTestToMetadataStreaming(
DynamicTest dynamicTest,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var testMetadataList = new List<TestMetadata>();

foreach (var discoveryResult in dynamicTest.GetTests())
{
cancellationToken.ThrowIfCancellationRequested();

if (discoveryResult is DynamicDiscoveryResult { TestMethod: not null } dynamicResult)
{
var testMetadata = await CreateMetadataFromDynamicDiscoveryResult(dynamicResult);
testMetadataList.Add(testMetadata);
yield return testMetadata;
}
}

return testMetadataList;
}

private Task<TestMetadata> CreateMetadataFromDynamicDiscoveryResult(DynamicDiscoveryResult result)
Expand Down
11 changes: 11 additions & 0 deletions TUnit.Engine/Building/Interfaces/ITestBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using TUnit.Core;

namespace TUnit.Engine.Building.Interfaces;
Expand All @@ -23,4 +24,14 @@ internal interface ITestBuilder
/// <param name="metadata">The test metadata with DataCombinationGenerator</param>
/// <returns>Collection of executable tests for all data combinations</returns>
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata);

/// <summary>
/// Streaming version that yields tests as they're built without buffering
/// </summary>
/// <param name="metadata">The test metadata with DataCombinationGenerator</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Stream of executable tests for all data combinations</returns>
IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
TestMetadata metadata,
CancellationToken cancellationToken = default);
}