diff --git a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs index d1671aba73..d9c530c89d 100644 --- a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs +++ b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs @@ -149,7 +149,7 @@ public static string GloballyQualified(this ISymbol typeSymbol) { // Handle open generic types where type arguments are type parameters // This prevents invalid C# like List, Dictionary, T? where type parameters are undefined - if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol) + if (typeSymbol is INamedTypeSymbol { IsGenericType: true, Arity: > 0 } namedTypeSymbol) { // Check if this is an unbound generic type or has type parameter arguments // Use multiple detection methods for robustness across Roslyn versions diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index b835a60e99..1899eb7a1b 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -125,6 +125,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return null; } + // Skip types nested inside open generic containing types + // These can't be instantiated without knowing the outer type's type arguments + if (HasOpenGenericContainingType(containingType)) + { + return null; + } + var isGenericType = containingType is { IsGenericType: true, TypeParameters.Length: > 0 }; var isGenericMethod = methodSymbol is { IsGenericMethod: true }; @@ -146,6 +153,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }; } + private static bool HasOpenGenericContainingType(INamedTypeSymbol type) + { + var current = type.ContainingType; + while (current != null) + { + if (current is { IsGenericType: true, TypeParameters.Length: > 0 }) + { + return true; + } + current = current.ContainingType; + } + return false; + } + private static void GenerateInheritedTestSources(SourceProductionContext context, InheritsTestsClassMetadata? classInfo) { if (classInfo?.TypeSymbol == null) diff --git a/TUnit.Engine.Tests/UidFilterMatchingTests.cs b/TUnit.Engine.Tests/UidFilterMatchingTests.cs new file mode 100644 index 0000000000..67e107873a --- /dev/null +++ b/TUnit.Engine.Tests/UidFilterMatchingTests.cs @@ -0,0 +1,149 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// Comprehensive tests for UID filter matching in MetadataFilterMatcher.CouldMatchUidFilter. +/// Tests various scenarios that ensure VS Test Explorer can correctly filter tests. +/// Regression tests for GitHub issue #4656 follow-up. +/// +public class UidFilterMatchingTests(TestMode testMode) : InvokableTestBase(testMode) +{ + #region Nested Classes Tests + + [Test] + public async Task Filter_NestedClass_ShouldMatchOnlyNestedClass() + { + // Filter for the nested class InnerClass + // Tree node paths use just the innermost class name (Type.Name) + // Should only run tests from InnerClass, not OuterClass + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/InnerClass/InnerMethod", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (InnerClass.InnerMethod) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_OuterClass_ShouldNotMatchNestedClasses() + { + // Filter for only the outer class method + // Should only run OuterClass.OuterMethod, not nested class methods + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/OuterClass/OuterMethod", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (OuterClass.OuterMethod) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + #endregion + + #region Overlapping Names Tests + + [Test] + public async Task Filter_FilterTest_ShouldNotMatchFilterTestHelper() + { + // Filter for FilterTest class + // Should NOT match FilterTestHelper or FilterTesting + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/FilterTest/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (FilterTest.Method1) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}. " + + "If more tests ran, substring matching may be incorrectly matching FilterTestHelper or FilterTesting."), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_FilterTestHelper_ShouldNotMatchFilterTest() + { + // Filter for FilterTestHelper class + // Should NOT match FilterTest + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/FilterTestHelper/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (FilterTestHelper.Method1) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + #endregion + + #region Method Name Boundary Tests + + [Test] + public async Task Filter_MethodNameTest_ShouldNotMatchTestMethod() + { + // Filter for method named "Test" + // Should NOT match "TestMethod", "MyTest", or "TestingMethod" + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/MethodNameBoundaryTests/Test", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (MethodNameBoundaryTests.Test) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}. " + + "If more tests ran, method name boundary matching may be incorrect."), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_AllMethodsInClass_ShouldMatchAllFour() + { + // Filter for all methods in MethodNameBoundaryTests + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/MethodNameBoundaryTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(4, + $"Expected 4 tests but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(4), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + #endregion + + #region Original Issue Regression Tests + + [Test] + public async Task OriginalIssue_ABCVC_B2_ShouldNotInclude_ABCV_B2() + { + // Original regression test from issue #4656 + // Filter for ABCVC.B2 should NOT include ABCV.B2 + await RunTestsWithFilter( + "/*/TUnit.TestProject.Bugs._4656/ABCVC/B2", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(2, + $"Expected 2 tests (ABCVC.B2 + ABCVC.B0 dependency) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(2), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + #endregion +} diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs index 1c3ffdd3f9..4548f49270 100644 --- a/TUnit.Engine/Services/MetadataFilterMatcher.cs +++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs @@ -2,6 +2,7 @@ using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Requests; using TUnit.Core; +using TUnit.Core.Helpers; namespace TUnit.Engine.Services; @@ -214,27 +215,20 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada { 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); - } + // Build the full class name including nested type hierarchy (e.g., Outer+Inner) + // This matches the format used by TestIdentifierService.WriteTypeNameWithGenerics + var classNameForMatching = BuildClassNameForMatching(metadata.TestClassType); - // Build expected prefix: {Namespace}.{ClassName}. or just {ClassName}. for empty namespace - // This ensures we match the exact class in the exact namespace + // Build expected prefix: {Namespace}.{ClassName} or just {ClassName} for empty namespace + // The class name may be followed by '.', '<', or '(' depending on: + // - '.' for regular classes (e.g., MyClass.0.0.Method) + // - '<' for generic classes (e.g., MyClass.0.0.Method) + // - '(' for classes with constructor parameters (e.g., MyClass(System.String).0.0.Method) 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}<"; + ? classNameForMatching + : $"{namespaceName}.{classNameForMatching}"; foreach (var uid in filter.TestNodeUids) { @@ -242,14 +236,24 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada // 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) + if (!uidValue.StartsWith(expectedClassPrefix, StringComparison.Ordinal)) { continue; } + // Verify the character after the class name is a valid boundary: '.', '<', or '(' + var indexAfterPrefix = expectedClassPrefix.Length; + if (indexAfterPrefix < uidValue.Length) + { + var charAfterPrefix = uidValue[indexAfterPrefix]; + if (charAfterPrefix != '.' && charAfterPrefix != '<' && charAfterPrefix != '(') + { + // Not a valid boundary - this could be a substring match + // e.g., "ABCV" matching "ABCVC" + continue; + } + } + // Check for method name with word boundaries // Method names are preceded by '.' and followed by '.', '<', or '(' if (!HasMethodNameMatch(uidValue, methodName)) @@ -263,6 +267,93 @@ private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetada return false; } + /// + /// Builds the class name for matching as it appears in UIDs. + /// Handles nested types (Outer+Inner) and generic types with their type arguments. + /// This matches the format used by TestIdentifierService.WriteTypeNameWithGenerics. + /// + private static string BuildClassNameForMatching(Type type) + { + // Fast path: non-nested, non-generic types + if (type.DeclaringType == null && !type.IsGenericType) + { + return type.Name; + } + + // Build the full nested type hierarchy with '+' separators + // This matches TestIdentifierService.WriteTypeNameWithGenerics + var typeHierarchy = new ValueListBuilder([null, null, null, null]); + var typeVsb = new ValueStringBuilder(stackalloc char[128]); + try + { + var currentType = type; + + while (currentType != null) + { + if (currentType.IsGenericType) + { + var name = currentType.Name; + + var backtickIndex = name.IndexOf('`'); + if (backtickIndex > 0) + { + typeVsb.Append(name.AsSpan(0, backtickIndex)); + } + else + { + typeVsb.Append(name); + } + + // Add the generic type arguments (same format as TestIdentifierService) + var genericArgs = currentType.GetGenericArguments(); + typeVsb.Append('<'); + for (var i = 0; i < genericArgs.Length; i++) + { + if (i > 0) + { + typeVsb.Append(", "); + } + typeVsb.Append(genericArgs[i].FullName ?? genericArgs[i].Name); + } + typeVsb.Append('>'); + + typeHierarchy.Append(typeVsb.AsSpan().ToString()); + typeVsb.Length = 0; + } + else + { + typeHierarchy.Append(currentType.Name); + } + + currentType = currentType.DeclaringType; + } + + // Build result: reverse to get outer-to-inner order and join with '+' + var resultVsb = new ValueStringBuilder(stackalloc char[256]); + try + { + for (var i = typeHierarchy.Length - 1; i >= 0; i--) + { + if (i < typeHierarchy.Length - 1) + { + resultVsb.Append('+'); + } + resultVsb.Append(typeHierarchy[i]); + } + return resultVsb.ToString(); + } + finally + { + resultVsb.Dispose(); + } + } + finally + { + typeHierarchy.Dispose(); + typeVsb.Dispose(); + } + } + private static bool HasMethodNameMatch(string uidValue, string methodName) { // Method name patterns with proper boundaries: diff --git a/TUnit.TestProject/Bugs/4656/UidFilterMatchingTests.cs b/TUnit.TestProject/Bugs/4656/UidFilterMatchingTests.cs new file mode 100644 index 0000000000..661c1f343f --- /dev/null +++ b/TUnit.TestProject/Bugs/4656/UidFilterMatchingTests.cs @@ -0,0 +1,177 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4656; + +/// +/// Test fixtures for GitHub issue #4656 follow-up: UID filter matching for VS Test Explorer. +/// These test various scenarios that the MetadataFilterMatcher.CouldMatchUidFilter must handle: +/// - Nested classes (Outer+Inner format) +/// - Generic classes (MyClass<T> format) +/// - Classes with constructor parameters (MyClass(params) format) +/// - Nested generic classes (Outer<T>+Inner format) +/// + +#region Nested Classes Tests + +[EngineTest(ExpectedResult.Pass)] +public class OuterClass +{ + [Test] + public async Task OuterMethod() + { + await Assert.That(true).IsEqualTo(true); + } + + [EngineTest(ExpectedResult.Pass)] + public class InnerClass + { + [Test] + public async Task InnerMethod() + { + await Assert.That(true).IsEqualTo(true); + } + } +} + +#endregion + +#region Generic Classes Tests + +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(int))] +public class GenericTestClass +{ + [Test] + public async Task GenericClassMethod() + { + await Assert.That(typeof(T)).IsNotNull(); + } +} + +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(int), typeof(string))] +public class MultiGenericTestClass +{ + [Test] + public async Task MultiGenericClassMethod() + { + await Assert.That(typeof(T1)).IsNotNull(); + await Assert.That(typeof(T2)).IsNotNull(); + } +} + +#endregion + +#region Classes With Constructor Parameters Tests + +[EngineTest(ExpectedResult.Pass)] +[Arguments("test-value")] +public class ClassWithStringParam(string value) +{ + [Test] + public async Task MethodWithParam() + { + await Assert.That(value).IsNotNull(); + } +} + +[EngineTest(ExpectedResult.Pass)] +[Arguments("test", 42)] +public class ClassWithMultipleParams(string name, int count) +{ + [Test] + public async Task MethodWithMultipleParams() + { + await Assert.That(name).IsNotNull(); + await Assert.That(count).IsEqualTo(42); + } +} + +#endregion + +#region Nested Generic Classes Tests + +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(int))] +public class OuterGenericClass +{ + [Test] + public async Task OuterGenericMethod() + { + await Assert.That(typeof(T)).IsNotNull(); + } +} + +#endregion + +#region Overlapping Names With Different Suffixes + +/// +/// Tests for ensuring "Test" doesn't match "TestHelper" or "Testing" +/// +[EngineTest(ExpectedResult.Pass)] +public class FilterTest +{ + [Test] + public async Task Method1() + { + await Assert.That(true).IsEqualTo(true); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class FilterTestHelper +{ + [Test] + public async Task Method1() + { + await Assert.That(true).IsEqualTo(true); + } +} + +[EngineTest(ExpectedResult.Pass)] +public class FilterTesting +{ + [Test] + public async Task Method1() + { + await Assert.That(true).IsEqualTo(true); + } +} + +#endregion + +#region Method Name Boundary Tests + +/// +/// Tests for ensuring method name "Test" doesn't match "TestMethod" or "MyTest" +/// +[EngineTest(ExpectedResult.Pass)] +public class MethodNameBoundaryTests +{ + [Test] + public async Task Test() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test] + public async Task TestMethod() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test] + public async Task MyTest() + { + await Assert.That(true).IsEqualTo(true); + } + + [Test] + public async Task TestingMethod() + { + await Assert.That(true).IsEqualTo(true); + } +} + +#endregion