From 547dec4d80c7941d56adf758279db45a42a63226 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 19:40:29 +0100 Subject: [PATCH 1/4] Add support for nested tuples in data source handling and tests --- TUnit.Analyzers/TestDataAnalyzer.cs | 57 +++++-- .../Generators/TestMetadataGenerator.cs | 112 +++++++++++++- .../TestData/MethodDataSourceAttribute.cs | 38 ++++- TUnit.Core/Helpers/DataSourceHelpers.cs | 131 ++++++++++++++++ TUnit.Engine/Building/TestBuilder.cs | 4 +- TUnit.Engine/Helpers/DataUnwrapper.cs | 59 +++++++- .../NestedTupleDataSourceTests.cs | 142 ++++++++++++++++++ TUnit.TestProject/NestedTupleTest.cs | 27 ++++ TUnit.TestProject/SimpleTupleTest.cs | 21 +++ 9 files changed, 562 insertions(+), 29 deletions(-) create mode 100644 TUnit.TestProject/NestedTupleDataSourceTests.cs create mode 100644 TUnit.TestProject/NestedTupleTest.cs create mode 100644 TUnit.TestProject/SimpleTupleTest.cs diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 098eb2267e..23b8c9fa68 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -634,19 +634,6 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, if (isTuples) { - // Check if any test method parameters are tuple types when data source returns tuples - // This causes a runtime mismatch: data source provides separate arguments, but method expects tuple parameter - var tupleParameters = testParameterTypes.Where(p => p is INamedTypeSymbol { IsTupleType: true }).ToArray(); - if (tupleParameters.Any()) - { - context.ReportDiagnostic(Diagnostic.Create( - Rules.WrongArgumentTypeTestData, - attribute.GetLocation(), - string.Join(", ", unwrappedTypes), - string.Join(", ", testParameterTypes)) - ); - return; - } if (unwrappedTypes.Length != testParameterTypes.Length) { @@ -777,11 +764,51 @@ private ImmutableArray UnwrapTypes(SymbolAnalysisContext context, type = genericType.TypeArguments[0]; } - // Check for tuple types first before doing conversion checks + // Check for tuple types - but handle them intelligently based on test parameters if (type is INamedTypeSymbol { IsTupleType: true } tupleType) { + // Special case: If there's a single parameter that expects the same tuple type, + // don't unwrap the tuple at all + if (testParameterTypes.Length == 1 && + testParameterTypes[0] is INamedTypeSymbol paramTupleType && + paramTupleType.IsTupleType && + SymbolEqualityComparer.Default.Equals(tupleType, testParameterTypes[0])) + { + // Return the tuple as-is for single parameter expecting the same tuple + return ImmutableArray.Create(type); + } + isTuples = true; - return ImmutableArray.CreateRange(tupleType.TupleElements.Select(x => x.Type)); + + // Create a list to build the unwrapped types + var unwrappedTypes = new List(); + var tupleElements = tupleType.TupleElements; + var paramIndex = 0; + + // Iterate through tuple elements and test parameters together + for (var i = 0; i < tupleElements.Length && paramIndex < testParameterTypes.Length; i++) + { + var tupleElementType = tupleElements[i].Type; + var testParamType = testParameterTypes[paramIndex]; + + // If the test parameter expects a tuple and the tuple element is a tuple, + // keep it as-is instead of unwrapping further + if (testParamType is INamedTypeSymbol { IsTupleType: true } && + tupleElementType is INamedTypeSymbol { IsTupleType: true }) + { + unwrappedTypes.Add(tupleElementType); + paramIndex++; + } + // If the tuple element is not a tuple, or the test param doesn't expect a tuple, + // add it normally + else + { + unwrappedTypes.Add(tupleElementType); + paramIndex++; + } + } + + return ImmutableArray.CreateRange(unwrappedTypes); } if (testParameterTypes.Length == 1 diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 6c3a6f96c1..644bac2b9e 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -1633,8 +1633,59 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet { writer.AppendLine("var context = global::TUnit.Core.TestContext.Current;"); } - - if (parametersFromArgs.Length == 0) + + // Special case: Single tuple parameter + // If we have exactly one parameter that's a tuple type, we need to handle it specially + // In source-generated mode, tuples are always unwrapped into their elements + if (parametersFromArgs.Length == 1 && parametersFromArgs[0].Type is INamedTypeSymbol { IsTupleType: true } tupleType) + { + writer.AppendLine("// Special handling for single tuple parameter"); + writer.AppendLine($"if (args.Length == {tupleType.TupleElements.Length})"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine("// Arguments are unwrapped tuple elements, reconstruct the tuple"); + + // Build tuple reconstruction + var tupleConstruction = $"({string.Join(", ", tupleType.TupleElements.Select((_, i) => $"({tupleType.TupleElements[i].Type.GloballyQualified()})args[{i}]"))})"; + + var methodCallReconstructed = hasCancellationToken + ? $"typedInstance.{methodName}({tupleConstruction}, context?.CancellationToken ?? System.Threading.CancellationToken.None)" + : $"typedInstance.{methodName}({tupleConstruction})"; + if (isAsync) + { + writer.AppendLine($"await {methodCallReconstructed};"); + } + else + { + writer.AppendLine($"{methodCallReconstructed};"); + } + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else if (args.Length == 1 && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0]))"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine("// Rare case: tuple is wrapped as a single argument"); + var methodCallDirect = hasCancellationToken + ? $"typedInstance.{methodName}(({tupleType.GloballyQualified()})args[0], context?.CancellationToken ?? System.Threading.CancellationToken.None)" + : $"typedInstance.{methodName}(({tupleType.GloballyQualified()})args[0])"; + if (isAsync) + { + writer.AppendLine($"await {methodCallDirect};"); + } + else + { + writer.AppendLine($"{methodCallDirect};"); + } + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {tupleType.TupleElements.Length} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");"); + writer.Unindent(); + writer.AppendLine("}"); + } + else if (parametersFromArgs.Length == 0) { var methodCall = hasCancellationToken ? $"typedInstance.{methodName}(context?.CancellationToken ?? System.Threading.CancellationToken.None)" @@ -1730,8 +1781,61 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet { writer.AppendLine("var context = global::TUnit.Core.TestContext.Current;"); } - - if (parametersFromArgs.Length == 0) + + // Special case: Single tuple parameter (same as in TestInvoker) + // If we have exactly one parameter that's a tuple type, we need to handle it specially + // In source-generated mode, tuples are always unwrapped into their elements + if (parametersFromArgs.Length == 1 && parametersFromArgs[0].Type is INamedTypeSymbol { IsTupleType: true } singleTupleParam) + { + writer.AppendLine("// Special handling for single tuple parameter"); + writer.AppendLine($"if (args.Length == {singleTupleParam.TupleElements.Length})"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine("// Arguments are unwrapped tuple elements, reconstruct the tuple"); + + // Build tuple reconstruction with proper casting + var tupleElements = singleTupleParam.TupleElements.Select((elem, i) => + $"TUnit.Core.Helpers.CastHelper.Cast<{elem.Type.GloballyQualified()}>(args[{i}])").ToList(); + var tupleConstruction = $"({string.Join(", ", tupleElements)})"; + + var methodCallReconstructed = hasCancellationToken + ? $"instance.{methodName}({tupleConstruction}, cancellationToken)" + : $"instance.{methodName}({tupleConstruction})"; + if (isAsync) + { + writer.AppendLine($"await {methodCallReconstructed};"); + } + else + { + writer.AppendLine($"{methodCallReconstructed};"); + } + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else if (args.Length == 1 && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0]))"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine("// Rare case: tuple is wrapped as a single argument"); + var methodCallDirect = hasCancellationToken + ? $"instance.{methodName}(TUnit.Core.Helpers.CastHelper.Cast<{singleTupleParam.GloballyQualified()}>(args[0]), cancellationToken)" + : $"instance.{methodName}(TUnit.Core.Helpers.CastHelper.Cast<{singleTupleParam.GloballyQualified()}>(args[0]))"; + if (isAsync) + { + writer.AppendLine($"await {methodCallDirect};"); + } + else + { + writer.AppendLine($"{methodCallDirect};"); + } + writer.Unindent(); + writer.AppendLine("}"); + writer.AppendLine("else"); + writer.AppendLine("{"); + writer.Indent(); + writer.AppendLine($"throw new global::System.ArgumentException($\"Expected {singleTupleParam.TupleElements.Length} unwrapped elements or 1 wrapped tuple, but got {{args.Length}} arguments\");"); + writer.Unindent(); + writer.AppendLine("}"); + } + else if (parametersFromArgs.Length == 0) { var typedMethodCall = hasCancellationToken ? $"instance.{methodName}(cancellationToken)" diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 758655cc41..bd355befa0 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using TUnit.Core.Enums; @@ -125,7 +126,8 @@ public MethodDataSourceAttribute( hasAnyItems = true; yield return async () => { - return await Task.FromResult(item.ToObjectArray()); + var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray(); + return await Task.FromResult(item.ToObjectArrayWithTypes(paramTypes)); }; } @@ -149,7 +151,8 @@ public MethodDataSourceAttribute( hasAnyItems = true; yield return async () => { - return await Task.FromResult(item.ToObjectArray()); + var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray(); + return await Task.FromResult(item.ToObjectArrayWithTypes(paramTypes)); }; } @@ -163,7 +166,8 @@ public MethodDataSourceAttribute( { yield return async () => { - return await Task.FromResult(taskResult.ToObjectArray()); + var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray(); + return await Task.FromResult(taskResult.ToObjectArrayWithTypes(paramTypes)); }; } } @@ -175,7 +179,8 @@ public MethodDataSourceAttribute( foreach (var item in enumerable) { hasAnyItems = true; - yield return () => Task.FromResult(item.ToObjectArray()); + var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray(); + yield return () => Task.FromResult(item.ToObjectArrayWithTypes(paramTypes)); } // If the enumerable was empty, yield one empty result like NoDataSource does @@ -186,9 +191,10 @@ public MethodDataSourceAttribute( } else { + var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray(); yield return async () => { - return await Task.FromResult(methodResult.ToObjectArray()); + return await Task.FromResult(methodResult.ToObjectArrayWithTypes(paramTypes)); }; } } @@ -204,8 +210,26 @@ private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAcc private static async IAsyncEnumerable ConvertToAsyncEnumerable(object asyncEnumerable, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var type = asyncEnumerable.GetType(); - var enumeratorMethod = type.GetMethod("GetAsyncEnumerator"); - var enumerator = enumeratorMethod!.Invoke(asyncEnumerable, [cancellationToken]); + + // Find the IAsyncEnumerable interface + var asyncEnumerableInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)); + + if (asyncEnumerableInterface is null) + { + throw new InvalidOperationException($"Type {type.Name} does not implement IAsyncEnumerable"); + } + + // Get the GetAsyncEnumerator method from the interface + var enumeratorMethod = asyncEnumerableInterface.GetMethod("GetAsyncEnumerator"); + + if (enumeratorMethod is null) + { + throw new InvalidOperationException($"Could not find GetAsyncEnumerator method on interface {asyncEnumerableInterface.Name}"); + } + + var enumerator = enumeratorMethod.Invoke(asyncEnumerable, [cancellationToken]); var moveNextMethod = enumerator!.GetType().GetMethod("MoveNextAsync"); var currentProperty = enumerator.GetType().GetProperty("Current"); diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index bc14402554..d27b56554c 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -370,6 +371,136 @@ public static async Task InitializeDataSourcePropertiesAsync(object? instance, M // Only arrays and tuples are expanded (handled above) return [item]; } + + /// + /// Converts an item to an object array, considering the expected parameter types. + /// This version handles nested tuples correctly by checking if parameters expect tuples. + /// + public static object?[] ToObjectArrayWithTypes(this object? item, Type[]? expectedTypes) + { + item = InvokeIfFunc(item); + + if (item is null) + { + return [ null ]; + } + + // Check if it's specifically object?[] (not other array types like string[]) + if(item is object?[] array && item.GetType().GetElementType() == typeof(object)) + { + return array; + } + + // Don't treat strings as character arrays + if (item is string) + { + return [item]; + } + + // Check if it's any other kind of array (string[], int[], etc.) + if (item is Array) + { + return [item]; + } + + // Check tuples before IEnumerable because tuples implement IEnumerable + // but need special unwrapping logic + if (IsTuple(item)) + { + // If we have expected types, handle nested tuples intelligently + if (expectedTypes != null && expectedTypes.Length > 0) + { + // Special case: If there's a single parameter that expects a tuple type, + // and the item is a tuple, don't unwrap + if (expectedTypes.Length == 1 && IsTupleType(expectedTypes[0])) + { + return [item]; + } + + return UnwrapTupleWithTypes(item, expectedTypes); + } + // Fall back to default unwrapping if no type info + return UnwrapTupleAot(item); + } + + // Don't expand IEnumerable - test methods expect the IEnumerable itself as a parameter + return [item]; + } + + /// + /// Unwraps a tuple considering the expected parameter types. + /// Preserves nested tuples when parameters expect tuple types. + /// + private static object?[] UnwrapTupleWithTypes(object? value, Type[] expectedTypes) + { + if (value == null) + { + return [null]; + } + +#if NET5_0_OR_GREATER || NETCOREAPP3_0_OR_GREATER + // Try to use ITuple interface first for any ValueTuple type + if (value is ITuple tuple) + { + var result = new List(); + var typeIndex = 0; + + for (var i = 0; i < tuple.Length && typeIndex < expectedTypes.Length; i++) + { + var element = tuple[i]; + var expectedType = expectedTypes[typeIndex]; + + // Check if the expected type is a tuple type + if (IsTupleType(expectedType) && IsTuple(element)) + { + // Keep nested tuple as-is + result.Add(element); + typeIndex++; + } + else + { + // Add element normally + result.Add(element); + typeIndex++; + } + } + + return result.ToArray(); + } +#endif + + // Fallback to default unwrapping + return UnwrapTupleAot(value); + } + + /// + /// Checks if a Type represents a tuple type. + /// + private static bool IsTupleType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + var genericType = type.GetGenericTypeDefinition(); + return genericType == typeof(ValueTuple<>) || + genericType == typeof(ValueTuple<,>) || + genericType == typeof(ValueTuple<,,>) || + genericType == typeof(ValueTuple<,,,>) || + genericType == typeof(ValueTuple<,,,,>) || + genericType == typeof(ValueTuple<,,,,,>) || + genericType == typeof(ValueTuple<,,,,,,>) || + genericType == typeof(ValueTuple<,,,,,,,>) || + genericType == typeof(Tuple<>) || + genericType == typeof(Tuple<,>) || + genericType == typeof(Tuple<,,>) || + genericType == typeof(Tuple<,,,>) || + genericType == typeof(Tuple<,,,,>) || + genericType == typeof(Tuple<,,,,,>) || + genericType == typeof(Tuple<,,,,,,>) || + genericType == typeof(Tuple<,,,,,,,>); + } public static bool IsTuple(object? obj) { diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 874d2b8835..0b0d5bfa08 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -232,7 +232,7 @@ public async Task> BuildTestsFromMetadataAsy }; classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); - var methodData = DataUnwrapper.Unwrap(await methodDataFactory() ?? []); + var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); // For concrete generic instantiations, check if the data is compatible with the expected types if (metadata.GenericMethodTypeArguments is { Length: > 0 }) @@ -1243,7 +1243,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( { var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); - var methodData = DataUnwrapper.Unwrap(await methodDataFactory() ?? []); + var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); // Check data compatibility for generic methods if (metadata.GenericMethodTypeArguments is { Length: > 0 }) diff --git a/TUnit.Engine/Helpers/DataUnwrapper.cs b/TUnit.Engine/Helpers/DataUnwrapper.cs index b5b7c58a17..9f985203b7 100644 --- a/TUnit.Engine/Helpers/DataUnwrapper.cs +++ b/TUnit.Engine/Helpers/DataUnwrapper.cs @@ -1,4 +1,7 @@ -using TUnit.Core.Helpers; +using System; +using System.Linq; +using TUnit.Core; +using TUnit.Core.Helpers; namespace TUnit.Engine.Helpers; @@ -13,4 +16,58 @@ internal class DataUnwrapper return values; } + + public static object?[] UnwrapWithTypes(object?[] values, ParameterMetadata[]? expectedParameters) + { + // If no parameter information, fall back to default behavior + if (expectedParameters == null || expectedParameters.Length == 0) + { + return Unwrap(values); + } + + // Special case: If we have a single value that's a tuple, and a single parameter that expects a tuple, + // don't unwrap it + if (values.Length == 1 && + expectedParameters.Length == 1 && + DataSourceHelpers.IsTuple(values[0]) && + IsTupleType(expectedParameters[0].Type)) + { + return values; + } + + // Otherwise use the default unwrapping + if(values.Length == 1 && DataSourceHelpers.IsTuple(values[0])) + { + var paramTypes = expectedParameters.Select(p => p.Type).ToArray(); + return values[0].ToObjectArrayWithTypes(paramTypes); + } + + return values; + } + + private static bool IsTupleType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + var genericType = type.GetGenericTypeDefinition(); + return genericType == typeof(ValueTuple<>) || + genericType == typeof(ValueTuple<,>) || + genericType == typeof(ValueTuple<,,>) || + genericType == typeof(ValueTuple<,,,>) || + genericType == typeof(ValueTuple<,,,,>) || + genericType == typeof(ValueTuple<,,,,,>) || + genericType == typeof(ValueTuple<,,,,,,>) || + genericType == typeof(ValueTuple<,,,,,,,>) || + genericType == typeof(Tuple<>) || + genericType == typeof(Tuple<,>) || + genericType == typeof(Tuple<,,>) || + genericType == typeof(Tuple<,,,>) || + genericType == typeof(Tuple<,,,,>) || + genericType == typeof(Tuple<,,,,,>) || + genericType == typeof(Tuple<,,,,,,>) || + genericType == typeof(Tuple<,,,,,,,>); + } } diff --git a/TUnit.TestProject/NestedTupleDataSourceTests.cs b/TUnit.TestProject/NestedTupleDataSourceTests.cs new file mode 100644 index 0000000000..4f31d9b19e --- /dev/null +++ b/TUnit.TestProject/NestedTupleDataSourceTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class NestedTupleDataSourceTests +{ + // Test 1: Simple nested tuple - (int, (int, int)) unwrapped to int, (int, int) + [Test] + [MethodDataSource(nameof(NestedTupleData))] + public async Task NestedTuple_SeparateParams(int value1, (int, int) value2) + { + await Assert.That(value1).IsGreaterThanOrEqualTo(1); + await Assert.That(value2.Item1).IsGreaterThanOrEqualTo(2); + await Assert.That(value2.Item2).IsGreaterThanOrEqualTo(3); + } + + // Test 2: Single parameter receiving the full nested tuple + [Test] + [MethodDataSource(nameof(NestedTupleData))] + public async Task NestedTuple_SingleParam((int, (int, int)) value) + { + await Assert.That(value.Item1).IsGreaterThanOrEqualTo(1); + await Assert.That(value.Item2.Item1).IsGreaterThanOrEqualTo(2); + await Assert.That(value.Item2.Item2).IsGreaterThanOrEqualTo(3); + } + + // Test 3: Deeply nested tuple - ((int, int), (int, int)) unwrapped to two tuple parameters + [Test] + [MethodDataSource(nameof(DoublyNestedTupleData))] + public async Task DoublyNestedTuple_SeparateParams((int, int) value1, (int, int) value2) + { + await Assert.That(value1.Item1).IsGreaterThanOrEqualTo(1); + await Assert.That(value1.Item2).IsGreaterThanOrEqualTo(2); + await Assert.That(value2.Item1).IsGreaterThanOrEqualTo(3); + await Assert.That(value2.Item2).IsGreaterThanOrEqualTo(4); + } + + // Test 4: Triple nested - (int, (int, (int, int))) unwrapped to int, (int, (int, int)) + [Test] + [MethodDataSource(nameof(TripleNestedTupleData))] + public async Task TripleNestedTuple_SeparateParams(int value1, (int, (int, int)) value2) + { + await Assert.That(value1).IsEqualTo(1); + await Assert.That(value2.Item1).IsEqualTo(2); + await Assert.That(value2.Item2.Item1).IsEqualTo(3); + await Assert.That(value2.Item2.Item2).IsEqualTo(4); + } + + // Test 5: Mixed types in nested tuple - (string, (int, bool)) + [Test] + [MethodDataSource(nameof(MixedNestedTupleData))] + public async Task MixedNestedTuple_SeparateParams(string value1, (int, bool) value2) + { + await Assert.That(value1).IsEqualTo("test"); + await Assert.That(value2.Item1).IsEqualTo(42); + await Assert.That(value2.Item2).IsTrue(); + } + + // Test 6: Nested tuple with array - (int[], (string, double)) + [Test] + [MethodDataSource(nameof(ArrayNestedTupleData))] + public async Task ArrayNestedTuple_SeparateParams(int[] value1, (string, double) value2) + { + await Assert.That(value1).HasCount(3); + await Assert.That(value2.Item1).IsEqualTo("array"); + await Assert.That(value2.Item2).IsEqualTo(3.14); + } + + // Test 7: IEnumerable with nested tuples + [Test] + [MethodDataSource(nameof(EnumerableNestedTupleData))] + public async Task EnumerableNestedTuple_SeparateParams(int value1, (int, int) value2) + { + await Assert.That(value1).IsGreaterThan(0); + await Assert.That(value2.Item1).IsGreaterThan(0); + await Assert.That(value2.Item2).IsGreaterThan(0); + } + + // Test 8: Async enumerable with nested tuples + [Test] + [MethodDataSource(nameof(AsyncEnumerableNestedTupleData))] + public async Task AsyncEnumerableNestedTuple_SeparateParams(int value1, (int, int) value2) + { + await Assert.That(value1).IsGreaterThan(0); + await Assert.That(value2.Item1).IsGreaterThan(0); + await Assert.That(value2.Item2).IsGreaterThan(0); + } + + // Data source methods + public static IEnumerable> NestedTupleData() + { + return + [ + () => (1, (2, 3)), + () => (4, (5, 6)), + () => (7, (8, 9)) + ]; + } + + public static IEnumerable> DoublyNestedTupleData() + { + return + [ + () => ((1, 2), (3, 4)), + () => ((5, 6), (7, 8)) + ]; + } + + public static Func<(int, (int, (int, int)))> TripleNestedTupleData() + { + return () => (1, (2, (3, 4))); + } + + public static IEnumerable<(string, (int, bool))> MixedNestedTupleData() + { + yield return ("test", (42, true)); + } + + public static (int[], (string, double)) ArrayNestedTupleData() + { + return ([1, 2, 3], ("array", 3.14)); + } + + public static IEnumerable<(int, (int, int))> EnumerableNestedTupleData() + { + for (var i = 1; i <= 3; i++) + { + yield return (i, (i + 1, i + 2)); + } + } + + public static async IAsyncEnumerable<(int, (int, int))> AsyncEnumerableNestedTupleData() + { + for (var i = 1; i <= 3; i++) + { + await Task.Yield(); + yield return (i, (i + 10, i + 20)); + } + } +} \ No newline at end of file diff --git a/TUnit.TestProject/NestedTupleTest.cs b/TUnit.TestProject/NestedTupleTest.cs new file mode 100644 index 0000000000..f2f08e34fc --- /dev/null +++ b/TUnit.TestProject/NestedTupleTest.cs @@ -0,0 +1,27 @@ +namespace TUnit.TestProject; + +public class NestedTupleTest +{ + // This is what the issue reports - should work but gives TUnit0001 error + [Test] + [MethodDataSource(nameof(Data))] + public void Test_NestedTuple_SeparateParams(int data1, (int, int) data2) + { + Console.WriteLine($"data1: {data1}, data2: {data2}"); + } + + // This is an alternative approach with a single tuple parameter + [Test] + [MethodDataSource(nameof(Data))] + public void Test_NestedTuple_SingleParam((int, (int, int)) data) + { + Console.WriteLine($"data: {data}"); + } + + // The data source method returning nested tuple + public static IEnumerable> Data() => new[] + { + () => (1, (2, 3)), + () => (4, (5, 6)) + }; +} diff --git a/TUnit.TestProject/SimpleTupleTest.cs b/TUnit.TestProject/SimpleTupleTest.cs new file mode 100644 index 0000000000..9152dfac27 --- /dev/null +++ b/TUnit.TestProject/SimpleTupleTest.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class SimpleTupleTest +{ + [Test] + [MethodDataSource(nameof(TupleData))] + public async Task Test_SingleTupleParam((int, int) value) + { + await Assert.That(value.Item1).IsEqualTo(1); + await Assert.That(value.Item2).IsEqualTo(2); + } + + public static IEnumerable> TupleData() + { + return [() => (1, 2)]; + } +} \ No newline at end of file From a1f254ad3e9825e1160d92190a54867f8cf06b8a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 19:41:34 +0100 Subject: [PATCH 2/4] Update public API --- .../Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 1 + 3 files changed, 3 insertions(+) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 3426da1bdd..da0c9931c1 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1945,6 +1945,7 @@ namespace .Helpers public static . ResolveDataSourceForPropertyAsync([.(..None | ..PublicParameterlessConstructor | ..PublicFields | ..NonPublicFields | ..PublicProperties)] containingType, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static . ResolveDataSourcePropertyAsync(object instance, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static object?[] ToObjectArray(this object? item) { } + public static object?[] ToObjectArrayWithTypes(this object? item, []? expectedTypes) { } [return: .(new string[] { "success", "createdInstance"})] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index ecc1c7250d..9c1b6b9d16 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1945,6 +1945,7 @@ namespace .Helpers public static . ResolveDataSourceForPropertyAsync([.(..None | ..PublicParameterlessConstructor | ..PublicFields | ..NonPublicFields | ..PublicProperties)] containingType, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static . ResolveDataSourcePropertyAsync(object instance, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static object?[] ToObjectArray(this object? item) { } + public static object?[] ToObjectArrayWithTypes(this object? item, []? expectedTypes) { } [return: .(new string[] { "success", "createdInstance"})] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index ff5dcf7e9b..48dcd0d9b7 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1847,6 +1847,7 @@ namespace .Helpers public static . ResolveDataSourceForPropertyAsync( containingType, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static . ResolveDataSourcePropertyAsync(object instance, string propertyName, .MethodMetadata testInformation, string testSessionId) { } public static object?[] ToObjectArray(this object? item) { } + public static object?[] ToObjectArrayWithTypes(this object? item, []? expectedTypes) { } [return: .(new string[] { "success", "createdInstance"})] From 8ab58aec103775e58bd0336ddd24532fc7fe3372 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:08:03 +0100 Subject: [PATCH 3/4] Fix null handling in HasImplicitConversionOrGenericParameter and improve tuple type processing in TestDataAnalyzer --- .../DynamicTestAwaitExpressionSuppressor.cs | 2 +- .../Extensions/CompilationExtensions.cs | 5 +- TUnit.Analyzers/TestDataAnalyzer.cs | 63 ++++++++----------- 3 files changed, 31 insertions(+), 39 deletions(-) diff --git a/TUnit.Analyzers/DynamicTestAwaitExpressionSuppressor.cs b/TUnit.Analyzers/DynamicTestAwaitExpressionSuppressor.cs index 19145cbd2c..906f4b5399 100644 --- a/TUnit.Analyzers/DynamicTestAwaitExpressionSuppressor.cs +++ b/TUnit.Analyzers/DynamicTestAwaitExpressionSuppressor.cs @@ -29,7 +29,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) continue; } - if (namedTypeSymbol.Name == "DynamicTest" || namedTypeSymbol.Name == "DynamicTest") + if (namedTypeSymbol.Name == "DynamicTest") { Suppress(context, diagnostic); } diff --git a/TUnit.Analyzers/Extensions/CompilationExtensions.cs b/TUnit.Analyzers/Extensions/CompilationExtensions.cs index 25de70190a..fba82a536d 100644 --- a/TUnit.Analyzers/Extensions/CompilationExtensions.cs +++ b/TUnit.Analyzers/Extensions/CompilationExtensions.cs @@ -8,7 +8,10 @@ public static class CompilationExtensions public static bool HasImplicitConversionOrGenericParameter(this Compilation compilation, ITypeSymbol? argumentType, ITypeSymbol? parameterType) { - // Handle exact type matches (including when metadata differs) + if (parameterType == null && argumentType != null) + { + return false; + } if (argumentType != null && parameterType != null && argumentType.ToDisplayString() == parameterType.ToDisplayString()) { diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 23b8c9fa68..bf53a25fcd 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -197,6 +197,7 @@ private void Analyze(SymbolAnalysisContext context, var typesToValidate = propertySymbol != null ? ImmutableArray.Create(propertySymbol.Type) : parameters.Select(p => p.Type).ToImmutableArray().WithoutCancellationTokenParameter(); + CheckMethodDataSource(context, attribute, testClassType, typesToValidate, propertySymbol); } @@ -556,6 +557,7 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, testParameterTypes, out var isFunc, out var isTuples); + if (!isFunc && unwrappedTypes.Any(x => x.SpecialType != SpecialType.System_String && x.IsReferenceType)) { @@ -577,6 +579,18 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, // object[] can contain any types - skip compile-time type checking return; } + + if (isTuples && unwrappedTypes.Length != testParameterTypes.Length) + { + context.ReportDiagnostic(Diagnostic.Create( + Rules.WrongArgumentTypeTestData, + attribute.GetLocation(), + string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")), + string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null"))) + ); + return; + } + var conversions = unwrappedTypes.ZipAll(testParameterTypes, (argument, parameter) => { @@ -597,8 +611,8 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, Diagnostic.Create( Rules.WrongArgumentTypeTestData, attribute.GetLocation(), - string.Join(", ", unwrappedTypes), - string.Join(", ", testParameterTypes)) + string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")), + string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null"))) ); return; } @@ -640,8 +654,8 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, context.ReportDiagnostic(Diagnostic.Create( Rules.WrongArgumentTypeTestData, attribute.GetLocation(), - string.Join(", ", unwrappedTypes), - string.Join(", ", testParameterTypes)) + string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")), + string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null"))) ); return; } @@ -676,8 +690,8 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, Diagnostic.Create( Rules.WrongArgumentTypeTestData, attribute.GetLocation(), - string.Join(", ", unwrappedTypes), - string.Join(", ", testParameterTypes)) + string.Join(", ", unwrappedTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null")), + string.Join(", ", testParameterTypes.Select(t => t?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) ?? "null"))) ); } } @@ -764,51 +778,26 @@ private ImmutableArray UnwrapTypes(SymbolAnalysisContext context, type = genericType.TypeArguments[0]; } - // Check for tuple types - but handle them intelligently based on test parameters - if (type is INamedTypeSymbol { IsTupleType: true } tupleType) + if (type is INamedTypeSymbol namedType && namedType.IsTupleType) { - // Special case: If there's a single parameter that expects the same tuple type, - // don't unwrap the tuple at all + var tupleType = namedType; if (testParameterTypes.Length == 1 && testParameterTypes[0] is INamedTypeSymbol paramTupleType && paramTupleType.IsTupleType && SymbolEqualityComparer.Default.Equals(tupleType, testParameterTypes[0])) { - // Return the tuple as-is for single parameter expecting the same tuple return ImmutableArray.Create(type); } isTuples = true; - // Create a list to build the unwrapped types - var unwrappedTypes = new List(); - var tupleElements = tupleType.TupleElements; - var paramIndex = 0; - - // Iterate through tuple elements and test parameters together - for (var i = 0; i < tupleElements.Length && paramIndex < testParameterTypes.Length; i++) + if (testParameterTypes.Length == 1 && + testParameterTypes[0] is INamedTypeSymbol { IsTupleType: true }) { - var tupleElementType = tupleElements[i].Type; - var testParamType = testParameterTypes[paramIndex]; - - // If the test parameter expects a tuple and the tuple element is a tuple, - // keep it as-is instead of unwrapping further - if (testParamType is INamedTypeSymbol { IsTupleType: true } && - tupleElementType is INamedTypeSymbol { IsTupleType: true }) - { - unwrappedTypes.Add(tupleElementType); - paramIndex++; - } - // If the tuple element is not a tuple, or the test param doesn't expect a tuple, - // add it normally - else - { - unwrappedTypes.Add(tupleElementType); - paramIndex++; - } + return ImmutableArray.CreateRange(tupleType.TupleElements.Select(e => e.Type)); } - return ImmutableArray.CreateRange(unwrappedTypes); + return ImmutableArray.CreateRange(tupleType.TupleElements.Select(e => e.Type)); } if (testParameterTypes.Length == 1 From f4fef74be0cbc43f238d7ed1eda5ba3fe692e90a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:37:55 +0100 Subject: [PATCH 4/4] Enhance MethodDataSourceAttribute to support IAsyncEnumerator interface for MoveNextAsync and Current property retrieval --- .../TestData/MethodDataSourceAttribute.cs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index bd355befa0..2cf613de7e 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -231,8 +231,39 @@ private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAcc var enumerator = enumeratorMethod.Invoke(asyncEnumerable, [cancellationToken]); - var moveNextMethod = enumerator!.GetType().GetMethod("MoveNextAsync"); - var currentProperty = enumerator.GetType().GetProperty("Current"); + // The enumerator might not have MoveNextAsync directly on its type, + // we need to look for it on the IAsyncEnumerator interface + var enumeratorType = enumerator!.GetType(); + + // Find MoveNextAsync - first try the type directly, then check interfaces + var moveNextMethod = enumeratorType.GetMethod("MoveNextAsync"); + if (moveNextMethod is null) + { + // Look for it on the IAsyncEnumerator interface + var asyncEnumeratorInterface = enumeratorType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IAsyncEnumerator<>)); + + if (asyncEnumeratorInterface != null) + { + moveNextMethod = asyncEnumeratorInterface.GetMethod("MoveNextAsync"); + } + } + + // Similarly for Current property + var currentProperty = enumeratorType.GetProperty("Current"); + if (currentProperty is null) + { + // Look for it on the IAsyncEnumerator interface + var asyncEnumeratorInterface = enumeratorType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IAsyncEnumerator<>)); + + if (asyncEnumeratorInterface != null) + { + currentProperty = asyncEnumeratorInterface.GetProperty("Current"); + } + } while (true) {