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. ///