Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions TUnit.Engine.Tests/FilteredDependencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// Regression test for GitHub issue #3637: Filtered dependent test should also run dependency
/// https://github.com/thomhurst/TUnit/issues/3637
/// </summary>
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");
}
]);
}
}
108 changes: 7 additions & 101 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,7 +34,8 @@ public TestBuilder(
PropertyInjectionService propertyInjectionService,
DataSourceInitializer dataSourceInitializer,
Discovery.IHookDiscoveryService hookDiscoveryService,
TestArgumentRegistrationService testArgumentRegistrationService)
TestArgumentRegistrationService testArgumentRegistrationService,
IMetadataFilterMatcher filterMatcher)
{
_sessionId = sessionId;
_hookDiscoveryService = hookDiscoveryService;
Expand All @@ -42,6 +44,7 @@ public TestBuilder(
_propertyInjectionService = propertyInjectionService;
_dataSourceInitializer = dataSourceInitializer;
_testArgumentRegistrationService = testArgumentRegistrationService;
_filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher));
}

/// <summary>
Expand Down Expand Up @@ -1580,107 +1583,10 @@ public async IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
/// <summary>
/// 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.
/// </summary>
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
}

/// <summary>
/// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains
/// the namespace, class name, and method name.
/// </summary>
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;
}

/// <summary>
/// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter.
/// </summary>
#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);
}

/// <summary>
/// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor.
/// </summary>
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

/// <summary>
/// Builds the test path from metadata, matching the format used by TestFilterService.
/// Path format: /AssemblyName/Namespace/ClassName/MethodName
/// </summary>
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);
}
}
20 changes: 20 additions & 0 deletions TUnit.Engine/Building/TestBuilderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,40 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsAsync(string te
return await BuildTestsFromMetadataAsync(collectedMetadata, buildingContext).ConfigureAwait(false);
}

/// <summary>
/// Collects all test metadata without building tests.
/// This is a lightweight operation used for dependency analysis.
/// </summary>
public async Task<IEnumerable<TestMetadata>> CollectTestMetadataAsync(string testSessionId)
{
return await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false);
}

/// <summary>
/// Streaming version that yields tests as they're built without buffering
/// </summary>
/// <param name="testSessionId">The test session identifier</param>
/// <param name="buildingContext">Context for test building</param>
/// <param name="metadataFilter">Optional predicate to filter which metadata should be built (null means build all)</param>
/// <param name="cancellationToken">Cancellation token</param>
[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<IEnumerable<AbstractExecutableTest>> BuildTestsStreamingAsync(
string testSessionId,
TestBuildingContext buildingContext,
Func<TestMetadata, bool>? 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);
Expand Down
7 changes: 5 additions & 2 deletions TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ public TUnitServiceProvider(IExtension extension,
staticPropertyInitializer = new ReflectionStaticPropertyInitializer(Logger);
}

var filterMatcher = Register<IMetadataFilterMatcher>(new MetadataFilterMatcher());
var dependencyExpander = Register(new MetadataDependencyExpander(filterMatcher));

var testBuilder = Register<ITestBuilder>(
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService));
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService, filterMatcher));

TestBuilderPipeline = Register(
new TestBuilderPipeline(
Expand All @@ -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<ITestFinder>(new TestFinder(DiscoveryService));
Expand Down
5 changes: 0 additions & 5 deletions TUnit.Engine/Framework/TestRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions TUnit.Engine/Services/IMetadataFilterMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Testing.Platform.Requests;
using TUnit.Core;

namespace TUnit.Engine.Services;

/// <summary>
/// Service for evaluating if test metadata could match an execution filter.
/// Provides conservative matching without requiring full test building.
/// </summary>
internal interface IMetadataFilterMatcher
{
/// <summary>
/// Determines if test metadata could potentially match the filter.
/// Returns true unless we can definitively rule out the test.
/// </summary>
/// <param name="metadata">The test metadata to evaluate</param>
/// <param name="filter">The execution filter to match against (null means match all)</param>
/// <returns>True if the metadata could match the filter, false if it definitely doesn't</returns>
bool CouldMatchFilter(TestMetadata metadata, ITestExecutionFilter? filter);
}
89 changes: 89 additions & 0 deletions TUnit.Engine/Services/MetadataDependencyExpander.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Microsoft.Testing.Platform.Requests;
using TUnit.Core;

namespace TUnit.Engine.Services;

/// <summary>
/// Equality comparer for TestMetadata based on unique test properties.
/// Used to compare metadata instances that represent the same test.
/// </summary>
internal sealed class TestMetadataEqualityComparer : IEqualityComparer<TestMetadata>
{
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;
}
}
}

/// <summary>
/// Expands filtered test metadata to include all transitive dependencies.
/// Ensures that when tests are filtered, their dependency tests are also included.
/// </summary>
internal sealed class MetadataDependencyExpander
{
private readonly IMetadataFilterMatcher _filterMatcher;

public MetadataDependencyExpander(IMetadataFilterMatcher filterMatcher)
{
_filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher));
}

public HashSet<TestMetadata> ExpandToIncludeDependencies(
IEnumerable<TestMetadata> allMetadata,
ITestExecutionFilter? filter)
{
var metadataList = allMetadata.ToList();

if (filter == null)
{
return new HashSet<TestMetadata>(metadataList, TestMetadataEqualityComparer.Instance);
}

var matchingMetadata = metadataList
.Where(m => _filterMatcher.CouldMatchFilter(m, filter))
.ToList();

var result = new HashSet<TestMetadata>(matchingMetadata, TestMetadataEqualityComparer.Instance);
var queue = new Queue<TestMetadata>(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;
}
}
Loading
Loading