Skip to content
Merged
8 changes: 8 additions & 0 deletions docs/docs/writing-tests/method-data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ public class MyTestClass
}
```

A tuple row whose type matches the first tuple parameter binds to that parameter whole when
all remaining parameters are optional or `params`. For example, a typed source returning
`IEnumerable<(int, int)>` can supply `(25, 26)` to a test with parameters
`((int, int) pair, int extra = 5)`; `pair` receives `(25, 26)` and `extra` receives `5`.
An `object[]` row containing only that tuple, such as `new object[] { (25, 26) }`, binds
the same way. To supply `extra`, return a nested tuple row such as `((25, 26), 7)` or an
`object[]` row containing both values; those rows spread across the parameters.

## Async Data Sources (AOT-Compatible)

TUnit's AOT mode supports async data sources with `IAsyncEnumerable<T>` for scenarios requiring asynchronous data loading:
Expand Down
32 changes: 27 additions & 5 deletions src/TUnit.Analyzers/TestDataAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,10 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
? CompilationExtensions.InferTypeArguments(unwrappedTypes, testParameterTypes)
: null;

var conversions = unwrappedTypes.ZipAll(testParameterTypes,
var wholeTupleWithOmissions = unwrappedTypes.Length == 1
&& CanBindWholeTupleWithOmissions(context, unwrappedTypes[0], testParameterTypes);

var conversions = unwrappedTypes.ZipAll(wholeTupleWithOmissions ? testParameterTypes.Take(1) : testParameterTypes,
(argument, parameter) =>
{
// Handle exact type matches for property injection where types might have different metadata
Expand Down Expand Up @@ -695,10 +698,10 @@ private void CheckMethodDataSource(SymbolAnalysisContext context,
return;
}

if (testParameterTypes.Length == 1
if (wholeTupleWithOmissions || (testParameterTypes.Length == 1
&& unwrappedTypes.Length == 1
&& ((!isTuples && CanConvertReferenceTuple(unwrappedTypes[0], testParameterTypes[0], context))
|| context.Compilation.CanBindToTestParameter(unwrappedTypes[0], testParameterTypes[0])))
|| context.Compilation.CanBindToTestParameter(unwrappedTypes[0], testParameterTypes[0]))))
{
return;
}
Expand Down Expand Up @@ -940,6 +943,24 @@ private static bool CanAcceptNull(ITypeSymbol parameterType)
|| parameterType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T;
}

// Only a matching tuple row binds whole before omittable parameters. A nested tuple row
// has a different type and continues to spread, including an explicitly supplied optional.
private static bool CanBindWholeTupleWithOmissions(SymbolAnalysisContext context, ITypeSymbol rowType,
ImmutableArray<ITypeSymbol> parameterTypes)
{
if (context.Symbol is not IMethodSymbol method || parameterTypes.Length < 2
|| !SymbolEqualityComparer.Default.Equals(rowType, parameterTypes[0])
|| !TryGetTupleElementTypes(rowType, context.Compilation, out _))
{
return false;
}

// testParameterTypes and this parameter list both exclude engine-injected tokens.
var cancellationToken = context.Compilation.GetTypeByMetadataName("System.Threading.CancellationToken");
return method.Parameters.Where(parameter => !SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationToken))
.Skip(1).All(parameter => parameter.IsOptional || parameter.IsParams);
}

private ImmutableArray<ITypeSymbol> UnwrapTypes(SymbolAnalysisContext context,
IMethodSymbol methodContainingTestData,
ImmutableArray<ITypeSymbol> testParameterTypes,
Expand Down Expand Up @@ -1038,9 +1059,10 @@ private ImmutableArray<ITypeSymbol> UnwrapTypes(SymbolAnalysisContext context,
// is not reconstructed: CanConvertReferenceTuple checks only the outer row here.
if (TryGetTupleElementTypes(type, context.Compilation, out var tupleElements))
{
if (testParameterTypes.Length == 1
if (CanBindWholeTupleWithOmissions(context, type, testParameterTypes)
Comment thread
thomhurst marked this conversation as resolved.
Resolved
|| (testParameterTypes.Length == 1
&& (SymbolEqualityComparer.Default.Equals(type, testParameterTypes[0])
|| CanConvertReferenceTuple(type, testParameterTypes[0], context)))
|| CanConvertReferenceTuple(type, testParameterTypes[0], context))))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
return ImmutableArray.Create(type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,48 @@ private static string GenerateCastExpression(ITypeSymbol type, string expression
var tupleType = type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable
? nullable.TypeArguments[0]
: type;
if (tupleType is not INamedTypeSymbol { IsTupleType: true } tuple)
if (tupleType is not INamedTypeSymbol tuple || !IsTuple(tuple))
{
return $"global::TUnit.Core.Helpers.CastHelper.Cast<{type.GloballyQualified()}>({expression})";
}

// Bind each input once. Repeating a parent's expression in its children makes output
// repeat its indexing chain at every level; switch inputs avoid that duplication.
// Bind each input once and reconstruct retained boxes as well as incompatible tuples.
// IsTuple excludes ordinary lists so they keep conversion-operator behavior.
var value = $"__tupleValue{depth.ToString(CultureInfo.InvariantCulture)}";
var input = $"__tupleInput{depth.ToString(CultureInfo.InvariantCulture)}";
var fallback = $"__tupleFallback{depth.ToString(CultureInfo.InvariantCulture)}";
var elements = tuple.TupleElements.Select((element, index) => GenerateCastExpression(element.Type,
$"{input}[{index.ToString(CultureInfo.InvariantCulture)}]", depth + 1));
var construction = tuple.TupleElements.Length == 1
? $"new {tuple.GloballyQualified()}({string.Join(", ", elements)})"
: $"({string.Join(", ", elements)})";
var index = 0;
var construction = ConstructTuple(tuple, input, ref index, depth);
// Nullable tuples box as their underlying tuple, or null. The fallback preserves null.
return $"({expression} switch {{ global::System.Runtime.CompilerServices.ITuple {{ Length: {tuple.TupleElements.Length.ToString(CultureInfo.InvariantCulture)} }} {input} when {input} is not {tuple.GloballyQualified()} => ({type.GloballyQualified()}){construction}, var {fallback} => global::TUnit.Core.Helpers.CastHelper.Cast<{type.GloballyQualified()}>({fallback}) }})";
return $"({expression} switch {{ var {value} when {value} is not {tuple.GloballyQualified()} && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple({value}) && global::TUnit.Core.Helpers.DataSourceHelpers.UnwrapTupleAot({value}) is {{ Length: {index.ToString(CultureInfo.InvariantCulture)} }} {input} => ({type.GloballyQualified()}){construction}, var {fallback} => global::TUnit.Core.Helpers.CastHelper.Cast<{type.GloballyQualified()}>({fallback}) }})";
}

private static bool IsTuple(INamedTypeSymbol type)
=> type.IsTupleType || type is { Name: "Tuple", IsGenericType: true, TypeArguments.Length: >= 1 and <= 8,
ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } };

private static string ConstructTuple(INamedTypeSymbol tuple, string input, ref int index, int depth)
{
var elements = new List<string>();
if (tuple.IsTupleType)
{
foreach (var element in tuple.TupleElements)
{
elements.Add(GenerateCastExpression(element.Type, $"{input}[{(index++).ToString(CultureInfo.InvariantCulture)}]", depth + 1));
}
return tuple.TupleElements.Length == 1
? $"new {tuple.GloballyQualified()}({string.Join(", ", elements)})"
: $"({string.Join(", ", elements)})";
}

for (var i = 0; i < tuple.TypeArguments.Length; i++)
{
var element = tuple.TypeArguments[i];
elements.Add(i == 7 && element is INamedTypeSymbol rest && IsTuple(rest)
? ConstructTuple(rest, input, ref index, depth)
: GenerateCastExpression(element, $"{input}[{(index++).ToString(CultureInfo.InvariantCulture)}]", depth + 1));
}
return $"new {tuple.GloballyQualified()}({string.Join(", ", elements)})";
}

/// <summary>
Expand Down
56 changes: 37 additions & 19 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
private const string InstanceMethodDataSourceAttributeName = "global::TUnit.Core.InstanceMethodDataSourceAttribute";

private static readonly DiagnosticDescriptor GenerationErrorDescriptor = new(
GeneratorDiagnosticIds.TestMetadataGenerationFailed,

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

View workflow job for this annotation

GitHub Actions / integration-tests

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

View workflow job for this annotation

GitHub Actions / modularpipeline (tr-TR)

'TUNIT0999' kuralını içeren çözümleyici projesi için çözümleyici yayını izlemeyi etkinleştir (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

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

View workflow job for this annotation

GitHub Actions / modularpipeline (sv-SE)

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

View workflow job for this annotation

GitHub Actions / doc-snippets

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

"Source Generation Error",
"Failed to generate test metadata for {0}.{1}: {2}",
"TUnit",
Expand Down Expand Up @@ -2493,7 +2493,9 @@
}

if (TryGetClosedParametersForSharedBinder(testMethod, entry) is { } closedParameters
&& HasSingleElementTupleParameter(closedParameters))
&& closedParameters.All(parameter => !CodeGenerationHelpers.ContainsTypeParameter(parameter.Type))
&& closedParameters.Any(parameter => parameter.Type is INamedTypeSymbol tuple
&& (tuple.IsTupleType || IsReferenceTuple(tuple))))
{
// The factories and engine bind using ParameterMetadata.Type, so close that metadata
// as well as the invoker. Keep attributes and lazy reflection access from the original.
Expand Down Expand Up @@ -4240,32 +4242,18 @@

// Emit tuple element conversions statically so nested Item tuples remain AOT-safe.
private static string CastTupleElement(ITypeSymbol type, string value)
{
if (type is INamedTypeSymbol { IsTupleType: true } tuple)
{
var elements = tuple.TupleElements.Select((element, index) => CastTupleElement(element.Type,
$"global::TUnit.Core.Helpers.DataSourceHelpers.UnwrapTupleAot({value})[{index.ToString(CultureInfo.InvariantCulture)}]"));
var construction = tuple.TupleElements.Length == 1
? $"new {type.GloballyQualified()}({string.Join(", ", elements)})"
: $"({string.Join(", ", elements)})";
// Only tuples can be recursively unpacked. Keep CastHelper's null-to-default
// and custom conversion behavior for other values.
return $"(global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple({value}) ? {construction} : global::TUnit.Core.Helpers.CastHelper.Cast<{type.GloballyQualified()}>({value}))";
}

return $"global::TUnit.Core.Helpers.CastHelper.Cast<{type.GloballyQualified()}>({value})";
}
=> TupleArgumentHelper.GenerateCastExpression(type, value);

// Rebuild flattened System.Tuple rows, recursively constructing Rest after the seventh element.
private static string ConstructReferenceTuple(INamedTypeSymbol type, ref int elementIndex)
private static string ConstructReferenceTuple(INamedTypeSymbol type, ref int elementIndex, string argumentsArray = "args")
{
var arguments = new List<string>();
for (var i = 0; i < type.TypeArguments.Length; i++)
{
var element = type.TypeArguments[i];
arguments.Add(i == 7 && element is INamedTypeSymbol rest && IsReferenceTuple(rest)
? ConstructReferenceTuple(rest, ref elementIndex)
: CastTupleElement(element, $"args[{(elementIndex++).ToString(CultureInfo.InvariantCulture)}]"));
? ConstructReferenceTuple(rest, ref elementIndex, argumentsArray)
: CastTupleElement(element, $"{argumentsArray}[{(elementIndex++).ToString(CultureInfo.InvariantCulture)}]"));
}

return $"new {type.GloballyQualified()}({string.Join(", ", arguments)})";
Expand Down Expand Up @@ -4311,6 +4299,36 @@
writer.AppendLine("var context = global::TUnit.Core.TestContext.Current;");
}

// Mutable elements retain their boxes inside one tuple argument until initialization finishes.
// Reconstruct only at invocation, before the ordinary optional/params binder consumes the row.
if (parametersFromArgs.Length > 1
&& parametersFromArgs.Skip(1).All(parameter => parameter.IsOptional || parameter.IsParams)
&& parametersFromArgs[0].Type is INamedTypeSymbol firstTuple
&& (firstTuple.IsTupleType || IsReferenceTuple(firstTuple)))
{
writer.AppendLine("if (args.Length == 1 && args[0] is global::System.Collections.Generic.IReadOnlyList<object> && global::TUnit.Core.Helpers.DataSourceHelpers.IsTuple(args[0]))");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("var __tupleElements = global::TUnit.Core.Helpers.DataSourceHelpers.UnwrapTupleAot(args[0]);");
string construction;
if (firstTuple.IsTupleType)
{
var elements = firstTuple.TupleElements.Select((element, index) =>
TupleArgumentHelper.GenerateCastExpression(element.Type, $"__tupleElements[{index.ToString(CultureInfo.InvariantCulture)}]"));
construction = firstTuple.TupleElements.Length == 1
? $"new {firstTuple.GloballyQualified()}({string.Join(", ", elements)})"
: $"({string.Join(", ", elements)})";
}
else
{
var index = 0;
construction = ConstructReferenceTuple(firstTuple, ref index, "__tupleElements");
}
writer.AppendLine($"args = new object[] {{ {construction} }};");
writer.Unindent();
writer.AppendLine("}");
}

// 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
// Generated factories unwrap tuples; instance sources can preserve the whole tuple.
Expand Down Expand Up @@ -6626,7 +6644,7 @@
{
if (!inferredTypes.ContainsKey(name))
{
inferredTypes[name] = compilation.GetSpecialType(SpecialType.System_Object);

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

View workflow job for this annotation

GitHub Actions / integration-tests

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (tr-TR)

Olası bir null başvurunun başvurma işlemi.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (sv-SE)

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / doc-snippets

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / doc-snippets

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / AOT (windows-latest)

Dereference of a possibly null reference.

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

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Dereference of a possibly null reference.
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,15 @@ private static void ProcessRootObject(
return;
}

if (item is Helpers.TupleArgument retained)
{
foreach (var element in retained.Elements)
{
ProcessRootObject(element, tryAdd, onRootObjectAdded, cancellationToken, tupleDepth + 1);
}
return;
}

#if NET
if (item is System.Runtime.CompilerServices.ITuple tuple)
{
Expand Down
4 changes: 2 additions & 2 deletions src/TUnit.Core/Helpers/ArgumentFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ private static string FormatDefault(object? o, Type? parameterType)
}
}

if (TupleHelper.IsTupleType(type))
if (o is TupleArgument || TupleHelper.IsTupleType(type))
{
return FormatTuple(o);
}
Expand Down Expand Up @@ -175,7 +175,7 @@ private static bool IsNumeric(object o, Type type)

private static string FormatTuple(object tuple)
{
var elements = TupleHelper.UnwrapTuple(tuple);
var elements = DataSourceHelpers.UnwrapTupleAot(tuple);

if (elements.Length == 0)
return "()";
Expand Down
20 changes: 20 additions & 0 deletions src/TUnit.Core/Helpers/CastHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public static class CastHelper
Justification = "Cast<T> is called from source-generated code that handles parseable types at compile time. The runtime TryParsableConvert fallback is only used in non-AOT scenarios.")]
public static T? Cast<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(object? value)
{
if (value is TupleArgument retained)
{
value = retained.Materialize();
}

if (value is T t)
{
return t;
Expand All @@ -45,6 +50,11 @@ public static class CastHelper
/// </remarks>
public static T[] ToTrailingArray<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(object value)
{
if (value is TupleArgument retained)
{
value = retained.Materialize();
}

if (value is T element)
{
return [element];
Expand Down Expand Up @@ -110,6 +120,11 @@ internal static bool TryConvertTrailingArray(Type arrayType, object value, out o
| DynamicallyAccessedMemberTypes.PublicMethods)] Type type,
object? value)
{
if (value is TupleArgument retained)
{
value = retained.Materialize();
}

if (value is null || value.GetType().IsAssignableTo(type))
{
return value;
Expand All @@ -130,6 +145,11 @@ internal static bool TryConvertTrailingArray(Type arrayType, object value, out o
[UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")]
public static object? Cast([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, object? value)
{
if (value is TupleArgument retained)
{
value = retained.Materialize();
}

// Fast path: handle null
if (value is null)
{
Expand Down
Loading
Loading