Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion TUnit.Engine/Building/Interfaces/ITestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ internal interface ITestBuilder
/// This is the main method that replaces the old DataSourceExpander approach.
/// </summary>
/// <param name="metadata">The test metadata with DataCombinationGenerator</param>
/// <param name="buildingContext">Context for optimizing test building (e.g., pre-filtering during execution)</param>
/// <returns>Collection of executable tests for all data combinations</returns>
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
#endif
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata);
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext);

/// <summary>
/// Streaming version that yields tests as they're built without buffering
Expand Down
123 changes: 121 additions & 2 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Requests;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Exceptions;
Expand Down Expand Up @@ -114,8 +116,18 @@ private async Task<object> CreateInstance(TestMetadata metadata, Type[] resolved
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
#endif
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata)
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
{
// OPTIMIZATION: Pre-filter in execution mode to skip building tests that cannot match the filter
if (buildingContext.IsForExecution && buildingContext.Filter != null)
{
if (!CouldTestMatchFilter(buildingContext.Filter, metadata))
{
// This test class cannot match the filter - skip all expensive work!
return Array.Empty<AbstractExecutableTest>();
}
}

var tests = new List<AbstractExecutableTest>();

try
Expand All @@ -126,7 +138,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
// Build tests from each concrete instantiation
foreach (var concreteMetadata in genericMetadata.ConcreteInstantiations.Values)
{
var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata);
var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext);
tests.AddRange(concreteTests);
}
return tests;
Expand Down Expand Up @@ -1563,4 +1575,111 @@ public async IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
return await CreateFailedTestForDataGenerationError(metadata, ex);
}
}

/// <summary>
/// Determines if a test could potentially match the filter without building the full test object.
/// This is a conservative check - returns true unless we can definitively rule out the test.
/// </summary>
private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata)
{
#pragma warning disable TPEXP
return filter switch
{
null => true,
NopFilter => true,
TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata),
TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata),
_ => true // Unknown filter type - be conservative
};
#pragma warning restore TPEXP
}

/// <summary>
/// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains
/// the namespace, class name, and method name.
/// </summary>
private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetadata metadata)
{
var classMetadata = metadata.MethodMetadata.Class;
var namespaceName = classMetadata.Namespace ?? "";
var className = metadata.TestClassType.Name;
var methodName = metadata.TestMethodName;

// Check if any UID in the filter contains all three components
foreach (var uid in filter.TestNodeUids)
{
var uidValue = uid.Value;
if (uidValue.Contains(namespaceName) &&
uidValue.Contains(className) &&
uidValue.Contains(methodName))
{
return true;
}
}

return false;
}

/// <summary>
/// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter.
/// </summary>
#pragma warning disable TPEXP
private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata)
{
var filterString = filter.Filter;

// No filter means match all
if (string.IsNullOrEmpty(filterString))
{
return true;
}

// If the filter contains property conditions, strip them for path-only matching
// Property conditions will be evaluated in the second pass after tests are fully built
TreeNodeFilter pathOnlyFilter;
if (filterString.Contains('['))
{
// Strip all property conditions: [key=value]
// Use regex to remove all [...] blocks
var strippedFilterString = System.Text.RegularExpressions.Regex.Replace(filterString, @"\[([^\]]*)\]", "");

// Create a new TreeNodeFilter with the stripped filter string using reflection
pathOnlyFilter = CreateTreeNodeFilterViaReflection(strippedFilterString);
}
else
{
pathOnlyFilter = filter;
}

var path = BuildPathFromMetadata(metadata);
var emptyPropertyBag = new PropertyBag();
return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag);
}

/// <summary>
/// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor.
/// </summary>
private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString)
{
var constructor = typeof(TreeNodeFilter).GetConstructors(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];

return (TreeNodeFilter)constructor.Invoke(new object[] { filterString });
}
#pragma warning restore TPEXP

/// <summary>
/// Builds the test path from metadata, matching the format used by TestFilterService.
/// Path format: /AssemblyName/Namespace/ClassName/MethodName
/// </summary>
private static string BuildPathFromMetadata(TestMetadata metadata)
{
var classMetadata = metadata.MethodMetadata.Class;
var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*";
var namespaceName = classMetadata.Namespace ?? "*";
var className = classMetadata.Name;
var methodName = metadata.TestMethodName;

return $"/{assemblyName}/{namespaceName}/{className}/{methodName}";
}
}
15 changes: 9 additions & 6 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsAsync(string te
{
var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);

return await BuildTestsFromMetadataAsync(collectedMetadata).ConfigureAwait(false);
// For this method (non-streaming), we're not in execution mode so no filter optimization
var buildingContext = new TestBuildingContext(IsForExecution: false, Filter: null);
return await BuildTestsFromMetadataAsync(collectedMetadata, buildingContext).ConfigureAwait(false);
}

/// <summary>
Expand All @@ -71,14 +73,15 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsAsync(string te
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")]
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsStreamingAsync(
string testSessionId,
TestBuildingContext buildingContext,
CancellationToken cancellationToken = default)
{
// Get metadata streaming if supported
// Fall back to non-streaming collection
var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);

return await collectedMetadata
.SelectManyAsync(BuildTestsFromSingleMetadataAsync, cancellationToken: cancellationToken)
.SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken)
.ProcessInParallel(cancellationToken: cancellationToken);
}

Expand All @@ -93,7 +96,7 @@ private async IAsyncEnumerable<TestMetadata> ToAsyncEnumerable(IEnumerable<TestM

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT/trimmed scenarios")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")]
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(IEnumerable<TestMetadata> testMetadata)
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(IEnumerable<TestMetadata> testMetadata, TestBuildingContext buildingContext)
{
var testGroups = await testMetadata.SelectAsync(async metadata =>
{
Expand All @@ -105,7 +108,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
return await GenerateDynamicTests(metadata).ConfigureAwait(false);
}

return await _testBuilder.BuildTestsFromMetadataAsync(metadata).ConfigureAwait(false);
return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -210,7 +213,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
#endif
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata)
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
{
TestMetadata resolvedMetadata;
Exception? resolutionError = null;
Expand Down Expand Up @@ -324,7 +327,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
else
{
// Normal test metadata goes through the standard test builder
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata).ConfigureAwait(false);
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false);
testsToYield = new List<AbstractExecutableTest>(testsFromMetadata);
}
}
Expand Down
19 changes: 19 additions & 0 deletions TUnit.Engine/Building/TestBuildingContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.Testing.Platform.Requests;

namespace TUnit.Engine.Building;

/// <summary>
/// Context information for building tests, used to optimize test discovery and execution.
/// </summary>
internal record TestBuildingContext(
/// <summary>
/// Indicates whether tests are being built for execution (true) or discovery/display (false).
/// When true, optimizations like early filtering can be applied.
/// </summary>
bool IsForExecution,

/// <summary>
/// The filter to apply during test building. Only relevant when IsForExecution is true.
/// </summary>
ITestExecutionFilter? Filter
);
4 changes: 3 additions & 1 deletion TUnit.Engine/Services/TestRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ private async Task ProcessPendingDynamicTests()
testMetadataList.Add(metadata);
}

var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList);
// These are dynamic tests registered after discovery, so not in execution mode with a filter
var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null);
var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList, buildingContext);

foreach (var test in builtTests)
{
Expand Down
13 changes: 10 additions & 3 deletions TUnit.Engine/TestDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest

contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext();

// Create building context for optimization
var buildingContext = new Building.TestBuildingContext(isForExecution, filter);

// Stage 1: Stream independent tests immediately while buffering dependent tests
var independentTests = new List<AbstractExecutableTest>();
var dependentTests = new List<AbstractExecutableTest>();
var allTests = new List<AbstractExecutableTest>();

await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);

Expand Down Expand Up @@ -131,14 +134,15 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
/// Streams test discovery for parallel discovery and execution
private async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsStreamAsync(
string testSessionId,
Building.TestBuildingContext buildingContext,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

// Set a reasonable timeout for test discovery (5 minutes)
cts.CancelAfter(TimeSpan.FromMinutes(5));

var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, cancellationToken).ConfigureAwait(false);
var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false);

foreach (var test in tests)
{
Expand All @@ -164,9 +168,12 @@ public async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsFullyStreamin
{
await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);

// Create building context - this is for discovery/streaming, not execution filtering
var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null);

// Collect all tests first (like source generation mode does)
var allTests = new List<AbstractExecutableTest>();
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}
Expand Down
7 changes: 5 additions & 2 deletions TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ public static async Task AfterEveryTestDiscovery(TestDiscoveryContext context)
{
await FilePolyfill.WriteAllTextAsync($"TestDiscoveryAfterTests{Guid.NewGuid():N}.txt", $"{context.AllTests.Count()} tests found");

var test = context.AllTests.First(x =>
var test = context.AllTests.FirstOrDefault(x =>
x.TestDetails.TestName == nameof(TestDiscoveryAfterTests.EnsureAfterEveryTestDiscoveryHit));

test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true);
if (test is not null)
{
test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true);
}
}
}

Expand Down
Loading