Skip to content

Commit ea52f86

Browse files
authored
+semver:minor - feat: introduce TestBuildingContext for optimized test building and filtering (#3547)
* feat: introduce TestBuildingContext for optimized test building and filtering * fix: handle null case for test discovery in TestDiscoveryAfterTests
1 parent 47ee614 commit ea52f86

File tree

7 files changed

+169
-15
lines changed

7 files changed

+169
-15
lines changed

TUnit.Engine/Building/Interfaces/ITestBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ internal interface ITestBuilder
2222
/// This is the main method that replaces the old DataSourceExpander approach.
2323
/// </summary>
2424
/// <param name="metadata">The test metadata with DataCombinationGenerator</param>
25+
/// <param name="buildingContext">Context for optimizing test building (e.g., pre-filtering during execution)</param>
2526
/// <returns>Collection of executable tests for all data combinations</returns>
2627
#if NET6_0_OR_GREATER
2728
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
2829
#endif
29-
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata);
30+
Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext);
3031

3132
/// <summary>
3233
/// Streaming version that yields tests as they're built without buffering

TUnit.Engine/Building/TestBuilder.cs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Diagnostics.CodeAnalysis;
2+
using Microsoft.Testing.Platform.Extensions.Messages;
3+
using Microsoft.Testing.Platform.Requests;
24
using TUnit.Core;
35
using TUnit.Core.Enums;
46
using TUnit.Core.Exceptions;
@@ -114,8 +116,18 @@ private async Task<object> CreateInstance(TestMetadata metadata, Type[] resolved
114116
#if NET6_0_OR_GREATER
115117
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
116118
#endif
117-
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata)
119+
public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
118120
{
121+
// OPTIMIZATION: Pre-filter in execution mode to skip building tests that cannot match the filter
122+
if (buildingContext.IsForExecution && buildingContext.Filter != null)
123+
{
124+
if (!CouldTestMatchFilter(buildingContext.Filter, metadata))
125+
{
126+
// This test class cannot match the filter - skip all expensive work!
127+
return Array.Empty<AbstractExecutableTest>();
128+
}
129+
}
130+
119131
var tests = new List<AbstractExecutableTest>();
120132

121133
try
@@ -126,7 +138,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
126138
// Build tests from each concrete instantiation
127139
foreach (var concreteMetadata in genericMetadata.ConcreteInstantiations.Values)
128140
{
129-
var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata);
141+
var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext);
130142
tests.AddRange(concreteTests);
131143
}
132144
return tests;
@@ -1563,4 +1575,111 @@ public async IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
15631575
return await CreateFailedTestForDataGenerationError(metadata, ex);
15641576
}
15651577
}
1578+
1579+
/// <summary>
1580+
/// Determines if a test could potentially match the filter without building the full test object.
1581+
/// This is a conservative check - returns true unless we can definitively rule out the test.
1582+
/// </summary>
1583+
private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata)
1584+
{
1585+
#pragma warning disable TPEXP
1586+
return filter switch
1587+
{
1588+
null => true,
1589+
NopFilter => true,
1590+
TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata),
1591+
TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata),
1592+
_ => true // Unknown filter type - be conservative
1593+
};
1594+
#pragma warning restore TPEXP
1595+
}
1596+
1597+
/// <summary>
1598+
/// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains
1599+
/// the namespace, class name, and method name.
1600+
/// </summary>
1601+
private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetadata metadata)
1602+
{
1603+
var classMetadata = metadata.MethodMetadata.Class;
1604+
var namespaceName = classMetadata.Namespace ?? "";
1605+
var className = metadata.TestClassType.Name;
1606+
var methodName = metadata.TestMethodName;
1607+
1608+
// Check if any UID in the filter contains all three components
1609+
foreach (var uid in filter.TestNodeUids)
1610+
{
1611+
var uidValue = uid.Value;
1612+
if (uidValue.Contains(namespaceName) &&
1613+
uidValue.Contains(className) &&
1614+
uidValue.Contains(methodName))
1615+
{
1616+
return true;
1617+
}
1618+
}
1619+
1620+
return false;
1621+
}
1622+
1623+
/// <summary>
1624+
/// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter.
1625+
/// </summary>
1626+
#pragma warning disable TPEXP
1627+
private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata)
1628+
{
1629+
var filterString = filter.Filter;
1630+
1631+
// No filter means match all
1632+
if (string.IsNullOrEmpty(filterString))
1633+
{
1634+
return true;
1635+
}
1636+
1637+
// If the filter contains property conditions, strip them for path-only matching
1638+
// Property conditions will be evaluated in the second pass after tests are fully built
1639+
TreeNodeFilter pathOnlyFilter;
1640+
if (filterString.Contains('['))
1641+
{
1642+
// Strip all property conditions: [key=value]
1643+
// Use regex to remove all [...] blocks
1644+
var strippedFilterString = System.Text.RegularExpressions.Regex.Replace(filterString, @"\[([^\]]*)\]", "");
1645+
1646+
// Create a new TreeNodeFilter with the stripped filter string using reflection
1647+
pathOnlyFilter = CreateTreeNodeFilterViaReflection(strippedFilterString);
1648+
}
1649+
else
1650+
{
1651+
pathOnlyFilter = filter;
1652+
}
1653+
1654+
var path = BuildPathFromMetadata(metadata);
1655+
var emptyPropertyBag = new PropertyBag();
1656+
return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag);
1657+
}
1658+
1659+
/// <summary>
1660+
/// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor.
1661+
/// </summary>
1662+
private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString)
1663+
{
1664+
var constructor = typeof(TreeNodeFilter).GetConstructors(
1665+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
1666+
1667+
return (TreeNodeFilter)constructor.Invoke(new object[] { filterString });
1668+
}
1669+
#pragma warning restore TPEXP
1670+
1671+
/// <summary>
1672+
/// Builds the test path from metadata, matching the format used by TestFilterService.
1673+
/// Path format: /AssemblyName/Namespace/ClassName/MethodName
1674+
/// </summary>
1675+
private static string BuildPathFromMetadata(TestMetadata metadata)
1676+
{
1677+
var classMetadata = metadata.MethodMetadata.Class;
1678+
var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*";
1679+
var namespaceName = classMetadata.Namespace ?? "*";
1680+
var className = classMetadata.Name;
1681+
var methodName = metadata.TestMethodName;
1682+
1683+
return $"/{assemblyName}/{namespaceName}/{className}/{methodName}";
1684+
}
15661685
}

TUnit.Engine/Building/TestBuilderPipeline.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsAsync(string te
6161
{
6262
var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);
6363

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

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

8083
return await collectedMetadata
81-
.SelectManyAsync(BuildTestsFromSingleMetadataAsync, cancellationToken: cancellationToken)
84+
.SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken)
8285
.ProcessInParallel(cancellationToken: cancellationToken);
8386
}
8487

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

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

108-
return await _testBuilder.BuildTestsFromMetadataAsync(metadata).ConfigureAwait(false);
111+
return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false);
109112
}
110113
catch (Exception ex)
111114
{
@@ -210,7 +213,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
210213
#if NET6_0_OR_GREATER
211214
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
212215
#endif
213-
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata)
216+
private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext)
214217
{
215218
TestMetadata resolvedMetadata;
216219
Exception? resolutionError = null;
@@ -324,7 +327,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
324327
else
325328
{
326329
// Normal test metadata goes through the standard test builder
327-
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata).ConfigureAwait(false);
330+
var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false);
328331
testsToYield = new List<AbstractExecutableTest>(testsFromMetadata);
329332
}
330333
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Microsoft.Testing.Platform.Requests;
2+
3+
namespace TUnit.Engine.Building;
4+
5+
/// <summary>
6+
/// Context information for building tests, used to optimize test discovery and execution.
7+
/// </summary>
8+
internal record TestBuildingContext(
9+
/// <summary>
10+
/// Indicates whether tests are being built for execution (true) or discovery/display (false).
11+
/// When true, optimizations like early filtering can be applied.
12+
/// </summary>
13+
bool IsForExecution,
14+
15+
/// <summary>
16+
/// The filter to apply during test building. Only relevant when IsForExecution is true.
17+
/// </summary>
18+
ITestExecutionFilter? Filter
19+
);

TUnit.Engine/Services/TestRegistry.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ private async Task ProcessPendingDynamicTests()
9090
testMetadataList.Add(metadata);
9191
}
9292

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

9597
foreach (var test in builtTests)
9698
{

TUnit.Engine/TestDiscoveryService.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,15 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
5555

5656
contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext();
5757

58+
// Create building context for optimization
59+
var buildingContext = new Building.TestBuildingContext(isForExecution, filter);
60+
5861
// Stage 1: Stream independent tests immediately while buffering dependent tests
5962
var independentTests = new List<AbstractExecutableTest>();
6063
var dependentTests = new List<AbstractExecutableTest>();
6164
var allTests = new List<AbstractExecutableTest>();
6265

63-
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
66+
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
6467
{
6568
allTests.Add(test);
6669

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

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

141-
var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, cancellationToken).ConfigureAwait(false);
145+
var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false);
142146

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

171+
// Create building context - this is for discovery/streaming, not execution filtering
172+
var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null);
173+
167174
// Collect all tests first (like source generation mode does)
168175
var allTests = new List<AbstractExecutableTest>();
169-
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false))
176+
await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
170177
{
171178
allTests.Add(test);
172179
}

TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ public static async Task AfterEveryTestDiscovery(TestDiscoveryContext context)
1313
{
1414
await FilePolyfill.WriteAllTextAsync($"TestDiscoveryAfterTests{Guid.NewGuid():N}.txt", $"{context.AllTests.Count()} tests found");
1515

16-
var test = context.AllTests.First(x =>
16+
var test = context.AllTests.FirstOrDefault(x =>
1717
x.TestDetails.TestName == nameof(TestDiscoveryAfterTests.EnsureAfterEveryTestDiscoveryHit));
1818

19-
test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true);
19+
if (test is not null)
20+
{
21+
test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true);
22+
}
2023
}
2124
}
2225

0 commit comments

Comments
 (0)