From 772139f9234320d6306b7c922e9df241e3a7b43f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:38:30 +0100 Subject: [PATCH] fix(engine): emit ECMA-335 metadata-format type names in TestMethodIdentifierProperty Microsoft.Testing.Platform specifies that TestMethodIdentifierProperty values are ECMA-335 compliant (managed name format, vstest RFC 0017). TUnit was passing reflection Type.FullName for ParameterTypeFullNames and ReturnTypeFullName, which yields assembly-qualified generic arguments (List`1[[System.String, System.Private.CoreLib, ...]]) and null for open generic method parameters. New MetadataTypeNameFormatter emits the spec format: - constructed generics: List`1 - generic method/type parameters: !!0 / !0 - arrays, byref, pointer suffixes; nested types via '+' TypeName intentionally keeps TUnit's class-argument decoration (MyTests(arg1, arg2)) so parameterized classes stay distinguishable in Test Explorer. --- .../MetadataTypeNameFormatterTests.cs | 71 +++++++++++++++ TUnit.Engine/Extensions/TestExtensions.cs | 5 +- .../Helpers/MetadataTypeNameFormatter.cs | 89 +++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 TUnit.Engine.Tests/MetadataTypeNameFormatterTests.cs create mode 100644 TUnit.Engine/Helpers/MetadataTypeNameFormatter.cs diff --git a/TUnit.Engine.Tests/MetadataTypeNameFormatterTests.cs b/TUnit.Engine.Tests/MetadataTypeNameFormatterTests.cs new file mode 100644 index 00000000000..1477863fc16 --- /dev/null +++ b/TUnit.Engine.Tests/MetadataTypeNameFormatterTests.cs @@ -0,0 +1,71 @@ +using Shouldly; +using TUnit.Engine.Helpers; + +namespace TUnit.Engine.Tests; + +public class MetadataTypeNameFormatterTests +{ + [Test] + [Arguments(typeof(string), "System.String")] + [Arguments(typeof(void), "System.Void")] + [Arguments(typeof(int[]), "System.Int32[]")] + [Arguments(typeof(int[][]), "System.Int32[][]")] + [Arguments(typeof(int[,]), "System.Int32[,]")] + [Arguments(typeof(List), "System.Collections.Generic.List`1")] + [Arguments(typeof(List<>), "System.Collections.Generic.List`1")] + [Arguments(typeof(Dictionary>), "System.Collections.Generic.Dictionary`2>")] + [Arguments(typeof(Task), "System.Threading.Tasks.Task`1")] + [Arguments(typeof(int?), "System.Nullable`1")] + [Arguments(typeof(Outer.Inner), "TUnit.Engine.Tests.MetadataTypeNameFormatterTests+Outer+Inner")] + [Arguments(typeof(Outer.GenericInner), "TUnit.Engine.Tests.MetadataTypeNameFormatterTests+Outer+GenericInner`1")] + public void Formats_Types_In_Metadata_Format(Type type, string expected) + { + MetadataTypeNameFormatter.GetMetadataFullName(type).ShouldBe(expected); + } + + [Test] + public void Formats_Generic_Method_Parameter_As_DoubleBang_Position() + { + var method = typeof(GenericMembers).GetMethod(nameof(GenericMembers.MethodWithGenericParameters))!; + var parameters = method.GetParameters(); + + MetadataTypeNameFormatter.GetMetadataFullName(parameters[0].ParameterType).ShouldBe("!!0"); + MetadataTypeNameFormatter.GetMetadataFullName(parameters[1].ParameterType).ShouldBe("!!1[]"); + MetadataTypeNameFormatter.GetMetadataFullName(parameters[2].ParameterType).ShouldBe("System.Collections.Generic.List`1"); + } + + [Test] + public void Formats_Generic_Type_Parameter_As_SingleBang_Position() + { + var typeParameter = typeof(Dictionary<,>).GetGenericArguments()[1]; + + MetadataTypeNameFormatter.GetMetadataFullName(typeParameter).ShouldBe("!1"); + } + + [Test] + public void Formats_ByRef_Parameter_With_Ampersand() + { + var method = typeof(GenericMembers).GetMethod(nameof(GenericMembers.MethodWithRefParameter))!; + var parameterType = method.GetParameters()[0].ParameterType; + + MetadataTypeNameFormatter.GetMetadataFullName(parameterType).ShouldBe("System.Int32&"); + } + + public static class Outer + { + public class Inner; + + public class GenericInner; + } + + public static class GenericMembers + { + public static void MethodWithGenericParameters(T1 first, T2[] second, List third) + { + } + + public static void MethodWithRefParameter(ref int value) + { + } + } +} diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 50f0ef87cfd..844de857b0a 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -6,6 +6,7 @@ using TUnit.Core; using TUnit.Core.Extensions; using TUnit.Engine.Capabilities; +using TUnit.Engine.Helpers; using TUnit.Engine.Reporters; #pragma warning disable TPEXP @@ -61,7 +62,7 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext typeName: testContext.GetClassTypeName(), methodName: testDetails.MethodName, parameterTypeFullNames: CreateParameterTypeArray(testDetails.MethodMetadata.Parameters), - returnTypeFullName: testDetails.ReturnType.FullName ?? typeof(void).FullName!, + returnTypeFullName: MetadataTypeNameFormatter.GetMetadataFullName(testDetails.ReturnType), methodArity: testDetails.MethodMetadata.GenericTypeCount ); @@ -354,7 +355,7 @@ private static string[] CreateParameterTypeArray(ParameterMetadata[] parameters) var array = new string[parameters.Length]; for (var i = 0; i < parameters.Length; i++) { - array[i] = parameters[i].Type.FullName!; + array[i] = MetadataTypeNameFormatter.GetMetadataFullName(parameters[i].Type); } return array; } diff --git a/TUnit.Engine/Helpers/MetadataTypeNameFormatter.cs b/TUnit.Engine/Helpers/MetadataTypeNameFormatter.cs new file mode 100644 index 00000000000..90683940b09 --- /dev/null +++ b/TUnit.Engine/Helpers/MetadataTypeNameFormatter.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using System.Text; +using TUnit.Core.Helpers; + +namespace TUnit.Engine.Helpers; + +/// +/// Formats instances as ECMA-335 metadata-format full names, as required by +/// TestMethodIdentifierProperty in Microsoft.Testing.Platform. This matches the managed +/// name format (vstest RFC 0017) that platform consumers parse: constructed generics as +/// List`1<System.String>, generic method parameters as !!0, generic type +/// parameters as !0, and nested types separated by +. +/// +internal static class MetadataTypeNameFormatter +{ + private static readonly ConcurrentDictionary Cache = new(); + + public static string GetMetadataFullName(Type type) + { + return Cache.GetOrAdd(type, static t => + { + var builder = StringBuilderPool.Get(); + try + { + AppendMetadataName(builder, t); + return builder.ToString(); + } + finally + { + StringBuilderPool.Return(builder); + } + }); + } + + private static void AppendMetadataName(StringBuilder builder, Type type) + { + if (type.IsGenericParameter) + { + builder.Append(type.DeclaringMethod is null ? "!" : "!!"); + builder.Append(type.GenericParameterPosition); + return; + } + + if (type.HasElementType) + { + AppendMetadataName(builder, type.GetElementType()!); + + if (type.IsArray) + { + builder.Append('['); + builder.Append(',', type.GetArrayRank() - 1); + builder.Append(']'); + } + else if (type.IsPointer) + { + builder.Append('*'); + } + else if (type.IsByRef) + { + builder.Append('&'); + } + + return; + } + + if (type.IsGenericType && !type.IsGenericTypeDefinition) + { + AppendMetadataName(builder, type.GetGenericTypeDefinition()); + + builder.Append('<'); + var genericArguments = type.GetGenericArguments(); + for (var i = 0; i < genericArguments.Length; i++) + { + if (i > 0) + { + builder.Append(','); + } + + AppendMetadataName(builder, genericArguments[i]); + } + builder.Append('>'); + return; + } + + // Non-generic types and generic type definitions: FullName is already metadata format + // (namespace-qualified, '+' for nested types, backtick arity for generics). + builder.Append(type.FullName ?? type.Name); + } +}