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
59 changes: 59 additions & 0 deletions TUnit.Engine.Tests/OverlappingClassNameFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// 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
/// </summary>
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)
]);
}
}
69 changes: 66 additions & 3 deletions TUnit.Engine/Services/MetadataFilterMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,78 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada
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);
Copy link
Contributor

@TimothyMakkison TimothyMakkison Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classNameForMatching = className.AsSpan().Slice(0, backtickIndex); and make ReadOnlySpan<char> classNameForMatching

Could prolly use ValueStringBuilder for expectedClassPrefix and expectedGenericClassPrefix but that's pretty pendanitc

}

// 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<System.Int32>.0.0...)
var expectedGenericClassPrefix = string.IsNullOrEmpty(namespaceName)
? $"{classNameForMatching}<"
: $"{namespaceName}.{classNameForMatching}<";

foreach (var uid in filter.TestNodeUids)
{
var uidValue = uid.Value;
if (uidValue.Contains(namespaceName) &&
uidValue.Contains(className) &&
uidValue.Contains(methodName))

// 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;
}

// 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 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<char> 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;
Expand Down
82 changes: 82 additions & 0 deletions TUnit.TestProject/Bugs/4656/OverlappingClassNameFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._4656;

/// <summary>
/// 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
/// </summary>
[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<ABCV>(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);
}
}
Loading