From 4553d2595e8381e47913abf50d089987e4421e01 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:57:55 +0100 Subject: [PATCH 1/2] fix(engine): infer object for [Arguments(null)] rows on generic classes instead of dropping them An untyped [Arguments(null)] method row on a generic class with no [GenerateGenericTest] or class-level data source produced no test in either mode. Its one null value now infers object, as any other null value and the generic-method case (#7120) do, in both the source generator and reflection mode. When object breaks the class's constraints, the row is reported as a failed constraint-violation test. Fixes #7141 --- .../Generators/TestMetadataGenerator.cs | 31 +- .../Attributes/TestData/ArgumentsAttribute.cs | 4 +- .../ReflectionGenericConstraintViolations.cs | 8 +- .../Discovery/ReflectionTestDataCollector.cs | 13 +- .../Bugs/7141/Tests7141.cs | 17 + .../Tests7141.Test.verified.txt | 366 ++++++++++++++++++ tests/TUnit.Engine.Tests/Issue7106Tests.cs | 1 - tests/TUnit.Engine.Tests/Issue7141Tests.cs | 46 +++ .../Bugs/_7106/NullMethodRowInferenceTests.cs | 15 +- .../NullArgumentsRowOnGenericClassTests.cs | 63 +++ 10 files changed, 533 insertions(+), 31 deletions(-) create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs create mode 100644 tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt create mode 100644 tests/TUnit.Engine.Tests/Issue7141Tests.cs create mode 100644 tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index c56c76268b9..a0a97f4b266 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -1122,7 +1122,8 @@ a.AttributeClass is { } attributeClass List? inferringRows = null; foreach (var methodArgAttr in methodArgumentsAttributes) { - var inferredTypes = InferClassTypesFromMethodArguments(testMethod.TypeSymbol, testMethod.MethodSymbol, methodArgAttr, compilation); + var inferredTypes = InferClassTypesFromMethodArguments(testMethod.TypeSymbol, testMethod.MethodSymbol, methodArgAttr, compilation, + nullArgumentsArrayInfersObject: !classTypeArgumentsSupplied); if (inferredTypes is { Length: > 0 }) { (inferringRows ??= []).Add(methodArgAttr); @@ -5387,7 +5388,8 @@ private static void GenerateGenericTestWithConcreteTypes( { foreach (var methodArgAttr in methodArgumentsAttributes) { - var inferredTypes = InferClassTypesFromMethodArguments(testMethod.TypeSymbol, testMethod.MethodSymbol, methodArgAttr, compilation); + var inferredTypes = InferClassTypesFromMethodArguments(testMethod.TypeSymbol, testMethod.MethodSymbol, methodArgAttr, compilation, + nullArgumentsArrayInfersObject: false); if (inferredTypes is { Length: > 0 }) { var typeKey = BuildTypeKey(inferredTypes); @@ -5968,7 +5970,12 @@ private static bool HasArgumentsArray(AttributeData argAttr) => /// private static bool IsNullArgumentsArray(AttributeData argAttr) => argAttr.ConstructorArguments[0].IsNull; - private static ITypeSymbol[]? InferClassTypesFromMethodArguments(INamedTypeSymbol classSymbol, IMethodSymbol methodSymbol, AttributeData argAttr, Compilation compilation) + /// + /// False when [GenerateGenericTest] or a class-level data source supplies the class type arguments: an + /// [Arguments(null)] row then infers no type and runs on those instantiations. True otherwise, so the + /// row's one null value infers object like any other null value instead of being dropped (#7141). + /// + private static ITypeSymbol[]? InferClassTypesFromMethodArguments(INamedTypeSymbol classSymbol, IMethodSymbol methodSymbol, AttributeData argAttr, Compilation compilation, bool nullArgumentsArrayInfersObject) { if (argAttr.ConstructorArguments.Length == 0) { @@ -5978,12 +5985,26 @@ private static bool HasArgumentsArray(AttributeData argAttr) => var inferredTypes = new Dictionary(); var classTypeParameters = classSymbol.TypeParameters; - // [Arguments(null)] passes one null value, from which no class type can be inferred (as in reflection mode) - if (!HasArgumentsArray(argAttr) || IsNullArgumentsArray(argAttr)) + if (!HasArgumentsArray(argAttr)) { return null; } + // [Arguments(null)] passes a null array, which ArgumentsAttribute stores as one null value (as in reflection mode) + if (IsNullArgumentsArray(argAttr)) + { + if (nullArgumentsArrayInfersObject + && classTypeParameters.Length == 1 + && methodSymbol.Parameters.Length > 0 + && methodSymbol.Parameters[0].Type is ITypeParameterSymbol { DeclaringMethod: null } nullTypeParam + && SymbolEqualityComparer.Default.Equals(nullTypeParam, classTypeParameters[0])) + { + return [compilation.GetSpecialType(SpecialType.System_Object)]; + } + + return null; + } + var argumentValues = argAttr.ConstructorArguments[0].Values; var methodParams = methodSymbol.Parameters; diff --git a/src/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs b/src/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs index 8b848a82b7b..3c0e56a441c 100644 --- a/src/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs +++ b/src/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs @@ -80,7 +80,9 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR /// /// True for [Arguments(null)], which passes a null params array rather than one null - /// element. Generic type inference treats it as inferring no type, as the source generator does. + /// element. On a generic class whose type arguments [GenerateGenericTest] or a class-level data source + /// supplies, it infers no class type argument; otherwise its one null value infers object, as the source + /// generator does. /// internal bool IsNullValuesArray { get; } diff --git a/src/TUnit.Engine/Discovery/ReflectionGenericConstraintViolations.cs b/src/TUnit.Engine/Discovery/ReflectionGenericConstraintViolations.cs index e9abbacd8d3..a948c9b359f 100644 --- a/src/TUnit.Engine/Discovery/ReflectionGenericConstraintViolations.cs +++ b/src/TUnit.Engine/Discovery/ReflectionGenericConstraintViolations.cs @@ -78,12 +78,8 @@ public void AddMethodRowViolations() foreach (var argumentsAttribute in testMethod.GetCustomAttributes(inherit: false)) { - // [Arguments(null)] (a null params array) infers no type, as in the source generator - if (argumentsAttribute.IsNullValuesArray) - { - continue; - } - + // [Arguments(null)] (a null params array) stores one null value, which infers object + // here, as in the source generator (#7141) var typeArguments = InferClassTypeArguments(classGenericParameters, parameters, argumentsAttribute.Values); if (typeArguments == null) { diff --git a/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index bf187edfe46..af346a86325 100644 --- a/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1204,6 +1204,10 @@ private static List BuildTestsFromMethodRows(Type genericTypeDefin inferredTypeArguments = null; var classGenericParameters = genericTypeDefinition.GetGenericArguments(); + // With no [GenerateGenericTest] instantiation for it to run on, [Arguments(null)] infers object + // like any other null value, rather than being dropped (#7141) + var nullValuesArrayInfersObject = generateGenericTestAttributes.Length == 0; + foreach (var genericMethod in testMethods) { if (genericMethod.IsGenericMethodDefinition) @@ -1232,7 +1236,7 @@ private static List BuildTestsFromMethodRows(Type genericTypeDefin continue; } - var typeArguments = InferClassTypeArgumentsFromMethodRow(classGenericParameters, parameters, arguments); + var typeArguments = InferClassTypeArgumentsFromMethodRow(classGenericParameters, parameters, arguments, nullValuesArrayInfersObject); if (typeArguments == null) { continue; @@ -1328,10 +1332,11 @@ private static bool IsInferredFromMethodRows(Dictionary && methodRowTypeArguments.TryGetValue(genericMethod, out var groups) && IndexOfTypeArguments(groups, typeArguments) >= 0; - private static Type[]? InferClassTypeArgumentsFromMethodRow(Type[] classGenericParameters, ParameterInfo[] parameters, ArgumentsAttribute arguments) + private static Type[]? InferClassTypeArgumentsFromMethodRow(Type[] classGenericParameters, ParameterInfo[] parameters, ArgumentsAttribute arguments, bool nullValuesArrayInfersObject) { - // [Arguments(null)] (a null params array) infers no type, as in the source generator - if (arguments.IsNullValuesArray) + // [Arguments(null)] (a null params array) infers no type when [GenerateGenericTest] supplies the + // instantiations it runs on. Otherwise its one null value infers object, as in the source generator. + if (arguments.IsNullValuesArray && !nullValuesArrayInfersObject) { return null; } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs new file mode 100644 index 00000000000..b309924e659 --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs @@ -0,0 +1,17 @@ +namespace TUnit.Core.SourceGenerator.Tests; + +internal class Tests7141 : TestsBase +{ + [Test] + public Task Test() => RunTest(Path.Combine( + Git.TestsDirectory.FullName, + "TUnit.TestProject", + "Bugs", + "_7141", + "NullArgumentsRowOnGenericClassTests.cs"), + async generatedFiles => + { + // The null row's C instantiation is emitted, not dropped + await Assert.That(string.Join("\n", generatedFiles)).Contains("NullRowOnGenericClass"); + }); +} diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt new file mode 100644 index 00000000000..9cd09140059 --- /dev/null +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt @@ -0,0 +1,366 @@ +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7141_NullRowOnGenericClass_T_Run__T_TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass<>", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass<>), + TypeInfo = new global::TUnit.Core.ConstructedGeneric(typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass<>), [new global::TUnit.Core.GenericParameter(0, false, "T")]), + Name = "NullRowOnGenericClass", + Namespace = "TUnit.TestProject.Bugs._7141", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass<>); + private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("Run", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata, parameters: global::TUnit.Core.ParameterMetadataFactory.ForGenericMethod(typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass<>), "Run", false, 0, new global::TUnit.Core.ParameterMetadata[] +{ +global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.GenericParameter(0, false, "T"), false) +})); + private static global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass __CreateInstance_0(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke_0(global::TUnit.TestProject.Bugs._7141.NullRowOnGenericClass instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.Run(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes_0(int groupIndex) + { + return new global::System.Attribute[] + { + new global::TUnit.Core.TestAttribute(), + new global::TUnit.Core.ArgumentsAttribute(null), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + }; + } + public static readonly global::TUnit.Core.TestEntry>[] Entries_0 = new global::TUnit.Core.TestEntry>[] + { + global::TUnit.Core.TestEntryFactory.Create>( + methodName: "Run", + fullyQualifiedName: "TUnit.TestProject.Bugs._7141.NullRowOnGenericClass.Run", + filePath: "", + lineNumber: 13, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(null), +}, + methodMetadata: __mm_0, + createInstance: __CreateInstance_0, + invokeBody: __Invoke_0, + methodIndex: 0, + createAttributes: __Attributes_0, + attributeGroupIndex: 0), + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_NullRowOnGenericClass_T_Run__T_TestSource_0 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_NullRowOnGenericClass_T_Run__T_TestSource.Entries_0); +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7141_NullAndValueRowsOnGenericClass_T_Run__T_TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass<>", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass<>), + TypeInfo = new global::TUnit.Core.ConstructedGeneric(typeof(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass<>), [new global::TUnit.Core.GenericParameter(0, false, "T")]), + Name = "NullAndValueRowsOnGenericClass", + Namespace = "TUnit.TestProject.Bugs._7141", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass<>); + private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("Run", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata, parameters: global::TUnit.Core.ParameterMetadataFactory.ForGenericMethod(typeof(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass<>), "Run", false, 0, new global::TUnit.Core.ParameterMetadata[] +{ +global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.GenericParameter(0, false, "T"), false) +})); + private static global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass __CreateInstance_0(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke_0(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.Run(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes_0(int groupIndex) + { + return new global::System.Attribute[] + { + new global::TUnit.Core.TestAttribute(), + new global::TUnit.Core.ArgumentsAttribute(1), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + }; + } + public static readonly global::TUnit.Core.TestEntry>[] Entries_0 = new global::TUnit.Core.TestEntry>[] + { + global::TUnit.Core.TestEntryFactory.Create>( + methodName: "Run", + fullyQualifiedName: "TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass.Run", + filePath: "", + lineNumber: 26, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(1), +}, + methodMetadata: __mm_0, + createInstance: __CreateInstance_0, + invokeBody: __Invoke_0, + methodIndex: 0, + createAttributes: __Attributes_0, + attributeGroupIndex: 0), + }; + private static global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass __CreateInstance_1(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke_1(global::TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.Run(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes_1(int groupIndex) + { + return new global::System.Attribute[] + { + new global::TUnit.Core.TestAttribute(), + new global::TUnit.Core.ArgumentsAttribute(null), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + }; + } + public static readonly global::TUnit.Core.TestEntry>[] Entries_1 = new global::TUnit.Core.TestEntry>[] + { + global::TUnit.Core.TestEntryFactory.Create>( + methodName: "Run", + fullyQualifiedName: "TUnit.TestProject.Bugs._7141.NullAndValueRowsOnGenericClass.Run", + filePath: "", + lineNumber: 26, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(null), +}, + methodMetadata: __mm_0, + createInstance: __CreateInstance_1, + invokeBody: __Invoke_1, + methodIndex: 0, + createAttributes: __Attributes_1, + attributeGroupIndex: 0), + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_NullAndValueRowsOnGenericClass_T_Run__T_TestSource_0 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_NullAndValueRowsOnGenericClass_T_Run__T_TestSource.Entries_0); +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_NullAndValueRowsOnGenericClass_T_Run__T_TestSource_1 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_NullAndValueRowsOnGenericClass_T_Run__T_TestSource.Entries_1); +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7141_NullRowOnConstrainedGenericClass_T_Run__T_TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>), + TypeInfo = new global::TUnit.Core.ConstructedGeneric(typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>), [new global::TUnit.Core.GenericParameter(0, false, "T")]), + Name = "NullRowOnConstrainedGenericClass", + Namespace = "TUnit.TestProject.Bugs._7141", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>); + private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("Run", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata, parameters: global::TUnit.Core.ParameterMetadataFactory.ForGenericMethod(typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>), "Run", false, 0, new global::TUnit.Core.ParameterMetadata[] +{ +global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.GenericParameter(0, false, "T"), false) +})); + private static global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass __CreateInstance_0(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke_0(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.Run(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes_0(int groupIndex) + { + return new global::System.Attribute[] + { + new global::TUnit.Core.TestAttribute(), + new global::TUnit.Core.ArgumentsAttribute(1) + }; + } + public static readonly global::TUnit.Core.TestEntry>[] Entries_0 = new global::TUnit.Core.TestEntry>[] + { + global::TUnit.Core.TestEntryFactory.Create>( + methodName: "Run", + fullyQualifiedName: "TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass.Run", + filePath: "", + lineNumber: 41, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(1), +}, + methodMetadata: __mm_0, + createInstance: __CreateInstance_0, + invokeBody: __Invoke_0, + methodIndex: 0, + createAttributes: __Attributes_0, + attributeGroupIndex: 0), + }; + public static readonly global::TUnit.Core.GenericConstraintViolationEntry[] ConstraintViolations = new global::TUnit.Core.GenericConstraintViolationEntry[] + { + new global::TUnit.Core.GenericConstraintViolationEntry + { + MethodName = "Run", + FilePath = "", + LineNumber = 41, + MethodMetadata = __mm_0, + Messages = new string[] { "Type 'Object' does not satisfy the 'struct' constraint for generic parameter 'T'" }, + }, + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_NullRowOnConstrainedGenericClass_T_Run__T_TestSource_0 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_NullRowOnConstrainedGenericClass_T_Run__T_TestSource.Entries_0); +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_NullRowOnConstrainedGenericClass_T_Run__T_TestSource_ConstraintViolations = global::TUnit.Core.SourceRegistrar.RegisterGenericConstraintViolations(typeof(global::TUnit.TestProject.Bugs._7141.NullRowOnConstrainedGenericClass<>), static () => TUnit_TestProject_Bugs__7141_NullRowOnConstrainedGenericClass_T_Run__T_TestSource.ConstraintViolations); +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_Bugs__7141_GenerateGenericTestWithNullRow_T_Run__T__TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow<>", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow<>), + TypeInfo = new global::TUnit.Core.ConstructedGeneric(typeof(global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow<>), [new global::TUnit.Core.GenericParameter(0, false, "T")]), + Name = "GenerateGenericTestWithNullRow", + Namespace = "TUnit.TestProject.Bugs._7141", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow<>); + private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("Run", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata, parameters: global::TUnit.Core.ParameterMetadataFactory.ForGenericMethod(typeof(global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow<>), "Run", false, 0, new global::TUnit.Core.ParameterMetadata[] +{ +global::TUnit.Core.ParameterMetadataFactory.Create(typeof(object), "value", new global::TUnit.Core.GenericParameter(0, false, "T"), true) +})); + private static global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow __CreateInstance_0(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke_0(global::TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.Run(global::TUnit.Core.Helpers.CastHelper.Cast(args[0]))); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + private static global::System.Attribute[] __Attributes_0(int groupIndex) + { + return new global::System.Attribute[] + { + new global::TUnit.Core.TestAttribute(), + new global::TUnit.Core.ArgumentsAttribute(null), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass), + new global::TUnit.Core.GenerateGenericTestAttribute(typeof(string)) + }; + } + public static readonly global::TUnit.Core.TestEntry>[] Entries_0 = new global::TUnit.Core.TestEntry>[] + { + global::TUnit.Core.TestEntryFactory.Create>( + methodName: "Run", + fullyQualifiedName: "TUnit.TestProject.Bugs._7141.GenerateGenericTestWithNullRow.Run", + filePath: "", + lineNumber: 56, + hasDataSource: true, + testDataSources: new global::TUnit.Core.IDataSourceAttribute[] +{ + new global::TUnit.Core.ArgumentsAttribute(null), +}, + methodMetadata: __mm_0, + createInstance: __CreateInstance_0, + invokeBody: __Invoke_0, + methodIndex: 0, + createAttributes: __Attributes_0, + attributeGroupIndex: 0), + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_Bugs__7141_GenerateGenericTestWithNullRow_T_Run__T__TestSource_0 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_GenerateGenericTestWithNullRow_T_Run__T__TestSource.Entries_0); +} diff --git a/tests/TUnit.Engine.Tests/Issue7106Tests.cs b/tests/TUnit.Engine.Tests/Issue7106Tests.cs index 5fcf8d19470..4a57db39f85 100644 --- a/tests/TUnit.Engine.Tests/Issue7106Tests.cs +++ b/tests/TUnit.Engine.Tests/Issue7106Tests.cs @@ -29,7 +29,6 @@ public class Issue7106Tests(TestMode testMode) : InvokableTestBase(testMode) [Arguments("NonGenericClassGenericMethodNullMethodRow", 2)] [Arguments("TypedNullMethodRowInfersObject", 1)] [Arguments("NullElementMethodRowInfersObject", 1)] - [Arguments("NullArrayMethodRowOnConstrainedClass", 1)] [Arguments("NullFirstOfSharedTypeParameterMethodRow", 1)] [Arguments("NullFirstOfSharedTypeParameterWithGenerateGenericTest", 1)] [Arguments("GenericClassArrayMethodRows", 2)] diff --git a/tests/TUnit.Engine.Tests/Issue7141Tests.cs b/tests/TUnit.Engine.Tests/Issue7141Tests.cs new file mode 100644 index 00000000000..94eeb5a345c --- /dev/null +++ b/tests/TUnit.Engine.Tests/Issue7141Tests.cs @@ -0,0 +1,46 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// An untyped [Arguments(null)] method row on a generic class with nothing else to supply the class's +/// type argument infers object and runs, or fails visibly when object breaks the class's constraints, +/// instead of silently producing no test, in both modes (#7141). +/// +public class Issue7141Tests(TestMode testMode) : InvokableTestBase(testMode) +{ + private const string Namespace = "/*/TUnit.TestProject.Bugs._7141"; + + [Test] + [Arguments("NullRowOnGenericClass", 1)] + [Arguments("NullAndValueRowsOnGenericClass", 2)] + [Arguments("GenerateGenericTestWithNullRow", 1)] + public async Task Null_Row_Runs(string className, int expectedTests) + { + await RunTestsWithFilter( + // Trailing '*': generic class nodes carry the arity + $"{Namespace}/{className}*/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(expectedTests), + result => result.ResultSummary.Counters.Passed.ShouldBe(expectedTests), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Null_Row_That_Breaks_The_Class_Constraint_Fails() + { + await RunTestsWithFilter( + $"{Namespace}/NullRowOnConstrainedGenericClass*/*", + [ + result => result.ResultSummary.Counters.Total.ShouldBe(2), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(1), + result => result.Results.Single(x => x.Outcome == "Failed").TestName.ShouldBe("Run"), + result => result.Results.Single(x => x.Outcome == "Failed").Output?.ErrorInfo?.Message + .ShouldContain("Type 'Object' does not satisfy the 'struct' constraint for generic parameter 'T'") + ]); + } +} diff --git a/tests/TUnit.TestProject/Bugs/_7106/NullMethodRowInferenceTests.cs b/tests/TUnit.TestProject/Bugs/_7106/NullMethodRowInferenceTests.cs index 91b1da0dd96..af8ebf93dc5 100644 --- a/tests/TUnit.TestProject/Bugs/_7106/NullMethodRowInferenceTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7106/NullMethodRowInferenceTests.cs @@ -3,7 +3,7 @@ namespace TUnit.TestProject.Bugs._7106; // A null element of a method row's params array infers object for the class type argument, as Roslyn -// types it, in both modes. [Arguments(null)] passes a null params array instead, which infers no type. +// types it, in both modes. [Arguments(null)], a null params array, does the same: see Bugs/_7141. [EngineTest(ExpectedResult.Pass)] public class TypedNullMethodRowInfersObject @@ -61,19 +61,6 @@ public async Task Run(T first, T second) } } -// The null row infers no type, so it neither runs nor is reported as breaking the struct constraint. -[EngineTest(ExpectedResult.Pass)] -public class NullArrayMethodRowOnConstrainedClass where T : struct -{ - [Test] - [Arguments(1)] - [Arguments(null)] - public async Task Run(T value) - { - await Assert.That(value).IsEqualTo((T)(object)1); - } -} - // Each class row reaches its own test: the values seen across the tests are exactly the rows' values. [EngineTest(ExpectedResult.Pass)] [Arguments(1)] diff --git a/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs b/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs new file mode 100644 index 00000000000..ae0c079eb1f --- /dev/null +++ b/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs @@ -0,0 +1,63 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._7141; + +// [Arguments(null)] passes a null params array, which ArgumentsAttribute stores as one null value. On a +// generic class with no [GenerateGenericTest] or class-level data source, that value infers object for +// the class's type argument, like any other null value, so the row runs instead of silently producing +// no test. This matches a generic method with [Arguments(null)] (#7120). Same in both modes. + +[EngineTest(ExpectedResult.Pass)] +public class NullRowOnGenericClass +{ + [Test] + [Arguments(null)] + public async Task Run(T value) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(object)); + await Assert.That(value).IsNull(); + } +} + +// Beside a row that infers int, the null row runs on its own C instantiation. +[EngineTest(ExpectedResult.Pass)] +public class NullAndValueRowsOnGenericClass +{ + [Test] + [Arguments(1)] + [Arguments(null)] + public async Task Run(T value) + { + await Assert.That(typeof(T)).IsEqualTo(value is null ? typeof(object) : typeof(int)); + await Assert.That((object?)value is null or 1).IsTrue(); + } +} + +// object breaks the struct constraint, so the null row is reported as a failed test, like any other +// method row that infers a type the class's constraints reject (#7107), rather than being dropped. +// No [EngineTest] attribute: the failing row must not be picked up by the `/**[EngineTest=Pass]` run. +public class NullRowOnConstrainedGenericClass where T : struct +{ + [Test] + [Arguments(1)] + [Arguments(null)] + public async Task Run(T value) + { + await Assert.That(value).IsEqualTo((T)(object)1); + } +} + +// A [GenerateGenericTest] supplies the instantiation, so the null row infers nothing and runs on it, +// as before (#7106). +[EngineTest(ExpectedResult.Pass)] +[GenerateGenericTest(typeof(string))] +public class GenerateGenericTestWithNullRow +{ + [Test] + [Arguments(null)] + public async Task Run(T? value) + { + await Assert.That(typeof(T)).IsEqualTo(typeof(string)); + await Assert.That(value).IsNull(); + } +} From 22701575d9fff06be5e987dd9573c5a09440cd0c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:32:53 +0100 Subject: [PATCH 2/2] fix(sourcegen): leave [Arguments(null)] rows on by-reference parameters uninferred A null row on a generic class whose first parameter is ref T or out T inferred object, and the generated call then passed the value without ref, which does not compile (CS1620). Reflection mode infers nothing for such a parameter (its type is T&), so the generator now does the same and the row keeps its previous behaviour. --- .../Generators/TestMetadataGenerator.cs | 3 +++ .../Bugs/7141/Tests7141.cs | 3 +++ .../Tests7141.Test.verified.txt | 9 +++++++++ .../Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs | 11 +++++++++++ 4 files changed, 26 insertions(+) diff --git a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index a0a97f4b266..5b503303914 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -5996,6 +5996,9 @@ private static bool HasArgumentsArray(AttributeData argAttr) => if (nullArgumentsArrayInfersObject && classTypeParameters.Length == 1 && methodSymbol.Parameters.Length > 0 + // A by-reference parameter cannot be passed the row's value, and reflection mode infers + // nothing for it (its type is T&, not T), so the row stays without an instantiation. + && methodSymbol.Parameters[0].RefKind == RefKind.None && methodSymbol.Parameters[0].Type is ITypeParameterSymbol { DeclaringMethod: null } nullTypeParam && SymbolEqualityComparer.Default.Equals(nullTypeParam, classTypeParameters[0])) { diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs index b309924e659..2967fcf1ca1 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs +++ b/tests/TUnit.Core.SourceGenerator.Tests/Bugs/7141/Tests7141.cs @@ -13,5 +13,8 @@ public Task Test() => RunTest(Path.Combine( { // The null row's C instantiation is emitted, not dropped await Assert.That(string.Join("\n", generatedFiles)).Contains("NullRowOnGenericClass"); + + // A by-reference parameter cannot take the row's value, so no instantiation is emitted for it + await Assert.That(string.Join("\n", generatedFiles)).DoesNotContain("NullRowOnGenericClassByRef"); }); } diff --git a/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt b/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt index 9cd09140059..d1fb6ba1d9d 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt +++ b/tests/TUnit.Core.SourceGenerator.Tests/Tests7141.Test.verified.txt @@ -364,3 +364,12 @@ internal static partial class TUnit_TestRegistration { static readonly int _r_TUnit_TestProject_Bugs__7141_GenerateGenericTestWithNullRow_T_Run__T__TestSource_0 = global::TUnit.Core.SourceRegistrar.RegisterEntries>(static () => TUnit_TestProject_Bugs__7141_GenerateGenericTestWithNullRow_T_Run__T__TestSource.Entries_0); } + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; diff --git a/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs b/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs index ae0c079eb1f..a4733e64f28 100644 --- a/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7141/NullArgumentsRowOnGenericClassTests.cs @@ -61,3 +61,14 @@ public async Task Run(T? value) await Assert.That(value).IsNull(); } } + +// A by-reference parameter cannot take the row's value, so the null row infers nothing for it in +// either mode, and the generated code must still compile. No test is registered. +public class NullRowOnGenericClassByRef +{ + [Test] + [Arguments(null)] + public void Run(ref T value) + { + } +}