Repository navigation
fix(sourcegen): report generic test rows whose type arguments cannot be inferred #7187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ba818d1
0e0a62b
ccf1b36
7128cf6
891ec8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,7 @@ | |
| private const string GeneratedNamespace = "TUnit.Generated"; | ||
|
|
||
| private static readonly DiagnosticDescriptor GenerationErrorDescriptor = new( | ||
| "TUNIT0999", | ||
|
Check warning on line 26 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
|
||
| "Source Generation Error", | ||
| "Failed to generate test metadata for {0}.{1}: {2}", | ||
| "TUnit", | ||
|
|
@@ -499,9 +499,9 @@ | |
|
|
||
| if (concreteInstantiations.Count == 0 && classConstraintViolations.Count == 0) | ||
| { | ||
| // No concrete types could be resolved — skip this method. | ||
| // The test won't be discovered, which matches the behavior of | ||
| // not having any data to resolve type arguments. | ||
| // No concrete types could be resolved for a generic class — skip this method. A | ||
| // generic method on a non-generic class always gets an entry: rows whose type | ||
| // arguments cannot be inferred fail instead (#7163). | ||
| return; | ||
| } | ||
| } | ||
|
|
@@ -819,6 +819,12 @@ | |
| // every instantiation a class row establishes, since reflection mode runs them there too. | ||
| List<AttributeData>? uninferredMethodDataSources = null; | ||
|
|
||
| // A generic method on a non-generic class without [GenerateGenericTest]: every method data source | ||
| // that infers an existing instantiation's types joins it (#7163). With [GenerateGenericTest], a later | ||
| // non-[Arguments] source inferring the same types is dropped, as reflection mode does (#7092). | ||
| var joinsDuplicateMethodSources = testMethod is { IsGenericMethod: true, IsGenericType: false } | ||
| && !testMethod.MethodAttributes.Any(static a => a.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true); | ||
|
|
||
| var classTypeArgCount = testMethod.IsGenericType ? testMethod.TypeSymbol.TypeParameters.Length : 0; | ||
| var methodTypeArgCount = testMethod.IsGenericMethod ? testMethod.MethodSymbol.TypeParameters.Length : 0; | ||
|
|
||
|
|
@@ -896,7 +902,13 @@ | |
| } | ||
| } | ||
|
|
||
| if (specificAttr?.AttributeClass?.Name == "ArgumentsAttribute") | ||
| // A generic method on a non-generic class runs only the sources scoped to its | ||
| // instantiation, so any method data source that infers the same types as an earlier | ||
| // one (a second [MethodDataSource] declared like the first, say) joins it too. | ||
| // Otherwise its rows would be reported as uninferable (#7163). Reflection mode drops | ||
| // such a source when the method has [GenerateGenericTest], so it is dropped here too. | ||
| if (specificAttr?.AttributeClass?.Name == "ArgumentsAttribute" | ||
| || (specificAttr is not null && !isClassRow && joinsDuplicateMethodSources)) | ||
| { | ||
| existing.AddRow(specificAttr, isClassRow); | ||
| } | ||
|
|
@@ -1300,9 +1312,159 @@ | |
| .Select(v => v.Message) | ||
| .ToList(); | ||
|
|
||
| if (testMethod is { IsGenericMethod: true, IsGenericType: false }) | ||
| { | ||
| AddUninferredTypeArgumentFailures(testMethod, className, results); | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Reports the data sources of a generic test method whose rows no instantiation runs, because | ||
| /// their declared type does not determine the method's type arguments (an <c>object</c> or | ||
| /// <c>object[]</c> row, say). Reflection mode infers the type arguments from each row's run-time | ||
| /// values, which source-generated mode cannot do. Rather than dropping the rows silently, each of | ||
| /// them fails with a message that says how to fix it. A method with no data source at all fails | ||
| /// once, as it does in reflection mode (#7163). | ||
| /// </summary> | ||
| private static void AddUninferredTypeArgumentFailures(TestMethodMetadata testMethod, string className, List<ConcreteInstantiation> results) | ||
| { | ||
| List<AttributeData>? uncovered = null; | ||
| var hasDataSource = false; | ||
|
|
||
| foreach (var attribute in testMethod.MethodAttributes) | ||
| { | ||
| // [GenerateGenericTest] lists the type arguments, so its types run every data source | ||
| // (or fail their constraints) and no data source has to infer them. | ||
| if (attribute.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true) | ||
| { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| foreach (var attribute in testMethod.MethodAttributes) | ||
| { | ||
| if (!DataSourceAttributeHelper.IsDataSourceAttribute(attribute.AttributeClass)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| hasDataSource = true; | ||
| if (!IsRunByAnyInstantiation(testMethod, results, attribute)) | ||
| { | ||
| (uncovered ??= []).Add(attribute); | ||
| } | ||
| } | ||
|
|
||
| if (hasDataSource && uncovered is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (!hasDataSource && results.Count > 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var methodName = testMethod.MethodSymbol.Name; | ||
| var violations = new List<MethodConstraintViolation>(); | ||
| if (uncovered is null) | ||
| { | ||
| violations.Add(new MethodConstraintViolation( | ||
| $"Could not infer the type arguments of generic test method '{methodName}': it has no data source to infer them from. List the type arguments with [GenerateGenericTest].", | ||
| RowSource: null, | ||
| AllMethodDataSources: false)); | ||
| } | ||
| else | ||
| { | ||
| foreach (var attribute in uncovered) | ||
| { | ||
| violations.Add(new MethodConstraintViolation( | ||
| $"Could not infer the type arguments of generic test method '{methodName}' from data source [{DescribeDataSource(attribute)}]. " | ||
| + "Source-generated mode infers type arguments at compile time from the data source's declared type, not from the run-time type of its values, and that declared type does not determine them. " | ||
| + "Declare the data source with a type that does (for example IEnumerable<Func<int>> rather than IEnumerable<object>), or list the type arguments with [GenerateGenericTest].", | ||
| RowSource: attribute, | ||
| AllMethodDataSources: false)); | ||
| } | ||
| } | ||
|
|
||
| // All failures of one concrete class share one entry, as constraint violations do, so each | ||
| // failing row gets its own data source index and so its own test id. | ||
| var existing = results.FirstOrDefault(r => r.MethodConstraintViolations != null && r.ConcreteClassName == className); | ||
| if (existing != null) | ||
| { | ||
| existing.MethodConstraintViolations!.AddRange(violations); | ||
| return; | ||
| } | ||
|
|
||
| results.Add(new ConcreteInstantiation | ||
| { | ||
| ConcreteClassName = className, | ||
| TypeArguments = [], | ||
| ClassTypeArgs = [], | ||
| MethodTypeArgs = [], | ||
| TestName = methodName, | ||
| MethodName = methodName, | ||
| MethodConstraintViolations = violations, | ||
|
thomhurst marked this conversation as resolved.
Resolved
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| }); | ||
|
|
||
| static string DescribeDataSource(AttributeData attribute) | ||
| { | ||
| if (attribute.ApplicationSyntaxReference?.GetSyntax() is { } syntax) | ||
| { | ||
| return syntax.ToString(); | ||
| } | ||
|
|
||
| var name = attribute.AttributeClass?.Name ?? "DataSource"; | ||
| return name.EndsWith("Attribute", StringComparison.Ordinal) ? name.Substring(0, name.Length - "Attribute".Length) : name; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Whether the rows of <paramref name="dataSource"/> run, or are reported, on at least one of the | ||
| /// instantiations of a generic method on a non-generic class. This mirrors the data source | ||
| /// selection in <see cref="EmitConcreteDataSources"/>. | ||
| /// </summary> | ||
| private static bool IsRunByAnyInstantiation(TestMethodMetadata testMethod, List<ConcreteInstantiation> results, AttributeData dataSource) | ||
| { | ||
| if (results.Count == 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // When the parameters do not use the type arguments, every instantiation runs every data source. | ||
| if (!MethodParametersReferenceTypeParameters(testMethod)) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| foreach (var instantiation in results) | ||
| { | ||
| if (instantiation.MethodConstraintViolations is { } violations) | ||
| { | ||
| foreach (var violation in violations) | ||
| { | ||
| if (violation.AllMethodDataSources | ||
| || (violation.RowSource is { } rowSource && AreSameAttribute(rowSource, dataSource))) | ||
| { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| // [GenerateGenericTest] and IInfersType<T> instantiations run every method data source. | ||
| if (instantiation.SpecificArgumentsAttribute is null || instantiation.IsSpecificMethodDataSource(dataSource)) | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Extracts type arguments from a GenerateGenericTestAttribute's constructor arguments. | ||
| /// The attribute takes params Type[] in its constructor. | ||
|
|
@@ -3838,7 +4000,7 @@ | |
|
|
||
| if (!methodSymbol.ReturnsVoid) | ||
| { | ||
| writer.AppendLine($"returnType: typeof({methodSymbol.ReturnType.GloballyQualified()}),"); | ||
| writer.AppendLine($"returnType: typeof({ReturnTypeForTypeOf(methodSymbol.ReturnType)}),"); | ||
| } | ||
|
|
||
| if (methodSymbol.TypeParameters.Length > 0) | ||
|
|
@@ -3858,12 +4020,68 @@ | |
| return writer.ToString(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The type to name in <c>typeof(...)</c> for a test method's return type. The metadata is emitted | ||
| /// outside the method and its class, so a return type that uses a type parameter cannot name it. | ||
| /// <see cref="TypeExtensions.GloballyQualified"/> opens a generic type whose arguments are type | ||
| /// parameters (<c>Task<T></c> becomes <c>Task<></c>), but not one that nests them | ||
| /// (<c>Task<T[]></c>) or takes them from a containing type (<c>Outer<T>.Inner</c>), and a | ||
| /// bare <c>T</c> or <c>T[]</c> has no open form. Those become the open generic type and | ||
| /// <c>object</c>, as parameter metadata does. | ||
| /// </summary> | ||
| private static string ReturnTypeForTypeOf(ITypeSymbol returnType) | ||
| { | ||
| if (!ReferencesTypeParameter(returnType)) | ||
| { | ||
| return returnType.GloballyQualified(); | ||
| } | ||
|
|
||
| if (returnType is INamedTypeSymbol namedType) | ||
| { | ||
| return OpenGenericTypeOf(namedType); | ||
| } | ||
|
|
||
| return "object"; | ||
|
|
||
| // Unlike CodeGenerationHelpers.ContainsTypeParameter, this also looks at containing types: | ||
| // Outer<T>.Inner has no type arguments of its own, but still mentions T. | ||
| static bool ReferencesTypeParameter(ITypeSymbol type) => type switch | ||
| { | ||
| ITypeParameterSymbol => true, | ||
| IArrayTypeSymbol arrayType => ReferencesTypeParameter(arrayType.ElementType), | ||
| INamedTypeSymbol named => named.TypeArguments.Any(ReferencesTypeParameter) | ||
| || (named.ContainingType is { } containingType && ReferencesTypeParameter(containingType)), | ||
| _ => false, | ||
| }; | ||
|
|
||
| // The unbound form of the type and every type that contains it, such as | ||
| // global::Outer<>.Inner<,>. C# only accepts an unbound generic name in typeof when every | ||
| // generic level is unbound. Roslyn displays Nullable<T> as `T?`, so the name is built from | ||
| // the definition's parts rather than from its display string. | ||
| static string OpenGenericTypeOf(INamedTypeSymbol type) | ||
| { | ||
| var definition = type.OriginalDefinition; | ||
| var name = definition.Arity > 0 | ||
| ? $"{definition.Name}<{new string(',', definition.Arity - 1)}>" | ||
| : definition.Name; | ||
|
|
||
| if (definition.ContainingType is { } containingType) | ||
| { | ||
| return $"{OpenGenericTypeOf(containingType)}.{name}"; | ||
| } | ||
|
|
||
| return definition.ContainingNamespace is { IsGlobalNamespace: false } containingNamespace | ||
| ? $"{containingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}.{name}" | ||
| : $"global::{name}"; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Generates a MethodMetadataFactory.Create(...) call expression referencing __classType and __classMetadata. | ||
| /// </summary> | ||
| private static string GenerateMethodMetadataFactoryCall(IMethodSymbol methodSymbol) | ||
| { | ||
| var returnType = methodSymbol.ReturnType.GloballyQualified(); | ||
| var returnType = ReturnTypeForTypeOf(methodSymbol.ReturnType); | ||
| var sb = new System.Text.StringBuilder(); | ||
| sb.Append($"global::TUnit.Core.MethodMetadataFactory.Create(\"{methodSymbol.Name}\", __classType, typeof({returnType}), __classMetadata"); | ||
|
|
||
|
|
@@ -6514,7 +6732,7 @@ | |
|
|
||
| // Map tuple elements to method parameters to infer types | ||
| var tupleElements = tupleType.TupleElements; | ||
| for (var i = 0; i < testMethodParams.Length && i < tupleElements.Length; i++) | ||
|
Check warning on line 6735 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
|
||
| { | ||
| var paramType = testMethodParams[i].Type; | ||
| var tupleElementType = tupleElements[i].Type; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| namespace TUnit.Core.SourceGenerator.Tests; | ||
|
|
||
| /// <summary> | ||
| /// A generic test method whose type arguments its data source's declared type cannot determine must | ||
| /// be emitted as an entry whose rows fail with an explanation, instead of being dropped (#7163). | ||
| /// </summary> | ||
| internal class Tests7163 : TestsBase | ||
| { | ||
| [Test] | ||
| public Task Test() => RunTest(Path.Combine( | ||
| Git.TestsDirectory.FullName, | ||
| "TUnit.TestProject", | ||
| "Bugs", | ||
| "_7163", | ||
| "UninferableGenericMethodTests.cs"), | ||
| async generatedFiles => | ||
| { | ||
| const string cannotInfer = "Could not infer the type arguments of generic test method 'Generic' from data source [MethodDataSource(nameof(Rows))]"; | ||
|
|
||
| string SourceFor(string className) => generatedFiles.Single(f => | ||
| f.Contains($"typeof(global::TUnit.TestProject.Bugs._7163.{className})")); | ||
|
|
||
| // Uninferable rows: the data source is wrapped so each of its rows fails. | ||
| foreach (var className in new[] { "ObjectRowsTests", "ObjectArrayRowsTests" }) | ||
| { | ||
| var source = SourceFor(className); | ||
| await Assert.That(source).Contains($"global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"{cannotInfer}"); | ||
| await Assert.That(source).DoesNotContain("instance.Generic<"); | ||
| } | ||
|
|
||
| // The inferable row still runs, beside the failing source. | ||
| var partly = SourceFor("PartlyInferableTests"); | ||
| await Assert.That(partly).Contains("instance.Generic<int>("); | ||
| await Assert.That(partly).Contains($"global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"{cannotInfer}"); | ||
|
|
||
| // [GenerateGenericTest] lists the type arguments, so nothing is reported. | ||
| var generateGeneric = SourceFor("GenerateGenericTestWithObjectRowsTests"); | ||
| await Assert.That(generateGeneric).Contains("instance.Generic<int>("); | ||
| await Assert.That(generateGeneric).DoesNotContain("GenericConstraintViolationDataSource"); | ||
|
|
||
| // No data source: a single failing row. | ||
| var noDataSource = SourceFor("NoDataSourceTests"); | ||
| await Assert.That(noDataSource).Contains("global::TUnit.Core.GenericConstraintViolationDataSource.Create(\"Could not infer the type arguments of generic test method 'Generic': it has no data source to infer them from."); | ||
|
|
||
| // Data sources that infer the same type arguments as an earlier one join its instantiation. | ||
| foreach (var className in new[] { "ArgumentsAndDataSourceWithSameTypesTests", "TwoDataSourcesWithSameTypesTests" }) | ||
| { | ||
| var source = SourceFor(className); | ||
| await Assert.That(source).Contains("instance.Generic<int>("); | ||
| await Assert.That(source).DoesNotContain("GenericConstraintViolationDataSource"); | ||
| } | ||
|
|
||
| // A return type that nests T is named by its open generic type, since T does not exist there. | ||
| var nestedReturnType = SourceFor("NestedGenericReturnTypeTests"); | ||
| await Assert.That(nestedReturnType).Contains("typeof(global::System.Threading.Tasks.Task<>)"); | ||
| await Assert.That(nestedReturnType).DoesNotContain("Task<T[]>"); | ||
|
|
||
| var nullableReturnType = SourceFor("NullableGenericReturnTypeTests"); | ||
| await Assert.That(nullableReturnType).Contains("typeof(global::System.Nullable<>)"); | ||
| await Assert.That(nullableReturnType).DoesNotContain("T?<>"); | ||
|
|
||
| var containingReturnType = SourceFor("ContainingGenericReturnTypeTests"); | ||
| await Assert.That(containingReturnType).Contains("typeof(global::TUnit.TestProject.Bugs._7163.ContainingGenericReturnTypeTests.Outer<>.Inner)"); | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.