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 +"""); } }