diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
index 9dd2aa3d5b..3983091e31 100644
--- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
@@ -483,9 +483,15 @@ public class InvocationReplacement : ConversionTarget
public class TheoryDataConversion : ConversionTarget
{
///
- /// The element type(s) from TheoryData<T> (e.g., "TimeSpan" from TheoryData<TimeSpan>)
+ /// The individual type arguments from TheoryData (e.g., ["TimeSpan"] or ["string", "int"]).
///
- public required string ElementType { get; init; }
+ public required IReadOnlyList ElementTypes { get; init; }
+
+ ///
+ /// Whether the TheoryData has multiple type arguments (e.g., TheoryData<string, int>).
+ /// When true, initializer expressions { val1, val2 } must be converted to tuple expressions (val1, val2).
+ ///
+ public bool IsMultiType => ElementTypes.Count > 1;
///
/// Annotation for the GenericName (TheoryData<T>) type syntax to convert to IEnumerable<T>
diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs
index d9bb94f30c..2d0d1488ad 100644
--- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs
@@ -229,9 +229,12 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root)
if (objectCreation?.Initializer != null)
{
- // Build array type: T[]
+ // Build the element type: T for single, (T1, T2) for multi
+ var elementTypeSyntax = BuildElementType(conversion.ElementTypes);
+
+ // Build array type: T[] or (T1, T2)[]
var arrayType = SyntaxFactory.ArrayType(
- SyntaxFactory.ParseTypeName(conversion.ElementType),
+ elementTypeSyntax,
SyntaxFactory.SingletonList(
SyntaxFactory.ArrayRankSpecifier(
SyntaxFactory.SingletonSeparatedList(
@@ -241,21 +244,40 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root)
)
).WithoutTrailingTrivia();
- // Get the open brace token and ensure it has proper newline trivia
+ // Get the open brace token and ensure it has proper newline trivia.
+ // When the brace is on a separate line (e.g., new Type\n{), the newline
+ // may be trailing trivia of the preceding token, not leading trivia of {.
+ // Preserve existing whitespace indentation when adding the newline.
var openBrace = objectCreation.Initializer.OpenBraceToken;
if (!openBrace.LeadingTrivia.Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia)))
{
- // Add newline and proper indentation before the brace
+ var existingWhitespace = openBrace.LeadingTrivia
+ .LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia));
+
+ var whitespace = existingWhitespace.IsKind(SyntaxKind.WhitespaceTrivia)
+ ? existingWhitespace
+ : SyntaxFactory.Whitespace(" ");
+
openBrace = openBrace.WithLeadingTrivia(
SyntaxFactory.EndOfLine("\n"),
- SyntaxFactory.Whitespace(" "));
+ whitespace);
+ }
+
+ // For multi-type TheoryData, convert complex initializer expressions
+ // { val1, val2 } to tuple expressions (val1, val2)
+ var expressions = objectCreation.Initializer.Expressions;
+ if (conversion.IsMultiType)
+ {
+ expressions = SyntaxFactory.SeparatedList(
+ expressions.Select(expr => ConvertToTupleExpression(expr)),
+ expressions.GetSeparators());
}
// Create array initializer from the collection initializer
var newInitializer = SyntaxFactory.InitializerExpression(
SyntaxKind.ArrayInitializerExpression,
openBrace,
- objectCreation.Initializer.Expressions,
+ expressions,
objectCreation.Initializer.CloseBraceToken);
// Build the array creation expression
@@ -273,7 +295,7 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root)
}
}
- // Then, transform the type declaration from TheoryData to IEnumerable
+ // Then, transform the type declaration from TheoryData to IEnumerable or IEnumerable<(T1, T2)>
if (conversion.TypeAnnotation != null)
{
var genericName = currentRoot.DescendantNodes()
@@ -282,10 +304,13 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root)
if (genericName != null)
{
+ var elementTypeSyntax = BuildElementType(conversion.ElementTypes);
+ var typeArgList = SyntaxFactory.TypeArgumentList(
+ SyntaxFactory.SingletonSeparatedList(elementTypeSyntax));
+
var enumerableType = SyntaxFactory.GenericName(
SyntaxFactory.Identifier("IEnumerable"),
- SyntaxFactory.TypeArgumentList(
- SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments)))
+ typeArgList)
.WithLeadingTrivia(genericName.GetLeadingTrivia())
.WithTrailingTrivia(genericName.GetTrailingTrivia());
@@ -308,6 +333,47 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root)
return currentRoot;
}
+ ///
+ /// Builds the element TypeSyntax from the stored type argument strings.
+ /// Single type: returns the type directly (e.g., "TimeSpan" → TimeSpan).
+ /// Multi type: returns a tuple type (e.g., ["string", "int"] → (string, int)).
+ ///
+ private static TypeSyntax BuildElementType(IReadOnlyList elementTypes)
+ {
+ if (elementTypes.Count == 1)
+ {
+ return SyntaxFactory.ParseTypeName(elementTypes[0]);
+ }
+
+ return SyntaxFactory.TupleType(
+ SyntaxFactory.SeparatedList(
+ elementTypes.Select(t =>
+ SyntaxFactory.TupleElement(SyntaxFactory.ParseTypeName(t)))));
+ }
+
+ ///
+ /// Converts a complex initializer expression { val1, val2 } to a tuple expression (val1, val2).
+ /// For simple expressions (single-value), returns the expression unchanged.
+ ///
+ private static ExpressionSyntax ConvertToTupleExpression(ExpressionSyntax expression)
+ {
+ if (expression is not InitializerExpressionSyntax initializer
+ || !initializer.IsKind(SyntaxKind.ComplexElementInitializerExpression))
+ {
+ return expression;
+ }
+
+ var arguments = initializer.Expressions.Select(
+ expr => SyntaxFactory.Argument(expr.WithoutLeadingTrivia().WithoutTrailingTrivia()));
+
+ var tupleExpression = SyntaxFactory.TupleExpression(
+ SyntaxFactory.SeparatedList(arguments))
+ .WithLeadingTrivia(initializer.GetLeadingTrivia())
+ .WithTrailingTrivia(initializer.GetTrailingTrivia());
+
+ return tupleExpression;
+ }
+
private CompilationUnitSyntax TransformAssertions(CompilationUnitSyntax root)
{
var currentRoot = root;
diff --git a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs
index 9a13310ca0..68c50ac78b 100644
--- a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs
+++ b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs
@@ -1833,11 +1833,11 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax
{
try
{
- // Get the type argument
- var typeArg = originalGeneric.TypeArgumentList.Arguments.FirstOrDefault();
- if (typeArg == null) continue;
+ // Get the type arguments
+ var typeArgs = originalGeneric.TypeArgumentList.Arguments;
+ if (typeArgs.Count == 0) continue;
- var elementType = typeArg.ToString();
+ var elementTypes = typeArgs.Select(t => t.ToString()).ToList();
// Create annotations for both the type and the object creation
var typeAnnotation = new SyntaxAnnotation("TUnitMigration", Guid.NewGuid().ToString());
@@ -1845,7 +1845,7 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax
var conversion = new TheoryDataConversion
{
- ElementType = elementType,
+ ElementTypes = elementTypes,
TypeAnnotation = typeAnnotation,
CreationAnnotation = creationAnnotation,
OriginalText = originalGeneric.ToString()
diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
index dcd7d31399..7b6a603d63 100644
--- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
@@ -563,6 +563,148 @@ public class MyClass
);
}
+ [Test]
+ public async Task TheoryData_MultiType_Is_Flagged()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ {|#0:using System;
+
+ public class MyClass
+ {
+ public static TheoryData MyData
+ {
+ get
+ {
+ var data = new TheoryData
+ {
+ { "a", 1 },
+ { "b", 2 }
+ };
+ return data;
+ }
+ }
+ }|}
+ """,
+ ConfigureXUnitTest,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0)
+ );
+ }
+
+ [Test]
+ public async Task TheoryData_MultiType_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using Xunit;
+
+ public class MyClass
+ {
+ public static readonly TheoryData Items = new()
+ {
+ { "a", 1 },
+ { "b", 2 }
+ };
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+
+ public class MyClass
+ {
+ public static readonly IEnumerable<(string, int)> Items = new (string, int)[]
+ {
+ ("a", 1),
+ ("b", 2)
+ };
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
+ [Test]
+ public async Task TheoryData_MultiType_PropertyGetter_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using Xunit;
+
+ public class MyClass
+ {
+ public static TheoryData MyData
+ {
+ get
+ {
+ var data = new TheoryData
+ {
+ { "a", 1 },
+ { "b", 2 }
+ };
+ return data;
+ }
+ }
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+
+ public class MyClass
+ {
+ public static IEnumerable<(string, int)> MyData
+ {
+ get
+ {
+ var data = new (string, int)[]
+ {
+ ("a", 1),
+ ("b", 2)
+ };
+ return data;
+ }
+ }
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
+ [Test]
+ public async Task TheoryData_ThreeTypes_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using Xunit;
+
+ public class MyClass
+ {
+ public static readonly TheoryData Items = new()
+ {
+ { "a", 1, true },
+ { "b", 2, false }
+ };
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+
+ public class MyClass
+ {
+ public static readonly IEnumerable<(string, int, bool)> Items = new (string, int, bool)[]
+ {
+ ("a", 1, true),
+ ("b", 2, false)
+ };
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
[Test]
public async Task ITestOutputHelper_Is_Flagged()
{