diff --git a/TUnit.Engine.Tests/FilteredDependencyTests.cs b/TUnit.Engine.Tests/FilteredDependencyTests.cs
new file mode 100644
index 0000000000..20ae6e339d
--- /dev/null
+++ b/TUnit.Engine.Tests/FilteredDependencyTests.cs
@@ -0,0 +1,46 @@
+using Shouldly;
+using TUnit.Engine.Tests.Enums;
+
+namespace TUnit.Engine.Tests;
+
+///
+/// Regression test for GitHub issue #3637: Filtered dependent test should also run dependency
+/// https://github.com/thomhurst/TUnit/issues/3637
+///
+public class FilteredDependencyTests(TestMode testMode) : InvokableTestBase(testMode)
+{
+ [Test]
+ public async Task FilteringDependentTest_ShouldAlsoRunDependency()
+ {
+ // When filtering to run only DependentTest, BaseTest should also be executed as a dependency
+ await RunTestsWithFilter(
+ "/*/TUnit.TestProject.Bugs._3627/FilteredDependencyTests/DependentTest",
+ [
+ result => result.ResultSummary.Outcome.ShouldBe("Completed"),
+ result => result.ResultSummary.Counters.Total.ShouldBe(2, "Both BaseTest and DependentTest should run"),
+ result => result.ResultSummary.Counters.Passed.ShouldBe(2),
+ result => result.ResultSummary.Counters.Failed.ShouldBe(0),
+ result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0),
+ result =>
+ {
+ // Verify both tests ran
+ var baseTest = result.Results.FirstOrDefault(x => x.TestName!.Contains("BaseTest"));
+ var dependentTest = result.Results.FirstOrDefault(x => x.TestName!.Contains("DependentTest"));
+
+ baseTest.ShouldNotBeNull("BaseTest should have been executed as a dependency");
+ dependentTest.ShouldNotBeNull("DependentTest should have been executed");
+ },
+ result =>
+ {
+ // Verify execution order (BaseTest before DependentTest)
+ var baseTestStart = DateTime.Parse(
+ result.Results.First(x => x.TestName!.Contains("BaseTest")).StartTime!);
+ var dependentTestStart = DateTime.Parse(
+ result.Results.First(x => x.TestName!.Contains("DependentTest")).StartTime!);
+
+ dependentTestStart.ShouldBeGreaterThanOrEqualTo(baseTestStart,
+ "DependentTest should run after BaseTest");
+ }
+ ]);
+ }
+}
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 680512d4ae..2109b15e1c 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -25,6 +25,7 @@ internal sealed class TestBuilder : ITestBuilder
private readonly DataSourceInitializer _dataSourceInitializer;
private readonly Discovery.IHookDiscoveryService _hookDiscoveryService;
private readonly TestArgumentRegistrationService _testArgumentRegistrationService;
+ private readonly IMetadataFilterMatcher _filterMatcher;
public TestBuilder(
string sessionId,
@@ -33,7 +34,8 @@ public TestBuilder(
PropertyInjectionService propertyInjectionService,
DataSourceInitializer dataSourceInitializer,
Discovery.IHookDiscoveryService hookDiscoveryService,
- TestArgumentRegistrationService testArgumentRegistrationService)
+ TestArgumentRegistrationService testArgumentRegistrationService,
+ IMetadataFilterMatcher filterMatcher)
{
_sessionId = sessionId;
_hookDiscoveryService = hookDiscoveryService;
@@ -42,6 +44,7 @@ public TestBuilder(
_propertyInjectionService = propertyInjectionService;
_dataSourceInitializer = dataSourceInitializer;
_testArgumentRegistrationService = testArgumentRegistrationService;
+ _filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher));
}
///
@@ -1580,107 +1583,10 @@ public async IAsyncEnumerable BuildTestsStreamingAsync(
///
/// 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.
+ /// Delegates to IMetadataFilterMatcher service.
///
- private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata)
+ internal 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
- }
-
- ///
- /// 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.
- ///
-#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);
- }
-
- ///
- /// 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 (TreeNodeFilter)constructor.Invoke([filterString]);
- }
-#pragma warning restore TPEXP
-
- ///
- /// 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}";
+ return _filterMatcher.CouldMatchFilter(metadata, filter);
}
}
diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs
index 6c2312c760..1a3fea0e7c 100644
--- a/TUnit.Engine/Building/TestBuilderPipeline.cs
+++ b/TUnit.Engine/Building/TestBuilderPipeline.cs
@@ -68,20 +68,40 @@ public async Task> BuildTestsAsync(string te
return await BuildTestsFromMetadataAsync(collectedMetadata, buildingContext).ConfigureAwait(false);
}
+ ///
+ /// Collects all test metadata without building tests.
+ /// This is a lightweight operation used for dependency analysis.
+ ///
+ public async Task> CollectTestMetadataAsync(string testSessionId)
+ {
+ return await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);
+ }
+
///
/// Streaming version that yields tests as they're built without buffering
///
+ /// The test session identifier
+ /// Context for test building
+ /// Optional predicate to filter which metadata should be built (null means build all)
+ /// Cancellation token
[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> BuildTestsStreamingAsync(
string testSessionId,
TestBuildingContext buildingContext,
+ Func? metadataFilter = null,
CancellationToken cancellationToken = default)
{
// Get metadata streaming if supported
// Fall back to non-streaming collection
var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);
+ // Apply metadata filter if provided (for dependency-aware filtering optimization)
+ if (metadataFilter != null)
+ {
+ collectedMetadata = collectedMetadata.Where(metadataFilter);
+ }
+
return await collectedMetadata
.SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken)
.ProcessInParallel(cancellationToken: cancellationToken);
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index c3825b8334..19093fdb39 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -166,8 +166,11 @@ public TUnitServiceProvider(IExtension extension,
staticPropertyInitializer = new ReflectionStaticPropertyInitializer(Logger);
}
+ var filterMatcher = Register(new MetadataFilterMatcher());
+ var dependencyExpander = Register(new MetadataDependencyExpander(filterMatcher));
+
var testBuilder = Register(
- new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService));
+ new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService, filterMatcher));
TestBuilderPipeline = Register(
new TestBuilderPipeline(
@@ -176,7 +179,7 @@ public TUnitServiceProvider(IExtension extension,
ContextProvider,
EventReceiverOrchestrator));
- DiscoveryService = Register(new TestDiscoveryService(TestExecutor, TestBuilderPipeline, TestFilterService));
+ DiscoveryService = Register(new TestDiscoveryService(TestExecutor, TestBuilderPipeline, TestFilterService, dependencyExpander));
// Create test finder service after discovery service so it can use its cache
TestFinder = Register(new TestFinder(DiscoveryService));
diff --git a/TUnit.Engine/Framework/TestRequestHandler.cs b/TUnit.Engine/Framework/TestRequestHandler.cs
index 38e447c779..b431468cd7 100644
--- a/TUnit.Engine/Framework/TestRequestHandler.cs
+++ b/TUnit.Engine/Framework/TestRequestHandler.cs
@@ -32,8 +32,6 @@ private async Task HandleDiscoveryRequestAsync(
ExecuteRequestContext context,
ITestExecutionFilter? testExecutionFilter)
{
- // For discovery, we want to show ALL tests including explicit ones
- // Pass isForExecution: false to discover all tests - filtering is only for execution
var discoveryResult = await serviceProvider.DiscoveryService.DiscoverTests(context.Request.Session.SessionUid.Value, testExecutionFilter, context.CancellationToken, isForExecution: false);
#if NET
@@ -56,7 +54,6 @@ private async Task HandleRunRequestAsync(
RunTestExecutionRequest request,
ExecuteRequestContext context, ITestExecutionFilter? testExecutionFilter)
{
- // For execution, apply filtering to exclude explicit tests unless explicitly targeted
var discoveryResult = await serviceProvider.DiscoveryService.DiscoverTests(context.Request.Session.SessionUid.Value, testExecutionFilter, context.CancellationToken, isForExecution: true);
#if NET
@@ -68,14 +65,12 @@ private async Task HandleRunRequestAsync(
var allTests = discoveryResult.Tests.ToArray();
- // Report only the tests that will actually run
foreach (var test in allTests)
{
context.CancellationToken.ThrowIfCancellationRequested();
await serviceProvider.MessageBus.Discovered(test.Context);
}
- // Execute tests
await serviceProvider.TestSessionCoordinator.ExecuteTests(
allTests,
request.Filter,
diff --git a/TUnit.Engine/Services/IMetadataFilterMatcher.cs b/TUnit.Engine/Services/IMetadataFilterMatcher.cs
new file mode 100644
index 0000000000..02af2ea1f4
--- /dev/null
+++ b/TUnit.Engine/Services/IMetadataFilterMatcher.cs
@@ -0,0 +1,21 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Testing.Platform.Requests;
+using TUnit.Core;
+
+namespace TUnit.Engine.Services;
+
+///
+/// Service for evaluating if test metadata could match an execution filter.
+/// Provides conservative matching without requiring full test building.
+///
+internal interface IMetadataFilterMatcher
+{
+ ///
+ /// Determines if test metadata could potentially match the filter.
+ /// Returns true unless we can definitively rule out the test.
+ ///
+ /// The test metadata to evaluate
+ /// The execution filter to match against (null means match all)
+ /// True if the metadata could match the filter, false if it definitely doesn't
+ bool CouldMatchFilter(TestMetadata metadata, ITestExecutionFilter? filter);
+}
diff --git a/TUnit.Engine/Services/MetadataDependencyExpander.cs b/TUnit.Engine/Services/MetadataDependencyExpander.cs
new file mode 100644
index 0000000000..3a607201a0
--- /dev/null
+++ b/TUnit.Engine/Services/MetadataDependencyExpander.cs
@@ -0,0 +1,89 @@
+using Microsoft.Testing.Platform.Requests;
+using TUnit.Core;
+
+namespace TUnit.Engine.Services;
+
+///
+/// Equality comparer for TestMetadata based on unique test properties.
+/// Used to compare metadata instances that represent the same test.
+///
+internal sealed class TestMetadataEqualityComparer : IEqualityComparer
+{
+ public static readonly TestMetadataEqualityComparer Instance = new();
+
+ public bool Equals(TestMetadata? x, TestMetadata? y)
+ {
+ if (ReferenceEquals(x, y)) return true;
+ if (x is null || y is null) return false;
+
+ return x.TestClassType == y.TestClassType &&
+ x.TestMethodName == y.TestMethodName &&
+ x.TestName == y.TestName;
+ }
+
+ public int GetHashCode(TestMetadata obj)
+ {
+ unchecked
+ {
+ int hash = 17;
+ hash = hash * 31 + (obj.TestClassType?.GetHashCode() ?? 0);
+ hash = hash * 31 + (obj.TestMethodName?.GetHashCode() ?? 0);
+ hash = hash * 31 + (obj.TestName?.GetHashCode() ?? 0);
+ return hash;
+ }
+ }
+}
+
+///
+/// Expands filtered test metadata to include all transitive dependencies.
+/// Ensures that when tests are filtered, their dependency tests are also included.
+///
+internal sealed class MetadataDependencyExpander
+{
+ private readonly IMetadataFilterMatcher _filterMatcher;
+
+ public MetadataDependencyExpander(IMetadataFilterMatcher filterMatcher)
+ {
+ _filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher));
+ }
+
+ public HashSet ExpandToIncludeDependencies(
+ IEnumerable allMetadata,
+ ITestExecutionFilter? filter)
+ {
+ var metadataList = allMetadata.ToList();
+
+ if (filter == null)
+ {
+ return new HashSet(metadataList, TestMetadataEqualityComparer.Instance);
+ }
+
+ var matchingMetadata = metadataList
+ .Where(m => _filterMatcher.CouldMatchFilter(m, filter))
+ .ToList();
+
+ var result = new HashSet(matchingMetadata, TestMetadataEqualityComparer.Instance);
+ var queue = new Queue(matchingMetadata);
+
+ while (queue.Count > 0)
+ {
+ var current = queue.Dequeue();
+
+ foreach (var dependency in current.Dependencies)
+ {
+ foreach (var candidateMetadata in metadataList)
+ {
+ if (dependency.Matches(candidateMetadata, current))
+ {
+ if (result.Add(candidateMetadata))
+ {
+ queue.Enqueue(candidateMetadata);
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs
new file mode 100644
index 0000000000..5e3c91db4e
--- /dev/null
+++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs
@@ -0,0 +1,94 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Requests;
+using TUnit.Core;
+
+namespace TUnit.Engine.Services;
+
+///
+/// Implementation of metadata filter matching logic extracted from TestBuilder.
+/// Evaluates if test metadata could match an execution filter without building tests.
+///
+internal sealed class MetadataFilterMatcher : IMetadataFilterMatcher
+{
+ public bool CouldMatchFilter(TestMetadata metadata, ITestExecutionFilter? filter)
+ {
+#pragma warning disable TPEXP
+ return filter switch
+ {
+ null => true,
+ NopFilter => true,
+ TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata),
+ TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata),
+ _ => true
+ };
+#pragma warning restore TPEXP
+ }
+
+ 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;
+
+ foreach (var uid in filter.TestNodeUids)
+ {
+ var uidValue = uid.Value;
+ if (uidValue.Contains(namespaceName) &&
+ uidValue.Contains(className) &&
+ uidValue.Contains(methodName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+#pragma warning disable TPEXP
+ private static bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata)
+ {
+ var filterString = filter.Filter;
+
+ if (string.IsNullOrEmpty(filterString))
+ {
+ return true;
+ }
+
+ TreeNodeFilter pathOnlyFilter;
+ if (filterString.Contains('['))
+ {
+ var strippedFilterString = System.Text.RegularExpressions.Regex.Replace(filterString, @"\[([^\]]*)\]", "");
+ pathOnlyFilter = CreateTreeNodeFilterViaReflection(strippedFilterString);
+ }
+ else
+ {
+ pathOnlyFilter = filter;
+ }
+
+ var path = BuildPathFromMetadata(metadata);
+ var emptyPropertyBag = new PropertyBag();
+ return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag);
+ }
+
+ private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString)
+ {
+ var constructor = typeof(TreeNodeFilter).GetConstructors(
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
+
+ return (TreeNodeFilter)constructor.Invoke([filterString]);
+ }
+#pragma warning restore TPEXP
+
+ 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/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs
index 41682fdcd8..e6419ea48a 100644
--- a/TUnit.Engine/TestDiscoveryService.cs
+++ b/TUnit.Engine/TestDiscoveryService.cs
@@ -5,6 +5,7 @@
using Microsoft.Testing.Platform.Requests;
using TUnit.Core;
using TUnit.Engine.Building;
+using TUnit.Engine.Building.Interfaces;
using TUnit.Engine.Services;
namespace TUnit.Engine;
@@ -27,9 +28,8 @@ internal sealed class TestDiscoveryService : IDataProducer
private readonly TestExecutor _testExecutor;
private readonly TestBuilderPipeline _testBuilderPipeline;
private readonly TestFilterService _testFilterService;
- private readonly ConcurrentBag _cachedTests =
- [
- ];
+ private readonly MetadataDependencyExpander _dependencyExpander;
+ private readonly ConcurrentBag _cachedTests = [];
private readonly TestDependencyResolver _dependencyResolver = new();
public string Uid => "TUnit";
@@ -40,11 +40,16 @@ internal sealed class TestDiscoveryService : IDataProducer
public Task IsEnabledAsync() => Task.FromResult(true);
- public TestDiscoveryService(TestExecutor testExecutor, TestBuilderPipeline testBuilderPipeline, TestFilterService testFilterService)
+ public TestDiscoveryService(
+ TestExecutor testExecutor,
+ TestBuilderPipeline testBuilderPipeline,
+ TestFilterService testFilterService,
+ MetadataDependencyExpander dependencyExpander)
{
_testExecutor = testExecutor;
_testBuilderPipeline = testBuilderPipeline ?? throw new ArgumentNullException(nameof(testBuilderPipeline));
_testFilterService = testFilterService;
+ _dependencyExpander = dependencyExpander ?? throw new ArgumentNullException(nameof(dependencyExpander));
}
public async Task DiscoverTests(string testSessionId, ITestExecutionFilter? filter, CancellationToken cancellationToken, bool isForExecution)
@@ -52,49 +57,58 @@ public async Task DiscoverTests(string testSessionId, ITest
await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
var contextProvider = _testExecutor.GetContextProvider();
-
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, buildingContext, cancellationToken).ConfigureAwait(false))
+#pragma warning disable TPEXP
+ var isNopFilter = filter is NopFilter;
+#pragma warning restore TPEXP
+ if (filter == null || !isForExecution || isNopFilter)
{
- allTests.Add(test);
-
- if (test.Metadata.Dependencies.Length > 0)
+ var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
+ await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false))
{
- // Buffer tests with dependencies for later resolution
- dependentTests.Add(test);
+ allTests.Add(test);
}
- else
+ }
+ else
+ {
+ var allMetadata = await _testBuilderPipeline.CollectTestMetadataAsync(testSessionId).ConfigureAwait(false);
+ var allMetadataList = allMetadata.ToList();
+
+ var metadataToInclude = _dependencyExpander.ExpandToIncludeDependencies(allMetadataList, filter);
+
+ var buildingContext = new Building.TestBuildingContext(isForExecution, Filter: null);
+ var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(
+ testSessionId,
+ buildingContext,
+ metadataFilter: m => metadataToInclude.Contains(m),
+ cancellationToken).ConfigureAwait(false);
+
+ var testsList = tests.ToList();
+
+ // Cache tests so ITestFinder can locate them
+ foreach (var test in testsList)
{
- // Independent test - can be used immediately
- independentTests.Add(test);
+ _cachedTests.Add(test);
}
+
+ allTests.AddRange(testsList);
}
- // Now resolve dependencies for dependent tests
- foreach (var test in dependentTests)
+ foreach (var test in allTests)
{
- _dependencyResolver.TryResolveDependencies(test);
+ _dependencyResolver.RegisterTest(test);
}
- // Combine independent and dependent tests
- var tests = new List(independentTests.Count + dependentTests.Count);
- tests.AddRange(independentTests);
- tests.AddRange(dependentTests);
+ foreach (var test in allTests.Where(t => t.Metadata.Dependencies.Length > 0))
+ {
+ _dependencyResolver.TryResolveDependencies(test);
+ }
- // For discovery requests (IDE test explorers), return all tests including explicit ones
- // For execution requests, apply filtering to exclude explicit tests unless explicitly targeted
- var filteredTests = isForExecution ? _testFilterService.FilterTests(filter, tests) : tests;
+ var filteredTests = isForExecution ? _testFilterService.FilterTests(filter, allTests) : allTests;
- // If we applied filtering, find all dependencies of filtered tests and add them
if (isForExecution)
{
var testsToInclude = new HashSet(filteredTests);
@@ -119,44 +133,32 @@ public async Task DiscoverTests(string testSessionId, ITest
contextProvider.TestDiscoveryContext.AddTests(allTests.Select(static t => t.Context));
await _testExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false);
-
contextProvider.TestDiscoveryContext.RestoreExecutionContext();
- // Register the filtered tests to invoke ITestRegisteredEventReceiver
await _testFilterService.RegisterTestsAsync(filteredTests).ConfigureAwait(false);
- // Capture the final execution context after discovery
var finalContext = ExecutionContext.Capture();
return new TestDiscoveryResult(filteredTests, finalContext);
}
- /// 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);
-
- // Set a reasonable timeout for test discovery (5 minutes)
cts.CancelAfter(TimeSpan.FromMinutes(5));
- var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false);
+ var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, metadataFilter: null, cancellationToken).ConfigureAwait(false);
foreach (var test in tests)
{
_dependencyResolver.RegisterTest(test);
-
- // Cache for backward compatibility
_cachedTests.Add(test);
-
yield return test;
}
}
- ///
- /// Simplified streaming test discovery without channels - matches source generation approach
- ///
#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Generic test instantiation requires MakeGenericType")]
#endif
@@ -167,23 +169,19 @@ 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, buildingContext, cancellationToken).ConfigureAwait(false))
{
allTests.Add(test);
}
- // Resolve dependencies for all tests
foreach (var test in allTests)
{
_dependencyResolver.TryResolveDependencies(test);
}
- // Separate into independent and dependent tests
var independentTests = new List();
var dependentTests = new List();
@@ -199,7 +197,6 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin
}
}
- // Yield independent tests first
foreach (var test in independentTests)
{
if (_testFilterService.MatchesTest(filter, test))
@@ -208,7 +205,6 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin
}
}
- // Process dependent tests in dependency order
var yieldedTests = new HashSet(independentTests.Select(static t => t.TestId));
var remainingTests = new List(dependentTests);
@@ -226,10 +222,8 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin
}
}
- // If no tests are ready, we have a circular dependency
if (readyTests.Count == 0 && remainingTests.Count > 0)
{
- // Yield remaining tests anyway to avoid hanging
foreach (var test in remainingTests)
{
if (_testFilterService.MatchesTest(filter, test))
@@ -240,7 +234,6 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin
break;
}
- // Yield ready tests and remove from remaining
foreach (var test in readyTests)
{
if (_testFilterService.MatchesTest(filter, test))
diff --git a/TUnit.TestProject/Bugs/3627/FilteredDependencyTests.cs b/TUnit.TestProject/Bugs/3627/FilteredDependencyTests.cs
new file mode 100644
index 0000000000..85ff6ff4b6
--- /dev/null
+++ b/TUnit.TestProject/Bugs/3627/FilteredDependencyTests.cs
@@ -0,0 +1,41 @@
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject.Bugs._3627;
+
+///
+/// Regression test for GitHub issue #3637: Filtered dependent test should also run dependency
+/// When filtering to run a single test that depends on another test, the dependency should be
+/// automatically included in execution.
+/// https://github.com/thomhurst/TUnit/issues/3637
+///
+[EngineTest(ExpectedResult.Pass)]
+public class FilteredDependencyTests
+{
+ [Test]
+ public async Task BaseTest()
+ {
+ // This test should run even when filtering for DependentTest only
+ await Task.Delay(100);
+
+ // Store data in StateBag to verify this test actually ran
+ TestContext.Current!.StateBag.Items["BaseTestExecuted"] = true;
+ TestContext.Current.StateBag.Items["BaseTestTimestamp"] = DateTime.UtcNow;
+ }
+
+ [Test]
+ [DependsOn(nameof(BaseTest))]
+ public async Task DependentTest()
+ {
+ // When filtering to run only this test, BaseTest should also execute
+ var dependencies = TestContext.Current!.Dependencies.GetTests(nameof(BaseTest));
+
+ await Assert.That(dependencies).HasCount().EqualTo(1);
+ await Assert.That(dependencies[0]).IsNotNull();
+
+ // Verify the dependency actually executed by checking its StateBag
+ var baseTestContext = dependencies[0];
+ await Assert.That(baseTestContext.StateBag.Items).ContainsKey("BaseTestExecuted");
+ await Assert.That(baseTestContext.StateBag.Items["BaseTestExecuted"]).IsEqualTo(true);
+ await Assert.That(baseTestContext.StateBag.Items).ContainsKey("BaseTestTimestamp");
+ }
+}
diff --git a/TUnit.TestProject/OrderedByAttributeOrderParameterSetupTests/Tests.cs b/TUnit.TestProject/OrderedByAttributeOrderParameterSetupTests/Tests.cs
index 24407269b4..965403d4f0 100644
--- a/TUnit.TestProject/OrderedByAttributeOrderParameterSetupTests/Tests.cs
+++ b/TUnit.TestProject/OrderedByAttributeOrderParameterSetupTests/Tests.cs
@@ -17,31 +17,31 @@ public void MyTest()
public static async Task AssertOrder(ClassHookContext classHookContext)
{
await Assert.That(classHookContext.Tests.Single().GetStandardOutput()).IsEqualTo("""
- Tests.A_Before3
- Tests.B_Before3
- Tests.Y_Before3
- Tests.Z_Before3
- Tests.A_Before1
- Tests.B_Before1
- Tests.Y_Before1
- Tests.Z_Before1
- Tests.A_Before2
- Tests.B_Before2
- Tests.Y_Before2
- Tests.Z_Before2
- Test Body
- Tests.A_After2
- Tests.B_After2
- Tests.Y_After2
- Tests.Z_After2
- Tests.A_After1
- Tests.B_After1
- Tests.Y_After1
- Tests.Z_After1
- Tests.A_After3
- Tests.B_After3
- Tests.Y_After3
- Tests.Z_After3
- """);
+Tests.A_Before3
+Tests.B_Before3
+Tests.Y_Before3
+Tests.Z_Before3
+Tests.A_Before1
+Tests.B_Before1
+Tests.Y_Before1
+Tests.Z_Before1
+Tests.A_Before2
+Tests.B_Before2
+Tests.Y_Before2
+Tests.Z_Before2
+Test Body
+Tests.A_After2
+Tests.B_After2
+Tests.Y_After2
+Tests.Z_After2
+Tests.A_After1
+Tests.B_After1
+Tests.Y_After1
+Tests.Z_After1
+Tests.A_After3
+Tests.B_After3
+Tests.Y_After3
+Tests.Z_After3
+""");
}
}
diff --git a/TUnit.TestProject/OrderedSetupTests/Tests.cs b/TUnit.TestProject/OrderedSetupTests/Tests.cs
index bb49d961ca..e55b8dfd01 100644
--- a/TUnit.TestProject/OrderedSetupTests/Tests.cs
+++ b/TUnit.TestProject/OrderedSetupTests/Tests.cs
@@ -17,31 +17,31 @@ public void MyTest()
public static async Task AssertOrder(ClassHookContext classHookContext)
{
await Assert.That(classHookContext.Tests.Single().GetStandardOutput()).IsEqualTo("""
- Tests.Z_Before3
- Tests.Y_Before3
- Tests.A_Before3
- Tests.B_Before3
- Tests.Z_Before1
- Tests.Y_Before1
- Tests.A_Before1
- Tests.B_Before1
- Tests.Z_Before2
- Tests.Y_Before2
- Tests.A_Before2
- Tests.B_Before2
- Test Body
- Tests.Z_After2
- Tests.Y_After2
- Tests.A_After2
- Tests.B_After2
- Tests.Z_After1
- Tests.Y_After1
- Tests.A_After1
- Tests.B_After1
- Tests.Z_After3
- Tests.Y_After3
- Tests.A_After3
- Tests.B_After3
- """);
+Tests.Z_Before3
+Tests.Y_Before3
+Tests.A_Before3
+Tests.B_Before3
+Tests.Z_Before1
+Tests.Y_Before1
+Tests.A_Before1
+Tests.B_Before1
+Tests.Z_Before2
+Tests.Y_Before2
+Tests.A_Before2
+Tests.B_Before2
+Test Body
+Tests.Z_After2
+Tests.Y_After2
+Tests.A_After2
+Tests.B_After2
+Tests.Z_After1
+Tests.Y_After1
+Tests.A_After1
+Tests.B_After1
+Tests.Z_After3
+Tests.Y_After3
+Tests.A_After3
+Tests.B_After3
+""");
}
}