Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add support for nested tuples in data source handling and tests
  • Loading branch information
thomhurst committed Sep 20, 2025
commit 547dec4d80c7941d56adf758279db45a42a63226
57 changes: 42 additions & 15 deletions TUnit.Analyzers/TestDataAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -777,11 +764,51 @@ private ImmutableArray<ITypeSymbol> 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<ITypeSymbol>();
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
Expand Down
112 changes: 108 additions & 4 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@
}

// Find the data source method
var dataSourceMethod = targetType.GetMembers(methodName)

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.

Check warning on line 746 in TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'name' in 'ImmutableArray<ISymbol> INamespaceOrTypeSymbol.GetMembers(string name)'.
.OfType<IMethodSymbol>()
.FirstOrDefault();

Expand Down Expand Up @@ -1633,8 +1633,59 @@
{
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)"
Expand Down Expand Up @@ -1730,8 +1781,61 @@
{
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)"
Expand Down
38 changes: 31 additions & 7 deletions TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using TUnit.Core.Enums;
Expand Down Expand Up @@ -125,7 +126,8 @@ public MethodDataSourceAttribute(
hasAnyItems = true;
yield return async () =>
{
return await Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
};
}

Expand All @@ -149,7 +151,8 @@ public MethodDataSourceAttribute(
hasAnyItems = true;
yield return async () =>
{
return await Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
};
}

Expand All @@ -163,7 +166,8 @@ public MethodDataSourceAttribute(
{
yield return async () =>
{
return await Task.FromResult<object?[]?>(taskResult.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
return await Task.FromResult<object?[]?>(taskResult.ToObjectArrayWithTypes(paramTypes));
};
}
}
Expand All @@ -175,7 +179,8 @@ public MethodDataSourceAttribute(
foreach (var item in enumerable)
{
hasAnyItems = true;
yield return () => Task.FromResult<object?[]?>(item.ToObjectArray());
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
yield return () => Task.FromResult<object?[]?>(item.ToObjectArrayWithTypes(paramTypes));
}

// If the enumerable was empty, yield one empty result like NoDataSource does
Expand All @@ -186,9 +191,10 @@ public MethodDataSourceAttribute(
}
else
{
var paramTypes = dataGeneratorMetadata.TestInformation?.Parameters.Select(p => p.Type).ToArray();
yield return async () =>
{
return await Task.FromResult<object?[]?>(methodResult.ToObjectArray());
return await Task.FromResult<object?[]?>(methodResult.ToObjectArrayWithTypes(paramTypes));
};
}
}
Expand All @@ -204,8 +210,26 @@ private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAcc
private static async IAsyncEnumerable<object?> 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<T> 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<T>");
}

// 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");
Expand Down
Loading
Loading