Repository navigation
Generic test tree-node paths and display names differ between source-gen and reflection modes #7105
Description
Summary
Generic tests get a different tree-node path and display name in source-gen mode than in reflection mode. So an exact --treenode-filter for a generic test only matches in one mode, and test names in reports and IDEs change when the engine mode changes.
Confirmed on current main (b1cceb3), net10.0.
Repro
namespace TUnit.TestProject.FollowupRepro7103;
public class FxNamesMethod
{
[Test]
[Arguments(5)]
[Arguments("s")]
public async Task Method<T>(T value) => await Assert.That(value is not null).IsTrue();
}
[Arguments(5)]
public class FxNamesClass<T>(T value)
{
[Test]
public async Task Plain() => await Assert.That(value is not null).IsTrue();
}
#pragma warning disable TUnit0001 // false positive, see the TUnit0001 issue
public class FxAnalyzer
{
public static IEnumerable<Func<(int, string)>> Rows() { yield return () => (1, "a"); }
[Test]
[GenerateGenericTest(typeof(long), typeof(string))]
[MethodDataSource(nameof(Rows))]
public async Task A_GenerateGeneric_Tuple<T1, T2>(T1 first, T2 second) => await Assert.That(first is T1).IsTrue();
}Tree-node path (what --treenode-filter matches)
Here N stands for /*/TUnit.TestProject.FollowupRepro7103. Cells are the number of tests that ran.
| Filter | Source-gen | Reflection |
|---|---|---|
N/FxNamesMethod/Method |
0 | 2 |
N/FxNamesMethod/Method<int> |
1 | 0 |
N/FxNamesMethod/Method<Int32> |
0 | 0 |
N/FxNamesMethod/Method* |
2 | 2 |
N/FxNamesClass/Plain |
1 | 0 |
N/FxNamesClass`1/Plain |
0 | 1 |
N/FxNamesClass<int>/Plain |
0 | 0 |
N/FxNamesClass*/Plain |
1 | 1 |
N/FxAnalyzer/A_GenerateGeneric_Tuple |
0 | 1 |
N/FxAnalyzer/A_GenerateGeneric_Tuple<long, string> |
1 | 0 |
N/FxAnalyzer/A_GenerateGeneric_Tuple<Int64, String> |
0 | 0 |
So:
- Generic method node: source-gen uses
Method<int>, one node per instantiation, with C# keyword names. Types without a keyword areglobal::-qualified, for exampleMethod<global::TUnit.TestProject.TestClass>(seen in Generic test rows that violate type constraints are silently dropped in source-gen mode but fail in reflection mode #7097). Reflection uses the bareMethod. - Generic class node: source-gen uses
FxNamesClass(the RoslynName, with no arity). Reflection usesFxNamesClass`1(the CLRType.Name).
Display name
| Test | Source-gen | Reflection |
|---|---|---|
Method, row 5 |
Method<int>(5) |
Method(5) |
Method, row "s" |
Method<string>(s) |
Method(s) |
A_GenerateGeneric_Tuple |
A_GenerateGeneric_Tuple<long, string>(1, a) |
A_GenerateGeneric_Tuple<Int64, String>(1, a) |
B_Tuple_Only<T1, T2> (same data source, no [GenerateGenericTest]) |
B_Tuple_Only<int, string>(1, a) |
B_Tuple_Only(1, a) |
[GenerateGenericTest(typeof(int))] GenerateGeneric<T>() |
GenerateGeneric<int> |
GenerateGeneric<Int32> |
FxNamesClass<T>.Plain |
Plain |
Plain |
Reflection shows type arguments only when it closed the method with MakeGenericMethod, which happens on the [GenerateGenericTest] and typed-data-source paths. Even then it uses CLR names (Int32, Int64, String). When T is resolved per row while the test is built, reflection shows no type arguments at all. Source-gen always shows type arguments, with C# keyword names.
Expected
The same tree-node path and display name in both modes, so that one filter selects the same tests in either mode. Which form to keep is an owner decision. One option is a method node of Method<int> and a class node of either FxNamesClass or FxNamesClass`1 in both modes. Either way, the type arguments must be formatted the same way in both modes: keyword or CLR name, and with or without global::.
Suspected cause / file pointers
Source-gen:
src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs,CollectConcreteInstantiations→TryAddInstantiation(around line 700):testName = $"{methodName}<{...ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>". ThatTestNamebecomes the entry's method name andTestMethodName(emitted around lines 1172-1192).src/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs, lines 91 and 181:ClassMetadata.Name = typeSymbol.Name, which has no arity.
Reflection:
src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs, around line 1091:TestMethodName = testMethod.Name, the bare name. The display-name builder around lines 1143-1153 adds<{t.Name}>(CLR names), and only for closed generic methods.- Same file, around line 1461:
ClassMetadata.Name = type.Name, which givesFxNamesClass`1.
The filter path is built from these values in src/TUnit.Engine/Services/TestFilterService.cs BuildPath (line 219) and in src/TUnit.Engine/Services/MetadataFilterMatcher.cs BuildPathFromMetadata.
Because of this mismatch, tests/TUnit.Engine.Tests/GenericConstraintValidationTests.cs in #7102 had to put a trailing * on its method filters.
Activity
fix: keep same-named generic instantiations distinct
fix: same filter path and display name for generic tests in both engi…