From f4de3003360e2bc4f07785928f7e42b170bf7167 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Oct 2025 00:05:23 +0000
Subject: [PATCH 1/5] feat: introduce TestBuildingContext for optimized test
building and filtering
---
.../Building/Interfaces/ITestBuilder.cs | 3 +-
TUnit.Engine/Building/TestBuilder.cs | 67 ++++++++++++++++++-
TUnit.Engine/Building/TestBuilderPipeline.cs | 15 +++--
TUnit.Engine/Building/TestBuildingContext.cs | 19 ++++++
TUnit.Engine/Services/TestRegistry.cs | 4 +-
TUnit.Engine/TestDiscoveryService.cs | 13 +++-
6 files changed, 108 insertions(+), 13 deletions(-)
create mode 100644 TUnit.Engine/Building/TestBuildingContext.cs
diff --git a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
index e2066eadff7..c50bda428e4 100644
--- a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
+++ b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
@@ -22,11 +22,12 @@ internal interface ITestBuilder
/// This is the main method that replaces the old DataSourceExpander approach.
///
/// The test metadata with DataCombinationGenerator
+ /// Context for optimizing test building (e.g., pre-filtering during execution)
/// Collection of executable tests for all data combinations
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
#endif
- Task> BuildTestsFromMetadataAsync(TestMetadata metadata);
+ Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext);
///
/// Streaming version that yields tests as they're built without buffering
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 11ed1b00750..1a5f57e3b2f 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -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;
@@ -114,8 +116,18 @@ private async Task 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> BuildTestsFromMetadataAsync(TestMetadata metadata)
+ public async Task> 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();
+ }
+ }
+
var tests = new List();
try
@@ -126,7 +138,7 @@ public async Task> 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;
@@ -1563,4 +1575,55 @@ public async IAsyncEnumerable BuildTestsStreamingAsync(
return await CreateFailedTestForDataGenerationError(metadata, ex);
}
}
+
+ ///
+ /// 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.
+ ///
+ private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata)
+ {
+#pragma warning disable TPEXP
+ return filter switch
+ {
+ null => true,
+ NopFilter => true,
+ TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata),
+ TestNodeUidListFilter => true, // Can't pre-filter without test IDs - be conservative
+ _ => true // Unknown filter type - be conservative
+ };
+#pragma warning restore TPEXP
+ }
+
+ ///
+ /// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter.
+ ///
+#pragma warning disable TPEXP
+ private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata)
+#pragma warning restore TPEXP
+ {
+ var path = BuildPathFromMetadata(metadata);
+
+ // Use the actual filter matching logic with the test path
+ // For now, pass empty property bag since we're doing a lightweight check
+ // Properties from attributes would require calling AttributeFactory which might be too expensive
+ var emptyPropertyBag = new PropertyBag(new List());
+ var matches = filter.MatchesFilter(path, emptyPropertyBag);
+
+ return matches;
+ }
+
+ ///
+ /// Builds the test path from metadata, matching the format used by TestFilterService.
+ /// Path format: /AssemblyName/Namespace/ClassName/MethodName
+ ///
+ 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}";
+ }
}
diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs
index 1ccb75146d5..ddeb3116914 100644
--- a/TUnit.Engine/Building/TestBuilderPipeline.cs
+++ b/TUnit.Engine/Building/TestBuilderPipeline.cs
@@ -61,7 +61,9 @@ public async Task> 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);
}
///
@@ -71,6 +73,7 @@ public async Task> BuildTestsAsync(string te
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")]
public async Task> BuildTestsStreamingAsync(
string testSessionId,
+ TestBuildingContext buildingContext,
CancellationToken cancellationToken = default)
{
// Get metadata streaming if supported
@@ -78,7 +81,7 @@ public async Task> BuildTestsStreamingAsync(
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);
}
@@ -93,7 +96,7 @@ private async IAsyncEnumerable ToAsyncEnumerable(IEnumerable> BuildTestsFromMetadataAsync(IEnumerable testMetadata)
+ public async Task> BuildTestsFromMetadataAsync(IEnumerable testMetadata, TestBuildingContext buildingContext)
{
var testGroups = await testMetadata.SelectAsync(async metadata =>
{
@@ -105,7 +108,7 @@ public async Task> 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)
{
@@ -210,7 +213,7 @@ private async Task 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 BuildTestsFromSingleMetadataAsync(TestMetadata metadata)
+ private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
{
TestMetadata resolvedMetadata;
Exception? resolutionError = null;
@@ -324,7 +327,7 @@ private async IAsyncEnumerable 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(testsFromMetadata);
}
}
diff --git a/TUnit.Engine/Building/TestBuildingContext.cs b/TUnit.Engine/Building/TestBuildingContext.cs
new file mode 100644
index 00000000000..b48669f9af6
--- /dev/null
+++ b/TUnit.Engine/Building/TestBuildingContext.cs
@@ -0,0 +1,19 @@
+using Microsoft.Testing.Platform.Requests;
+
+namespace TUnit.Engine.Building;
+
+///
+/// Context information for building tests, used to optimize test discovery and execution.
+///
+internal record TestBuildingContext(
+ ///
+ /// Indicates whether tests are being built for execution (true) or discovery/display (false).
+ /// When true, optimizations like early filtering can be applied.
+ ///
+ bool IsForExecution,
+
+ ///
+ /// The filter to apply during test building. Only relevant when IsForExecution is true.
+ ///
+ ITestExecutionFilter? Filter
+);
diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs
index 131c9feb589..737998f8f55 100644
--- a/TUnit.Engine/Services/TestRegistry.cs
+++ b/TUnit.Engine/Services/TestRegistry.cs
@@ -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)
{
diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs
index 05de9b3332b..9d40643ac26 100644
--- a/TUnit.Engine/TestDiscoveryService.cs
+++ b/TUnit.Engine/TestDiscoveryService.cs
@@ -55,12 +55,15 @@ public async Task 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();
var dependentTests = new List();
var allTests = new List();
- await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
+ await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
@@ -131,6 +134,7 @@ public async Task DiscoverTests(string testSessionId, ITest
/// Streams test discovery for parallel discovery and execution
private async IAsyncEnumerable DiscoverTestsStreamAsync(
string testSessionId,
+ Building.TestBuildingContext buildingContext,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -138,7 +142,7 @@ private async IAsyncEnumerable DiscoverTestsStreamAsync(
// 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)
{
@@ -164,9 +168,12 @@ public async IAsyncEnumerable 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();
- await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
+ await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}
From b10a95fbf6dce93354695f22bf8ad98a3ef2eb59 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Oct 2025 01:10:23 +0000
Subject: [PATCH 2/5] fix: handle null case for test discovery in
TestDiscoveryAfterTests
---
TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs
index b1f80522e11..5d02d5dac8c 100644
--- a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs
+++ b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs
@@ -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);
+ }
}
}
From cc80558add290ef0a21f1fa334cf1c7437b30d6f Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Oct 2025 01:42:54 +0000
Subject: [PATCH 3/5] refactor: improve filter matching logic in TestBuilder
for property conditions
---
TUnit.Engine/Building/TestBuilder.cs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 1a5f57e3b2f..46f3f2bcb85 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -1602,11 +1602,12 @@ private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metada
#pragma warning restore TPEXP
{
var path = BuildPathFromMetadata(metadata);
-
- // Use the actual filter matching logic with the test path
- // For now, pass empty property bag since we're doing a lightweight check
- // Properties from attributes would require calling AttributeFactory which might be too expensive
var emptyPropertyBag = new PropertyBag(new List());
+
+ // For filters with property conditions, we cannot pre-filter accurately
+ // because properties are only available after the test is fully built.
+ // However, we can still check if the path portion matches.
+ // The platform's filter will handle property conditions in the second pass.
var matches = filter.MatchesFilter(path, emptyPropertyBag);
return matches;
From f1e986c468bb072ea7de57324bbbd1e9985d5b11 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Oct 2025 02:04:04 +0000
Subject: [PATCH 4/5] feat: enhance filter matching in TestBuilder to support
path-only evaluation
---
TUnit.Engine/Building/TestBuilder.cs | 45 +++++++++++++++++++++++-----
1 file changed, 37 insertions(+), 8 deletions(-)
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 46f3f2bcb85..be1ef36ded3 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -1599,19 +1599,48 @@ private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata meta
///
#pragma warning disable TPEXP
private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata)
-#pragma warning restore TPEXP
{
+ 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(new List());
+ var emptyPropertyBag = new PropertyBag();
+ return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag);
+ }
- // For filters with property conditions, we cannot pre-filter accurately
- // because properties are only available after the test is fully built.
- // However, we can still check if the path portion matches.
- // The platform's filter will handle property conditions in the second pass.
- var matches = filter.MatchesFilter(path, emptyPropertyBag);
+ ///
+ /// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor.
+ ///
+ private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString)
+ {
+ var constructor = typeof(TreeNodeFilter).GetConstructors(
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
- return matches;
+ return (TreeNodeFilter)constructor.Invoke(new object[] { filterString });
}
+#pragma warning restore TPEXP
///
/// Builds the test path from metadata, matching the format used by TestFilterService.
From 29a954beb1c9af623b29dacf3a0e01ee22fc4943 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Oct 2025 02:10:55 +0000
Subject: [PATCH 5/5] feat: implement UID matching logic in TestBuilder for
TestNodeUidListFilter
---
TUnit.Engine/Building/TestBuilder.cs | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index be1ef36ded3..78fa82ca948 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -1588,12 +1588,38 @@ private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata meta
null => true,
NopFilter => true,
TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata),
- TestNodeUidListFilter => true, // Can't pre-filter without test IDs - be conservative
+ TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata),
_ => true // Unknown filter type - be conservative
};
#pragma warning restore TPEXP
}
+ ///
+ /// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains
+ /// the namespace, class name, and method name.
+ ///
+ 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;
+ }
+
///
/// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter.
///