diff --git a/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs
index 77da7b16a6..a5ef3fa98d 100644
--- a/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs
@@ -307,49 +307,100 @@ protected static ExpressionSyntax CreateMessageExpression(
///
/// Checks if the argument at the given index appears to be a comparer (IComparer, IEqualityComparer).
- /// Returns null if the type cannot be determined.
+ /// Uses semantic analysis when available, with syntax-based fallback for resilience across TFMs.
///
- protected bool? IsLikelyComparerArgument(ArgumentSyntax argument)
+ protected bool IsLikelyComparerArgument(ArgumentSyntax argument)
{
- var typeInfo = SemanticModel.GetTypeInfo(argument.Expression);
- if (typeInfo.Type == null || typeInfo.Type.TypeKind == TypeKind.Error)
+ // First, try syntax-based detection for string literals (most common message case)
+ // This is deterministic and consistent across all TFMs
+ if (argument.Expression.IsKind(SyntaxKind.StringLiteralExpression) ||
+ argument.Expression.IsKind(SyntaxKind.InterpolatedStringExpression))
{
- // Type couldn't be resolved - return null to indicate unknown
- return null;
+ return false; // String literals are messages, not comparers
}
- var typeName = typeInfo.Type.ToDisplayString();
-
- // If it's a string type, it's definitely a message, not a comparer
- if (typeInfo.Type.SpecialType == SpecialType.System_String ||
- typeName == "string" || typeName == "System.String")
+ // Try semantic analysis
+ var typeInfo = SemanticModel.GetTypeInfo(argument.Expression);
+ if (typeInfo.Type != null && typeInfo.Type.TypeKind != TypeKind.Error)
{
+ var typeName = typeInfo.Type.ToDisplayString();
+
+ // If it's a string type, it's definitely a message, not a comparer
+ if (typeInfo.Type.SpecialType == SpecialType.System_String ||
+ typeName == "string" || typeName == "System.String")
+ {
+ return false;
+ }
+
+ // Check for IComparer, IComparer, IEqualityComparer, IEqualityComparer
+ if (typeName.Contains("IComparer") || typeName.Contains("IEqualityComparer"))
+ {
+ return true;
+ }
+
+ // Check interfaces - also check for generic interface names like IComparer`1
+ if (typeInfo.Type is INamedTypeSymbol namedType)
+ {
+ if (namedType.AllInterfaces.Any(i =>
+ i.Name.StartsWith("IComparer") ||
+ i.Name.StartsWith("IEqualityComparer")))
+ {
+ return true;
+ }
+ }
+
+ // Also check if the type name itself contains Comparer (for StringComparer, etc.)
+ if (typeName.Contains("Comparer"))
+ {
+ return true;
+ }
+
+ // Semantic analysis resolved to a non-comparer type
return false;
}
- // Check for IComparer, IComparer, IEqualityComparer, IEqualityComparer
- if (typeName.Contains("IComparer") || typeName.Contains("IEqualityComparer"))
+ // Fallback: Syntax-based detection when semantic analysis fails
+ // This ensures consistent behavior across TFMs
+ return IsLikelyComparerArgumentBySyntax(argument.Expression);
+ }
+
+ ///
+ /// Syntax-based fallback for comparer detection. Used when semantic analysis fails.
+ /// Must be deterministic to ensure consistent behavior across TFMs.
+ ///
+ private static bool IsLikelyComparerArgumentBySyntax(ExpressionSyntax expression)
+ {
+ var expressionText = expression.ToString();
+ var lowerExpressionText = expressionText.ToLowerInvariant();
+
+ // Check for variable names or expressions containing "comparer" (case-insensitive)
+ // This catches variable names like 'comparer', 'myComparer', 'stringComparer', etc.
+ if (lowerExpressionText.EndsWith("comparer") ||
+ lowerExpressionText.Contains("comparer.") ||
+ lowerExpressionText.Contains("comparer<") ||
+ lowerExpressionText.Contains("equalitycomparer"))
{
return true;
}
- // Check interfaces - also check for generic interface names like IComparer`1
- if (typeInfo.Type is INamedTypeSymbol namedType)
+ // Check for new SomeComparer() or new SomeComparer() patterns
+ if (expression is ObjectCreationExpressionSyntax objectCreation)
{
- if (namedType.AllInterfaces.Any(i =>
- i.Name.StartsWith("IComparer") ||
- i.Name.StartsWith("IEqualityComparer")))
+ var typeText = objectCreation.Type.ToString().ToLowerInvariant();
+ if (typeText.Contains("comparer"))
{
return true;
}
}
- // Also check if the type name itself contains Comparer (for StringComparer, etc.)
- if (typeName.Contains("Comparer"))
+ // Check for ImplicitObjectCreationExpressionSyntax (new() { ... })
+ // These are ambiguous without semantic info, assume not a comparer
+ if (expression is ImplicitObjectCreationExpressionSyntax)
{
- return true;
+ return false;
}
+ // Default: assume it's a message (conservative - avoids incorrect comparer handling)
return false;
}
@@ -361,19 +412,50 @@ protected static SyntaxTrivia CreateTodoComment(string message)
return SyntaxFactory.Comment($"// TODO: TUnit migration - {message}");
}
+ ///
+ /// Determines if an invocation is a framework assertion method.
+ /// Uses semantic analysis when available, with syntax-based fallback for resilience across TFMs.
+ ///
protected bool IsFrameworkAssertion(InvocationExpressionSyntax invocation)
{
+ // Try semantic analysis first
var symbolInfo = SemanticModel.GetSymbolInfo(invocation);
- var symbol = symbolInfo.Symbol;
+ if (symbolInfo.Symbol is IMethodSymbol methodSymbol)
+ {
+ var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString() ?? "";
+ if (IsFrameworkAssertionNamespace(namespaceName))
+ {
+ return true;
+ }
+ }
- if (symbol is not IMethodSymbol methodSymbol)
+ // Fallback: Syntax-based detection when semantic analysis fails
+ // This ensures consistent behavior across TFMs
+ return IsFrameworkAssertionBySyntax(invocation);
+ }
+
+ ///
+ /// Syntax-based fallback for framework assertion detection. Used when semantic analysis fails.
+ /// Must be deterministic to ensure consistent behavior across TFMs.
+ ///
+ private bool IsFrameworkAssertionBySyntax(InvocationExpressionSyntax invocation)
+ {
+ if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return false;
}
- var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString() ?? "";
- return IsFrameworkAssertionNamespace(namespaceName);
+ var targetType = memberAccess.Expression.ToString();
+ var methodName = memberAccess.Name.Identifier.Text;
+
+ return IsKnownAssertionTypeBySyntax(targetType, methodName);
}
-
+
+ ///
+ /// Checks if the target type and method name match known framework assertion patterns.
+ /// Override in derived classes to provide framework-specific patterns.
+ ///
+ protected abstract bool IsKnownAssertionTypeBySyntax(string targetType, string methodName);
+
protected abstract bool IsFrameworkAssertionNamespace(string namespaceName);
}
\ No newline at end of file
diff --git a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
index 763773508e..02b4b7c411 100644
--- a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
+++ b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
@@ -202,10 +202,16 @@ public MSTestAssertionRewriter(SemanticModel semanticModel) : base(semanticModel
protected override bool IsFrameworkAssertionNamespace(string namespaceName)
{
- return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" ||
+ return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" ||
namespaceName.StartsWith("Microsoft.VisualStudio.TestTools.UnitTesting.");
}
-
+
+ protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName)
+ {
+ // MSTest assertion types that can be detected by syntax
+ return targetType is "Assert" or "CollectionAssert" or "StringAssert" or "FileAssert" or "DirectoryAssert";
+ }
+
protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation)
{
// First try semantic analysis
diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
index 2e7bdf5df6..210021b544 100644
--- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
+++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
@@ -138,6 +138,13 @@ protected override bool IsFrameworkAttribute(string attributeName)
return null;
}
+ // [Platform(Include = "Win")] -> [RunOn(OS.Windows)]
+ // [Platform(Exclude = "Linux")] -> [ExcludeOn(OS.Linux)]
+ if (attributeName == "Platform")
+ {
+ return ConvertPlatformAttribute(attribute);
+ }
+
return base.ConvertAttribute(attribute);
}
@@ -191,6 +198,143 @@ protected override bool IsFrameworkAttribute(string attributeName)
return null;
}
+ private AttributeSyntax? ConvertPlatformAttribute(AttributeSyntax attribute)
+ {
+ // [Platform(Include = "Win")] -> [RunOn(OS.Windows)]
+ // [Platform(Exclude = "Linux")] -> [ExcludeOn(OS.Linux)]
+ // [Platform("Win")] -> [RunOn(OS.Windows)]
+
+ string? includeValue = null;
+ string? excludeValue = null;
+
+ if (attribute.ArgumentList == null || attribute.ArgumentList.Arguments.Count == 0)
+ {
+ return null; // No arguments, remove the attribute
+ }
+
+ foreach (var arg in attribute.ArgumentList.Arguments)
+ {
+ var argName = arg.NameEquals?.Name.Identifier.Text;
+ var value = GetStringLiteralValue(arg.Expression);
+
+ if (argName == "Include" || argName == null)
+ {
+ // Named argument Include= or positional argument (which is Include)
+ includeValue = value;
+ }
+ else if (argName == "Exclude")
+ {
+ excludeValue = value;
+ }
+ }
+
+ // Prefer Include (RunOn) over Exclude (ExcludeOn) if both are present
+ if (!string.IsNullOrEmpty(includeValue))
+ {
+ var osBits = ParsePlatformString(includeValue);
+ if (osBits != null)
+ {
+ return CreateOsAttribute("RunOn", osBits);
+ }
+ }
+ else if (!string.IsNullOrEmpty(excludeValue))
+ {
+ var osBits = ParsePlatformString(excludeValue);
+ if (osBits != null)
+ {
+ return CreateOsAttribute("ExcludeOn", osBits);
+ }
+ }
+
+ // Cannot convert - return null to remove the attribute
+ return null;
+ }
+
+ private static string? GetStringLiteralValue(ExpressionSyntax expression)
+ {
+ return expression switch
+ {
+ LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression)
+ => literal.Token.ValueText,
+ _ => null
+ };
+ }
+
+ private static List? ParsePlatformString(string? platformString)
+ {
+ if (string.IsNullOrEmpty(platformString))
+ {
+ return null;
+ }
+
+ var osNames = new List();
+ var platforms = platformString.Split(',');
+
+ foreach (var platform in platforms)
+ {
+ var trimmed = platform.Trim();
+ var osName = MapNUnitPlatformToTUnitOS(trimmed);
+ if (osName != null && !osNames.Contains(osName))
+ {
+ osNames.Add(osName);
+ }
+ }
+
+ return osNames.Count > 0 ? osNames : null;
+ }
+
+ private static string? MapNUnitPlatformToTUnitOS(string nunitPlatform)
+ {
+ // NUnit platform names: https://docs.nunit.org/articles/nunit/writing-tests/attributes/platform.html
+ return nunitPlatform.ToLowerInvariant() switch
+ {
+ "win" or "win32" or "win32s" or "win32nt" or "win32windows" or "wince" or "windows" => "Windows",
+ "linux" or "unix" => "Linux",
+ "macosx" or "macos" or "osx" or "mac" => "MacOs",
+ _ => null // Unknown platform - cannot convert
+ };
+ }
+
+ private static AttributeSyntax CreateOsAttribute(string attributeName, List osNames)
+ {
+ // Build OS.Windows | OS.Linux | OS.MacOs expression
+ ExpressionSyntax osExpression;
+
+ if (osNames.Count == 1)
+ {
+ // Single OS: OS.Windows
+ osExpression = SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName("OS"),
+ SyntaxFactory.IdentifierName(osNames[0]));
+ }
+ else
+ {
+ // Multiple OSes: OS.Windows | OS.Linux
+ osExpression = SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName("OS"),
+ SyntaxFactory.IdentifierName(osNames[0]));
+
+ for (int i = 1; i < osNames.Count; i++)
+ {
+ osExpression = SyntaxFactory.BinaryExpression(
+ SyntaxKind.BitwiseOrExpression,
+ osExpression,
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName("OS"),
+ SyntaxFactory.IdentifierName(osNames[i])));
+ }
+ }
+
+ return SyntaxFactory.Attribute(
+ SyntaxFactory.IdentifierName(attributeName),
+ SyntaxFactory.AttributeArgumentList(
+ SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.AttributeArgument(osExpression))));
+ }
+
private AttributeSyntax ConvertValuesAttribute(AttributeSyntax attribute)
{
// [Values(1, 2, 3)] -> [Matrix(1, 2, 3)]
@@ -520,7 +664,15 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName)
return (namespaceName == "NUnit.Framework" || namespaceName.StartsWith("NUnit.Framework."))
&& namespaceName != "NUnit.Framework.Legacy";
}
-
+
+ protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName)
+ {
+ // NUnit assertion types that can be detected by syntax
+ // NOTE: ClassicAssert is NOT included because it's in NUnit.Framework.Legacy namespace
+ // and should not be auto-converted. The semantic check excludes it properly.
+ return targetType is "Assert" or "CollectionAssert" or "StringAssert" or "FileAssert" or "DirectoryAssert";
+ }
+
protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation)
{
// Handle FileAssert - check BEFORE IsFrameworkAssertion since FileAssert is a separate class
@@ -1217,7 +1369,7 @@ private ExpressionSyntax ConvertAreEqualWithComparer(SeparatedSyntaxList= 3 && IsLikelyComparerArgument(arguments[2]) == true)
+ if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]))
{
// Add TODO comment and skip the comparer
var result = CreateTUnitAssertion("IsEqualTo", actual, expected);
@@ -1238,7 +1390,7 @@ private ExpressionSyntax ConvertAreNotEqualWithMessage(SeparatedSyntaxList= 3 && IsLikelyComparerArgument(arguments[2]) == true)
+ if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]))
{
var result = CreateTUnitAssertion("IsNotEqualTo", actual, expected);
return result.WithLeadingTrivia(
diff --git a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs
index d93b47ccc2..411ce36b9a 100644
--- a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs
+++ b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs
@@ -64,6 +64,9 @@ protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(Compi
updatedRoot = ConvertTestOutputHelpers(ref compilation, ref syntaxTree, updatedRoot);
UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref updatedRoot);
+ updatedRoot = ConvertRecordException(ref compilation, ref syntaxTree, updatedRoot);
+ UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref updatedRoot);
+
return (CompilationUnitSyntax)updatedRoot;
}
@@ -136,6 +139,244 @@ private static bool IsTestOutputHelperInvocation(Compilation compilation, Invoca
is "global::Xunit.Abstractions.ITestOutputHelper" or "global::Xunit.ITestOutputHelper";
}
+ private static SyntaxNode ConvertRecordException(ref Compilation compilation, ref SyntaxTree syntaxTree, SyntaxNode root)
+ {
+ var currentRoot = root;
+ var compilationValue = compilation;
+
+ // Find local declarations with Record.Exception() or Record.ExceptionAsync()
+ while (currentRoot.DescendantNodes()
+ .OfType()
+ .FirstOrDefault(x => IsRecordExceptionDeclaration(compilationValue, x))
+ is { } localDeclaration)
+ {
+ var variableDeclarator = localDeclaration.Declaration.Variables.First();
+ var variableName = variableDeclarator.Identifier.Text;
+ var invocation = variableDeclarator.Initializer?.Value as InvocationExpressionSyntax;
+
+ if (invocation == null)
+ {
+ break;
+ }
+
+ // Get the action/func argument
+ var actionArg = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (actionArg == null)
+ {
+ break;
+ }
+
+ // Check if this is async (Record.ExceptionAsync)
+ var isAsync = invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
+ memberAccess.Name.Identifier.Text == "ExceptionAsync";
+
+ // Extract the body from the lambda/action
+ var actionBody = ExtractActionBody(actionArg);
+
+ // Create the try-catch replacement
+ // Exception? ex = null;
+ // try { actionBody; } catch (Exception e) { ex = e; }
+ var statements = CreateTryCatchStatements(variableName, actionBody, isAsync, localDeclaration.GetLeadingTrivia());
+
+ // Replace the local declaration with the try-catch statements
+ var parent = localDeclaration.Parent;
+ if (parent is BlockSyntax block)
+ {
+ var index = block.Statements.IndexOf(localDeclaration);
+ var newStatements = block.Statements
+ .RemoveAt(index)
+ .InsertRange(index, statements);
+ var newBlock = block.WithStatements(newStatements);
+ currentRoot = currentRoot.ReplaceNode(block, newBlock);
+ }
+ else
+ {
+ // If not in a block, just replace with the first statement (may not work perfectly)
+ currentRoot = currentRoot.ReplaceNode(localDeclaration, statements.First());
+ }
+
+ UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref currentRoot);
+ compilationValue = compilation;
+ }
+
+ return currentRoot;
+ }
+
+ private static bool IsRecordExceptionDeclaration(Compilation compilation, LocalDeclarationStatementSyntax localDeclaration)
+ {
+ var variableDeclarator = localDeclaration.Declaration.Variables.FirstOrDefault();
+ if (variableDeclarator?.Initializer?.Value is not InvocationExpressionSyntax invocation)
+ {
+ return false;
+ }
+
+ if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
+ {
+ return false;
+ }
+
+ // Check if it's Record.Exception or Record.ExceptionAsync by name first (fast check)
+ if (memberAccess.Expression is not IdentifierNameSyntax { Identifier.Text: "Record" })
+ {
+ return false;
+ }
+
+ if (memberAccess.Name.Identifier.Text is not ("Exception" or "ExceptionAsync"))
+ {
+ return false;
+ }
+
+ // Verify with semantic model
+ var semanticModel = compilation.GetSemanticModel(invocation.SyntaxTree);
+ var symbolInfo = semanticModel.GetSymbolInfo(invocation);
+
+ if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
+ {
+ return false;
+ }
+
+ return methodSymbol.ContainingType?.ToDisplayString(DisplayFormats.FullyQualifiedGenericWithGlobalPrefix)
+ is "global::Xunit.Record" or "global::Xunit.Assert";
+ }
+
+ private static StatementSyntax ExtractActionBody(ExpressionSyntax actionExpression)
+ {
+ // Handle lambda expressions: () => SomeMethod() or () => { statements }
+ if (actionExpression is SimpleLambdaExpressionSyntax simpleLambda)
+ {
+ return ConvertLambdaBodyToStatement(simpleLambda.Body);
+ }
+
+ if (actionExpression is ParenthesizedLambdaExpressionSyntax parenLambda)
+ {
+ return ConvertLambdaBodyToStatement(parenLambda.Body);
+ }
+
+ // Handle method group or direct invocation
+ // For Action delegates, wrap in invocation
+ return SyntaxFactory.ExpressionStatement(
+ SyntaxFactory.InvocationExpression(actionExpression));
+ }
+
+ private static StatementSyntax ConvertLambdaBodyToStatement(CSharpSyntaxNode body)
+ {
+ if (body is BlockSyntax block)
+ {
+ // If it's a single statement block, extract the statement
+ if (block.Statements.Count == 1)
+ {
+ return block.Statements[0];
+ }
+ // Otherwise return the block as-is (will need to be a compound statement)
+ return block;
+ }
+
+ // Handle throw expressions - convert to throw statement
+ if (body is ThrowExpressionSyntax throwExpression)
+ {
+ return SyntaxFactory.ThrowStatement(throwExpression.Expression);
+ }
+
+ if (body is ExpressionSyntax expression)
+ {
+ return SyntaxFactory.ExpressionStatement(expression);
+ }
+
+ // Fallback - wrap in expression statement
+ return SyntaxFactory.ExpressionStatement(
+ SyntaxFactory.IdentifierName("/* TODO: Convert lambda body */"));
+ }
+
+ private static IEnumerable CreateTryCatchStatements(
+ string variableName,
+ StatementSyntax actionBody,
+ bool isAsync,
+ SyntaxTriviaList leadingTrivia)
+ {
+ // Extract the base indentation from leading trivia
+ var indentation = leadingTrivia.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia)).LastOrDefault();
+ var indentString = indentation.ToFullString();
+
+ // Exception? variableName = null;
+ var nullableExceptionType = SyntaxFactory.NullableType(
+ SyntaxFactory.IdentifierName("Exception"));
+
+ var declarationStatement = SyntaxFactory.LocalDeclarationStatement(
+ SyntaxFactory.VariableDeclaration(nullableExceptionType)
+ .WithVariables(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.VariableDeclarator(variableName)
+ .WithInitializer(SyntaxFactory.EqualsValueClause(
+ SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))))))
+ .WithLeadingTrivia(leadingTrivia)
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
+
+ // Prepare the action body with proper indentation (inside try block)
+ var tryBodyIndent = indentString + " ";
+ StatementSyntax tryBodyStatement;
+
+ if (actionBody is BlockSyntax blockBody)
+ {
+ // If it's a block, use its statements directly
+ tryBodyStatement = blockBody
+ .WithOpenBraceToken(blockBody.OpenBraceToken.WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent)))
+ .WithCloseBraceToken(blockBody.CloseBraceToken.WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent)));
+ }
+ else
+ {
+ tryBodyStatement = actionBody
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent))
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
+ }
+
+ // If async, we may need to await the action
+ if (isAsync && tryBodyStatement is ExpressionStatementSyntax exprStmt)
+ {
+ var awaitExpr = SyntaxFactory.AwaitExpression(
+ SyntaxFactory.Token(SyntaxKind.AwaitKeyword).WithTrailingTrivia(SyntaxFactory.Space),
+ exprStmt.Expression);
+ tryBodyStatement = SyntaxFactory.ExpressionStatement(awaitExpr)
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent))
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
+ }
+
+ // catch (Exception e) { variableName = e; }
+ var catchClause = SyntaxFactory.CatchClause()
+ .WithCatchKeyword(SyntaxFactory.Token(SyntaxKind.CatchKeyword)
+ .WithLeadingTrivia(SyntaxFactory.CarriageReturnLineFeed, SyntaxFactory.Whitespace(indentString)))
+ .WithDeclaration(SyntaxFactory.CatchDeclaration(
+ SyntaxFactory.IdentifierName("Exception"),
+ SyntaxFactory.Identifier("e")))
+ .WithBlock(SyntaxFactory.Block(
+ SyntaxFactory.ExpressionStatement(
+ SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName(variableName),
+ SyntaxFactory.IdentifierName("e")))
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent))
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed))
+ .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed))
+ .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString))));
+
+ // try { actionBody; }
+ var tryBlock = SyntaxFactory.Block(tryBodyStatement)
+ .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed))
+ .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString)));
+
+ var tryStatement = SyntaxFactory.TryStatement()
+ .WithTryKeyword(SyntaxFactory.Token(SyntaxKind.TryKeyword)
+ .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString)))
+ .WithBlock(tryBlock)
+ .WithCatches(SyntaxFactory.SingletonList(catchClause))
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
+
+ yield return declarationStatement;
+ yield return tryStatement;
+ }
+
private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode root)
{
var currentRoot = root;
@@ -144,7 +385,7 @@ private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode
var type = objectCreationExpressionSyntax switch
{
ObjectCreationExpressionSyntax explicitObjectCreationExpressionSyntax => explicitObjectCreationExpressionSyntax.Type,
- ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpressionSyntax => SyntaxFactory.ParseTypeName(compilation.GetSemanticModel(implicitObjectCreationExpressionSyntax.SyntaxTree).GetTypeInfo(implicitObjectCreationExpressionSyntax).Type!.ToDisplayString()),
+ ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpressionSyntax => GetTypeFromImplicitCreation(compilation, implicitObjectCreationExpressionSyntax),
_ => null
};
@@ -215,6 +456,31 @@ private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode
return currentRoot;
}
+ ///
+ /// Safely gets the type from an implicit object creation expression using semantic analysis.
+ /// Returns null if semantic analysis fails (defensive for multi-TFM scenarios).
+ ///
+ private static TypeSyntax? GetTypeFromImplicitCreation(Compilation compilation, ImplicitObjectCreationExpressionSyntax implicitCreation)
+ {
+ try
+ {
+ var semanticModel = compilation.GetSemanticModel(implicitCreation.SyntaxTree);
+ var typeInfo = semanticModel.GetTypeInfo(implicitCreation);
+
+ if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error)
+ {
+ return null;
+ }
+
+ return SyntaxFactory.ParseTypeName(typeInfo.Type.ToDisplayString());
+ }
+ catch (InvalidOperationException)
+ {
+ // Semantic analysis failed due to invalid compilation state
+ return null;
+ }
+ }
+
private static SyntaxNode UpdateInitializeDispose(Compilation compilation, SyntaxNode root)
{
// Always operate on the latest root
@@ -519,6 +785,12 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName)
namespaceName.StartsWith("Xunit.", StringComparison.OrdinalIgnoreCase);
}
+ protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName)
+ {
+ // XUnit assertion type that can be detected by syntax
+ return targetType == "Assert";
+ }
+
protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation)
{
if (!IsFrameworkAssertion(invocation))
@@ -542,11 +814,11 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName)
return methodName switch
{
// Equality assertions - check for comparer overloads
- "Equal" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) == true =>
+ "Equal" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) =>
CreateEqualWithComparerComment(arguments),
"Equal" when arguments.Count >= 2 =>
CreateTUnitAssertion("IsEqualTo", arguments[1].Expression, arguments[0]),
- "NotEqual" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) == true =>
+ "NotEqual" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) =>
CreateNotEqualWithComparerComment(arguments),
"NotEqual" when arguments.Count >= 2 =>
CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]),
diff --git a/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs
index 6858434068..edbea903d5 100644
--- a/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs
@@ -782,8 +782,8 @@ public async Task TestWithFormatStrings()
[Test]
public async Task MSTest_Assertions_With_Comparer_AddsTodoComment()
{
- // When the comparer type cannot be determined via semantic analysis (e.g., in test context),
- // a TODO comment is added for manual review instead of passing invalid arguments to .Because().
+ // When a comparer is detected (via semantic or syntax-based detection),
+ // a TODO comment is added explaining that TUnit uses different comparison semantics.
await CodeFixer.VerifyCodeFixAsync(
"""
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -814,7 +814,7 @@ public class MyClass
public async Task TestWithComparer()
{
var comparer = StringComparer.OrdinalIgnoreCase;
- // TODO: TUnit migration - third argument could not be identified as comparer or message. Manual verification required.
+ // TODO: TUnit migration - IEqualityComparer was used. TUnit uses .IsEqualTo() which may have different comparison semantics.
await Assert.That("HELLO").IsEqualTo("hello");
}
}
diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs
index c9c648a6c2..ac2d6ac4be 100644
--- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs
@@ -2856,7 +2856,7 @@ public async Task TestMethod()
}
[Test]
- public async Task NUnit_Platform_Attribute_Removed()
+ public async Task NUnit_Platform_Include_Converted_To_RunOn()
{
await CodeFixer.VerifyCodeFixAsync(
"""
@@ -2881,6 +2881,79 @@ public void TestMethod()
public class MyClass
{
[Test]
+ [RunOn(OS.Windows)]
+ public void TestMethod()
+ {
+ }
+ }
+ """,
+ ConfigureNUnitTest
+ );
+ }
+
+ [Test]
+ public async Task NUnit_Platform_Exclude_Converted_To_ExcludeOn()
+ {
+ await CodeFixer.VerifyCodeFixAsync(
+ """
+ using NUnit.Framework;
+
+ public class MyClass
+ {
+ {|#0:[Test]|}
+ [Platform(Exclude = "Linux")]
+ public void TestMethod()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0),
+ """
+ using TUnit.Core;
+ using TUnit.Assertions;
+ using static TUnit.Assertions.Assert;
+ using TUnit.Assertions.Extensions;
+
+ public class MyClass
+ {
+ [Test]
+ [ExcludeOn(OS.Linux)]
+ public void TestMethod()
+ {
+ }
+ }
+ """,
+ ConfigureNUnitTest
+ );
+ }
+
+ [Test]
+ public async Task NUnit_Platform_Multiple_Platforms_Converted()
+ {
+ await CodeFixer.VerifyCodeFixAsync(
+ """
+ using NUnit.Framework;
+
+ public class MyClass
+ {
+ {|#0:[Test]|}
+ [Platform(Include = "Win,Linux")]
+ public void TestMethod()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0),
+ """
+ using TUnit.Core;
+ using TUnit.Assertions;
+ using static TUnit.Assertions.Assert;
+ using TUnit.Assertions.Extensions;
+
+ public class MyClass
+ {
+ [Test]
+ [RunOn(OS.Windows | OS.Linux)]
public void TestMethod()
{
}
diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
index fe7362552d..db9e695043 100644
--- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
@@ -984,6 +984,192 @@ public async Task MyTest()
);
}
+ [Test]
+ public async Task Record_Exception_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using System;
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [Fact]
+ public void MyTest()
+ {
+ var ex = Record.Exception(() => throw new InvalidOperationException("Test"));
+ Assert.NotNull(ex);
+ Assert.IsType(ex);
+ }
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+ using System;
+ using TUnit.Core;
+ using System.Threading.Tasks;
+
+ public class MyClass
+ {
+ [Test]
+ public async Task MyTest()
+ {
+ Exception? ex = null;
+ try
+ {
+ throw new InvalidOperationException("Test");
+ }
+ catch (Exception e)
+ {
+ ex = e;
+ }
+ await Assert.That(ex).IsNotNull();
+ await Assert.That(ex).IsTypeOf();
+ }
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
+ [Test]
+ public async Task Assert_Throws_With_Property_Access_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using System;
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [Fact]
+ public void MyTest()
+ {
+ var ex = Assert.Throws(() => ThrowException());
+ Assert.Equal("param", ex.ParamName);
+ }
+
+ private void ThrowException() => throw new ArgumentException("error", "param");
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+ using System;
+ using TUnit.Core;
+ using System.Threading.Tasks;
+
+ public class MyClass
+ {
+ [Test]
+ public async Task MyTest()
+ {
+ var ex = await Assert.ThrowsAsync(() => ThrowException());
+ await Assert.That(ex.ParamName).IsEqualTo("param");
+ }
+
+ private void ThrowException() => throw new ArgumentException("error", "param");
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
+ [Test]
+ public async Task Assert_Throws_With_Message_Contains_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using System;
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [Fact]
+ public void MyTest()
+ {
+ var ex = Assert.Throws(() => ThrowException());
+ Assert.Contains("error occurred", ex.Message);
+ }
+
+ private void ThrowException() => throw new InvalidOperationException("An error occurred");
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+ using System;
+ using TUnit.Core;
+ using System.Threading.Tasks;
+
+ public class MyClass
+ {
+ [Test]
+ public async Task MyTest()
+ {
+ var ex = await Assert.ThrowsAsync(() => ThrowException());
+ await Assert.That(ex.Message).Contains("error occurred");
+ }
+
+ private void ThrowException() => throw new InvalidOperationException("An error occurred");
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
+ [Test]
+ public async Task Record_Exception_With_Method_Call_Can_Be_Converted()
+ {
+ await CodeFixer
+ .VerifyCodeFixAsync(
+ """
+ {|#0:using System;
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [Fact]
+ public void MyTest()
+ {
+ var ex = Record.Exception(() => DoSomething());
+ Assert.Null(ex);
+ }
+
+ private void DoSomething() { }
+ }|}
+ """,
+ Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
+ """
+ using System;
+ using TUnit.Core;
+ using System.Threading.Tasks;
+
+ public class MyClass
+ {
+ [Test]
+ public async Task MyTest()
+ {
+ Exception? ex = null;
+ try
+ {
+ DoSomething();
+ }
+ catch (Exception e)
+ {
+ ex = e;
+ }
+ await Assert.That(ex).IsNull();
+ }
+
+ private void DoSomething() { }
+ }
+ """,
+ ConfigureXUnitTest
+ );
+ }
+
private static void ConfigureXUnitTest(Verifier.Test test)
{
var globalUsings = ("GlobalUsings.cs", SourceText.From("global using Xunit;"));
diff --git a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs
index 6c6bf05070..bc569d97a6 100644
--- a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs
+++ b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs
@@ -135,7 +135,8 @@ public static bool ShouldRemoveAttribute(string attributeName, string framework)
{
// Note: Parallelizable is NOT listed here because it needs special handling in the code fixer
// for ParallelScope.None -> [NotInParallel]. ConvertAttribute handles all cases.
- "NUnit" => attributeName is "TestFixture" or "Platform" or "Description",
+ // Note: Platform is NOT listed here because it gets converted to RunOn/ExcludeOn.
+ "NUnit" => attributeName is "TestFixture" or "Description",
"MSTest" => attributeName is "TestClass",
_ => false
};