From 2512c1a6924026fe12a8e1ff59e483cd29d7babd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:48:30 +0000 Subject: [PATCH 1/2] fix: prevent substring matching in UID filter causing incorrect test inclusion (#4656) When filtering tests by UID (e.g., from Visual Studio), class names like "ABCV" would incorrectly match "ABCVC" because the filter used string.Contains() for matching. This fix changes CouldMatchUidFilter to use word-boundary matching: - Class names must be bounded by '.', '<', or '+' - Method names must be preceded by '.' and followed by '.', '<', or '(' This ensures "ABCV" only matches the exact class name, not substrings like "ABCVC". Fixes #4656 --- .../OverlappingClassNameFilterTests.cs | 59 ++++++++++++ .../Services/MetadataFilterMatcher.cs | 89 ++++++++++++++++++- .../4656/OverlappingClassNameFilterTests.cs | 82 +++++++++++++++++ 3 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 TUnit.Engine.Tests/OverlappingClassNameFilterTests.cs create mode 100644 TUnit.TestProject/Bugs/4656/OverlappingClassNameFilterTests.cs diff --git a/TUnit.Engine.Tests/OverlappingClassNameFilterTests.cs b/TUnit.Engine.Tests/OverlappingClassNameFilterTests.cs new file mode 100644 index 0000000000..b68cb90c8b --- /dev/null +++ b/TUnit.Engine.Tests/OverlappingClassNameFilterTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// Regression test for GitHub issue #4656: TestDiscoveryContext.AllTests incorrectly +/// includes tests when class names overlap (ABCV vs ABCVC) due to substring matching. +/// https://github.com/thomhurst/TUnit/issues/4656 +/// +public class OverlappingClassNameFilterTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Filtering_ABCVC_B2_ShouldNotInclude_ABCV_B2() + { + // Filter for only ABCVC.B2 (and its dependency ABCVC.B0) + // Bug #4656: ABCV.B2 was incorrectly included because "ABCV" is a substring of "ABCVC" + // With the fix, only ABCVC tests should run (B2 + B0 dependency = 2 tests) + // Without the fix, ABCV.B2 and ABCV.A1 (dependency) would also run (4 tests total) + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/ABCVC/B2", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => + { + // Key assertion: exactly 2 tests should run (ABCVC.B2 + ABCVC.B0) + // If the bug exists, 4 tests would run (also ABCV.B2 + ABCV.A1) + result.ResultSummary.Counters.Total.ShouldBe(2, + $"Expected 2 tests (ABCVC.B2 + ABCVC.B0) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}. " + + "If more than 2 tests ran, the substring matching bug (#4656) may be present."); + }, + result => result.ResultSummary.Counters.Passed.ShouldBe(2), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filtering_ABCV_ShouldNotMatch_ABCVC() + { + // Filter for all tests in ABCV class (A1, B1, B2 = 3 tests) + // Should NOT include any ABCVC tests + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/ABCV/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => + { + // Expected: 3 tests from ABCV (A1, B1, B2) + // If ABCVC tests were incorrectly included, we'd have 6 tests + result.ResultSummary.Counters.Total.ShouldBe(3, + $"Expected 3 tests (ABCV.A1, ABCV.B1, ABCV.B2) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"); + }, + result => result.ResultSummary.Counters.Passed.ShouldBe(3), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs index 3dfbf4d7d8..dca0fee7d2 100644 --- a/TUnit.Engine/Services/MetadataFilterMatcher.cs +++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs @@ -213,19 +213,100 @@ public bool CouldMatchFilter(TestMetadata metadata, ITestExecutionFilter? filter 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; + // Handle generic types: Type`1 -> need to match Type< in the UID + var classNameForMatching = className; + var backtickIndex = className.IndexOf('`'); + if (backtickIndex > 0) + { + classNameForMatching = className.Substring(0, backtickIndex); + } + foreach (var uid in filter.TestNodeUids) { var uidValue = uid.Value; - if (uidValue.Contains(namespaceName) && - uidValue.Contains(className) && - uidValue.Contains(methodName)) + + // Check for class name with word boundaries to avoid substring false positives + // e.g., "ABCV" should not match "ABCVC" + // Class names in TestIds are bounded by: '.' (namespace/indices), '<' (generics), '+' (nested) + if (!HasClassNameMatch(uidValue, classNameForMatching)) + { + continue; + } + + // Check for method name with word boundaries + // Method names are preceded by '.' and followed by '.', '<', or '(' + if (!HasMethodNameMatch(uidValue, methodName)) + { + continue; + } + + return true; + } + + return false; + } + + private static bool HasClassNameMatch(string uidValue, string className) + { + // Class name patterns with proper boundaries: + // .{ClassName}. (most common: namespace.class.index) + // .{ClassName}< (generic: namespace.class.index) + // .{ClassName}+ (nested: outer+inner) + // Also handle start of string for edge cases + ReadOnlySpan validSuffixes = ['.', '<', '+']; + + var searchStart = 0; + int index; + while ((index = uidValue.IndexOf(className, searchStart, StringComparison.Ordinal)) >= 0) + { + // Check prefix boundary: must be preceded by '.' or be at start + var prefixOk = index == 0 || uidValue[index - 1] == '.'; + + // Check suffix boundary: must be followed by '.', '<', or '+' + var suffixIndex = index + className.Length; + var suffixOk = suffixIndex < uidValue.Length && + validSuffixes.Contains(uidValue[suffixIndex]); + + if (prefixOk && suffixOk) { return true; } + + searchStart = index + 1; + } + + return false; + } + + private static bool HasMethodNameMatch(string uidValue, string methodName) + { + // Method name patterns with proper boundaries: + // .{MethodName}. (most common: after class indices) + // .{MethodName}< (generic method) + // .{MethodName}( (method with parameter types in signature) + ReadOnlySpan validSuffixes = ['.', '<', '(']; + + var searchStart = 0; + int index; + while ((index = uidValue.IndexOf(methodName, searchStart, StringComparison.Ordinal)) >= 0) + { + // Method name must be preceded by '.' + var prefixOk = index > 0 && uidValue[index - 1] == '.'; + + // Check suffix boundary + var suffixIndex = index + methodName.Length; + var suffixOk = suffixIndex < uidValue.Length && + validSuffixes.Contains(uidValue[suffixIndex]); + + if (prefixOk && suffixOk) + { + return true; + } + + searchStart = index + 1; } return false; diff --git a/TUnit.TestProject/Bugs/4656/OverlappingClassNameFilterTests.cs b/TUnit.TestProject/Bugs/4656/OverlappingClassNameFilterTests.cs new file mode 100644 index 0000000000..67f0fa5835 --- /dev/null +++ b/TUnit.TestProject/Bugs/4656/OverlappingClassNameFilterTests.cs @@ -0,0 +1,82 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4656; + +/// +/// Regression test for GitHub issue #4656: TestDiscoveryContext.AllTests is incorrect +/// when filtering tests with overlapping class names (e.g., ABCV vs ABCVC). +/// The bug caused ABCV.B2 to incorrectly appear in AllTests when filtering for +/// ABCD.B2, ABCV.B1, ABCVC.B2, and VPCU.A1000. +/// https://github.com/thomhurst/TUnit/issues/4656 +/// +[EngineTest(ExpectedResult.Pass)] +public class ABCD +{ + [Test] + public async Task B1() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test] + public async Task B2() + { + await Assert.That(true).IsEqualTo(true); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class VPCU +{ + [Test, DependsOn(nameof(ABCV.B1))] + public async Task A1000() + { + await Assert.That(true).IsEqualTo(true); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class ABCV +{ + [Test] + public async Task A1() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test, DependsOn(nameof(A1))] + public async Task B1() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test, DependsOn(nameof(A1))] + public async Task B2() + { + // This test should NOT be included when filtering for ABCVC.B2 + // Bug #4656: substring matching caused this to be incorrectly included + await Assert.That(true).IsEqualTo(true); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class ABCVC +{ + [Test] + public async Task B0() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test, DependsOn(nameof(B0))] + public async Task B1() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test, DependsOn(nameof(B0))] + public async Task B2() + { + await Assert.That(true).IsEqualTo(true); + } +} From 76868c04c7af68d31366bb4b11e0e20616484f22 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:51:36 +0000 Subject: [PATCH 2/2] fix: add namespace checking to prevent false matches across namespaces Instead of using word-boundary matching for class names, use exact prefix matching with namespace.classname to ensure we don't match classes with the same name in different namespaces. --- .../Services/MetadataFilterMatcher.cs | 54 +++++++------------ 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs index dca0fee7d2..1c3ffdd3f9 100644 --- a/TUnit.Engine/Services/MetadataFilterMatcher.cs +++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs @@ -213,6 +213,7 @@ public bool CouldMatchFilter(TestMetadata metadata, ITestExecutionFilter? filter 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; @@ -224,14 +225,27 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada classNameForMatching = className.Substring(0, backtickIndex); } + // Build expected prefix: {Namespace}.{ClassName}. or just {ClassName}. for empty namespace + // This ensures we match the exact class in the exact namespace + var expectedClassPrefix = string.IsNullOrEmpty(namespaceName) + ? $"{classNameForMatching}." + : $"{namespaceName}.{classNameForMatching}."; + + // Also handle generic class names in UIDs (e.g., Namespace.MyClass.0.0...) + var expectedGenericClassPrefix = string.IsNullOrEmpty(namespaceName) + ? $"{classNameForMatching}<" + : $"{namespaceName}.{classNameForMatching}<"; + foreach (var uid in filter.TestNodeUids) { var uidValue = uid.Value; - // Check for class name with word boundaries to avoid substring false positives - // e.g., "ABCV" should not match "ABCVC" - // Class names in TestIds are bounded by: '.' (namespace/indices), '<' (generics), '+' (nested) - if (!HasClassNameMatch(uidValue, classNameForMatching)) + // Check for exact namespace.classname prefix to avoid matching + // same class name in different namespaces + var hasClassPrefix = uidValue.StartsWith(expectedClassPrefix, StringComparison.Ordinal) || + uidValue.StartsWith(expectedGenericClassPrefix, StringComparison.Ordinal); + + if (!hasClassPrefix) { continue; } @@ -249,38 +263,6 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada return false; } - private static bool HasClassNameMatch(string uidValue, string className) - { - // Class name patterns with proper boundaries: - // .{ClassName}. (most common: namespace.class.index) - // .{ClassName}< (generic: namespace.class.index) - // .{ClassName}+ (nested: outer+inner) - // Also handle start of string for edge cases - ReadOnlySpan validSuffixes = ['.', '<', '+']; - - var searchStart = 0; - int index; - while ((index = uidValue.IndexOf(className, searchStart, StringComparison.Ordinal)) >= 0) - { - // Check prefix boundary: must be preceded by '.' or be at start - var prefixOk = index == 0 || uidValue[index - 1] == '.'; - - // Check suffix boundary: must be followed by '.', '<', or '+' - var suffixIndex = index + className.Length; - var suffixOk = suffixIndex < uidValue.Length && - validSuffixes.Contains(uidValue[suffixIndex]); - - if (prefixOk && suffixOk) - { - return true; - } - - searchStart = index + 1; - } - - return false; - } - private static bool HasMethodNameMatch(string uidValue, string methodName) { // Method name patterns with proper boundaries: