diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/SourceInformationWriter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/SourceInformationWriter.cs index 460410e5ea..d5d1a1870e 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/SourceInformationWriter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/SourceInformationWriter.cs @@ -9,8 +9,7 @@ public static class SourceInformationWriter { public static void GenerateClassInformation(ICodeWriter sourceCodeWriter, Compilation compilation, INamedTypeSymbol namedTypeSymbol) { - var parent = namedTypeSymbol.ContainingType; - var parentExpression = parent != null ? MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(parent, null, sourceCodeWriter.IndentLevel) : null; + var parentExpression = GenerateParentClassMetadataExpression(namedTypeSymbol, sourceCodeWriter.IndentLevel); var classMetadata = MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(namedTypeSymbol, parentExpression, sourceCodeWriter.IndentLevel); // Handle multi-line class metadata similar to method metadata @@ -98,4 +97,20 @@ public static void GenerateParameterInformation(ICodeWriter sourceCodeWriter, MetadataGenerationHelper.WriteParameterMetadataGeneric(sourceCodeWriter, parameter); sourceCodeWriter.Append(","); } + + /// + /// Recursively generates parent ClassMetadata expression for nested types. + /// Returns null if the type has no containing type. + /// + private static string? GenerateParentClassMetadataExpression(INamedTypeSymbol typeSymbol, int indentLevel) + { + var parent = typeSymbol.ContainingType; + if (parent == null) + { + return null; + } + + var grandparentExpression = GenerateParentClassMetadataExpression(parent, indentLevel); + return MetadataGenerationHelper.GenerateClassMetadataGetOrAdd(parent, grandparentExpression, indentLevel); + } } diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 9422f2fd25..354c645cd4 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -1934,7 +1934,7 @@ private static void GenerateEnumerateTestDescriptors(CodeWriter writer, TestMeth { var methodName = testMethod.MethodSymbol.Name; var namespaceName = testMethod.TypeSymbol.ContainingNamespace?.ToDisplayString() ?? ""; - var simpleClassName = testMethod.TypeSymbol.Name; + var simpleClassName = testMethod.TypeSymbol.GetNestedClassName(); var fullyQualifiedName = string.IsNullOrEmpty(namespaceName) ? $"{simpleClassName}.{methodName}" : $"{namespaceName}.{simpleClassName}.{methodName}"; diff --git a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs index db6b93fda3..2da6f06b2b 100644 --- a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs +++ b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs @@ -34,7 +34,7 @@ public static void WriteMethodMetadata(ICodeWriter writer, IMethodSymbol methodS WriteParameterMetadataArrayForMethod(writer, methodSymbol); writer.AppendLine(","); writer.Append("Class = "); - WriteClassMetadataGetOrAdd(writer, namedTypeSymbol); + WriteClassMetadataGetOrAddWithParent(writer, namedTypeSymbol); // Manually restore indent level writer.SetIndentLevel(currentIndent); @@ -147,6 +147,24 @@ private static void WriteClassMetadataGetOrAdd(ICodeWriter writer, INamedTypeSym writer.Append("})"); } + /// + /// Writes ClassMetadata with recursive parent generation for nested types + /// + private static void WriteClassMetadataGetOrAddWithParent(ICodeWriter writer, INamedTypeSymbol typeSymbol) + { + if (typeSymbol.ContainingType != null) + { + // Generate the parent expression as a string, then pass it + var parentWriter = new CodeWriter("", includeHeader: false).SetIndentLevel(writer.IndentLevel); + WriteClassMetadataGetOrAddWithParent(parentWriter, typeSymbol.ContainingType); + WriteClassMetadataGetOrAdd(writer, typeSymbol, parentWriter.ToString()); + } + else + { + WriteClassMetadataGetOrAdd(writer, typeSymbol); + } + } + /// /// Generates code for creating a ClassMetadata instance with GetOrAdd pattern /// diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 0a3381d515..af39629ed0 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -8,18 +8,29 @@ public static class TestContextExtensions { public static string GetClassTypeName(this TestContext context) { + var type = context.Metadata.TestDetails.ClassType; var parameters = context.Metadata.TestDetails.MethodMetadata.Class.Parameters; + var nestedPrefix = GetNestedTypePrefix(type); + if (parameters.Length == 0) { - return context.Metadata.TestDetails.ClassType.Name; + return nestedPrefix != null + ? $"{nestedPrefix}+{type.Name}" + : type.Name; } var args = context.Metadata.TestDetails.TestClassArguments; var sb = StringBuilderPool.Get(); try { - sb.Append(context.Metadata.TestDetails.ClassType.Name); + if (nestedPrefix != null) + { + sb.Append(nestedPrefix); + sb.Append('+'); + } + + sb.Append(type.Name); sb.Append('('); for (var i = 0; i < args.Length; i++) @@ -40,6 +51,40 @@ public static string GetClassTypeName(this TestContext context) } } + /// + /// Gets the nested type prefix (outer class names joined by '+') for a type, or null if not nested. + /// For example, for OuterClass+MiddleClass+InnerClass, returns "OuterClass+MiddleClass". + /// + internal static string? GetNestedTypePrefix(Type type) + { + if (type.DeclaringType == null) + { + return null; + } + + // Walk the declaring type chain and build the hierarchy + var hierarchy = new List(); + var current = type.DeclaringType; + while (current != null) + { + hierarchy.Add(current.Name); + current = current.DeclaringType; + } + + hierarchy.Reverse(); + return string.Join("+", hierarchy); + } + + /// + /// Gets the full nested type name with '+' separator (matching .NET Type.FullName convention for nested types). + /// For example: OuterClass+InnerClass + /// + internal static string GetNestedTypeName(Type type) + { + var prefix = GetNestedTypePrefix(type); + return prefix != null ? $"{prefix}+{type.Name}" : type.Name; + } + #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Dynamic test metadata creation uses reflection")] #endif diff --git a/TUnit.Engine.Tests/NestedClassFilteringTests.cs b/TUnit.Engine.Tests/NestedClassFilteringTests.cs new file mode 100644 index 0000000000..78ec7b6335 --- /dev/null +++ b/TUnit.Engine.Tests/NestedClassFilteringTests.cs @@ -0,0 +1,46 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class NestedClassFilteringTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Filter_NestedClass_ByFullNestedName() + { + await RunTestsWithFilter( + "/*/*/NestedTestClassTests+NestedClass/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_NestedClass_SpecificMethod() + { + await RunTestsWithFilter( + "/*/*/NestedTestClassTests+NestedClass/Inner", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_OuterClass_StillWorks() + { + await RunTestsWithFilter( + "/*/*/NestedTestClassTests/Outer", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine.Tests/UidFilterMatchingTests.cs b/TUnit.Engine.Tests/UidFilterMatchingTests.cs index 67e107873a..a38ac24b8e 100644 --- a/TUnit.Engine.Tests/UidFilterMatchingTests.cs +++ b/TUnit.Engine.Tests/UidFilterMatchingTests.cs @@ -15,11 +15,11 @@ public class UidFilterMatchingTests(TestMode testMode) : InvokableTestBase(testM [Test] public async Task Filter_NestedClass_ShouldMatchOnlyNestedClass() { - // Filter for the nested class InnerClass - // Tree node paths use just the innermost class name (Type.Name) + // Filter for the nested class InnerClass using full nested path + // Tree node paths now use OuterClass+InnerClass format for nested types // Should only run tests from InnerClass, not OuterClass await RunTestsWithFilter( - "/*/TUnit.TestProject.Bugs._4656/InnerClass/InnerMethod", + "/*/TUnit.TestProject.Bugs._4656/OuterClass+InnerClass/InnerMethod", [ result => result.ResultSummary.Outcome.ShouldBe("Completed"), result => result.ResultSummary.Counters.Total.ShouldBe(1, diff --git a/TUnit.Engine/Building/ReflectionMetadataBuilder.cs b/TUnit.Engine/Building/ReflectionMetadataBuilder.cs index bf869eba2c..f6f06e38b2 100644 --- a/TUnit.Engine/Building/ReflectionMetadataBuilder.cs +++ b/TUnit.Engine/Building/ReflectionMetadataBuilder.cs @@ -90,7 +90,7 @@ private static ClassMetadata CreateClassMetadata([DynamicallyAccessedMembers(Dyn }), Parameters = constructorParameters, Properties = [], - Parent = null + Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null }; }); } diff --git a/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs b/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs index cc3954b121..00e5f742d0 100644 --- a/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs +++ b/TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs @@ -837,20 +837,7 @@ private static MethodMetadata CreateMethodMetadata( { Name = method.Name, Type = type, - Class = new ClassMetadata - { - Name = type.Name, - Type = type, - TypeInfo = new ConcreteType(type), - Namespace = type.Namespace ?? string.Empty, - Assembly = new AssemblyMetadata - { - Name = type.Assembly.GetName().Name ?? "Unknown" - }, - Parameters = [], - Properties = [], - Parent = null - }, + Class = CreateClassMetadata(type), Parameters = method.GetParameters().Select(p => new ParameterMetadata(p.ParameterType) { Name = p.Name ?? string.Empty, @@ -865,6 +852,24 @@ private static MethodMetadata CreateMethodMetadata( }; } + private static ClassMetadata CreateClassMetadata(Type type) + { + return ClassMetadata.GetOrAdd(type.FullName ?? type.Name, () => new ClassMetadata + { + Name = type.Name, + Type = type, + TypeInfo = new ConcreteType(type), + Namespace = type.Namespace ?? string.Empty, + Assembly = AssemblyMetadata.GetOrAdd(type.Assembly.GetName().Name ?? "Unknown", () => new AssemblyMetadata + { + Name = type.Assembly.GetName().Name ?? "Unknown" + }), + Parameters = [], + Properties = [], + Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null + }); + } + private static Func CreateInstanceHookDelegate(Type type, MethodInfo method) { return async (instance, context, cancellationToken) => diff --git a/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs b/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs index f2074b1539..181bae8019 100644 --- a/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs +++ b/TUnit.Engine/Discovery/ReflectionInstanceFactory.cs @@ -167,7 +167,7 @@ private static ClassMetadata CreateClassMetadata(Type type) }), Properties = [], Parameters = [], - Parent = null + Parent = type.DeclaringType != null ? CreateClassMetadata(type.DeclaringType) : null }); } diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 8fd87568a9..cd5e8aacf7 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -2497,10 +2497,11 @@ public IEnumerable EnumerateDescriptors() var capturedType = type; var capturedMethod = method; + var nestedClassName = TUnit.Core.Extensions.TestContextExtensions.GetNestedTypeName(type); yield return new TestDescriptor { TestId = $"{type.FullName}.{method.Name}", - ClassName = type.Name, + ClassName = nestedClassName, MethodName = method.Name, FullyQualifiedName = $"{type.FullName}.{method.Name}", FilePath = ExtractFilePath(method) ?? "Unknown", diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs index 4548f49270..e7dac385a8 100644 --- a/TUnit.Engine/Services/MetadataFilterMatcher.cs +++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs @@ -65,8 +65,15 @@ public bool CouldTypeMatch(Type type) // Check class name hint if (ClassName != null) { - // Handle generic types (e.g., MyClass`1) - if (type.Name != ClassName && !type.Name.StartsWith(ClassName + "`")) + // The filter ClassName may be "OuterClass+InnerClass" or just "InnerClass" + // type.Name is just the innermost name + var nestedName = TUnit.Core.Extensions.TestContextExtensions.GetNestedTypeName(type); + if (nestedName != ClassName + && !nestedName.StartsWith(ClassName + "`") + && !nestedName.EndsWith("+" + ClassName) + && !nestedName.StartsWith(ClassName + "+") + && type.Name != ClassName + && !type.Name.StartsWith(ClassName + "`")) { return false; } @@ -91,8 +98,12 @@ public bool CouldDescriptorMatch(TestDescriptor descriptor) // Check class name hint if (ClassName != null) { - // Handle generic types (e.g., MyClass`1) - if (descriptor.ClassName != ClassName && !descriptor.ClassName.StartsWith(ClassName + "`")) + // The filter ClassName may be "OuterClass+InnerClass" or just "InnerClass" + // The descriptor ClassName now includes nested hierarchy (e.g., "OuterClass+InnerClass") + if (descriptor.ClassName != ClassName + && !descriptor.ClassName.StartsWith(ClassName + "`") + && !descriptor.ClassName.EndsWith("+" + ClassName) + && !descriptor.ClassName.StartsWith(ClassName + "+")) { return false; } @@ -425,7 +436,7 @@ 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 className = TestFilterService.GetNestedClassName(classMetadata); var methodName = metadata.TestMethodName; return $"/{assemblyName}/{namespaceName}/{className}/{methodName}"; diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 7bf0d78d97..2980b1a682 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -140,7 +140,7 @@ private string BuildPath(AbstractExecutableTest test) var classMetadata = test.Context.Metadata.TestDetails.MethodMetadata.Class; var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*"; var namespaceName = classMetadata.Namespace ?? "*"; - var classTypeName = classMetadata.Name; + var classTypeName = GetNestedClassName(classMetadata); var path = $"/{assemblyName}/{namespaceName}/{classTypeName}/{metadata.TestMethodName}"; @@ -243,4 +243,27 @@ private IReadOnlyCollection FilterOutExplicitTests(IRead return filteredTests; } + + /// + /// Builds the nested class name from ClassMetadata by walking the Parent chain. + /// Returns names joined with '+' (e.g., "OuterClass+InnerClass"). + /// + internal static string GetNestedClassName(ClassMetadata classMetadata) + { + if (classMetadata.Parent == null) + { + return classMetadata.Name; + } + + var hierarchy = new List(); + var current = classMetadata; + while (current != null) + { + hierarchy.Add(current.Name); + current = current.Parent; + } + + hierarchy.Reverse(); + return string.Join("+", hierarchy); + } } diff --git a/TUnit.TestProject/NestedTestClassTests.cs b/TUnit.TestProject/NestedTestClassTests.cs new file mode 100644 index 0000000000..7cece13d60 --- /dev/null +++ b/TUnit.TestProject/NestedTestClassTests.cs @@ -0,0 +1,20 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class NestedTestClassTests +{ + [Test] + public void Outer() + { + } + + public class NestedClass + { + [Test] + public void Inner() + { + } + } +}