diff --git a/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl b/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl index e0cad6d197..1116471997 100644 Binary files a/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl and b/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl differ diff --git a/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs new file mode 100644 index 0000000000..4f5807bdbb --- /dev/null +++ b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace TUnit.Analyzers.CodeFixers.Base; + +public abstract class AssertionRewriter : CSharpSyntaxRewriter +{ + protected readonly SemanticModel SemanticModel; + protected abstract string FrameworkName { get; } + + protected AssertionRewriter(SemanticModel semanticModel) + { + SemanticModel = semanticModel; + } + + public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node) + { + var convertedAssertion = ConvertAssertionIfNeeded(node); + if (convertedAssertion != null) + { + return convertedAssertion; + } + + return base.VisitInvocationExpression(node); + } + + protected abstract ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation); + + protected ExpressionSyntax CreateTUnitAssertion( + string methodName, + ExpressionSyntax actualValue, + params ArgumentSyntax[] additionalArguments) + { + // Create Assert.That(actualValue) + var assertThatInvocation = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Assert"), + SyntaxFactory.IdentifierName("That") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(actualValue) + ) + ) + ); + + // Create Assert.That(actualValue).MethodName(args) + var methodAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + assertThatInvocation, + SyntaxFactory.IdentifierName(methodName) + ); + + var arguments = additionalArguments.Length > 0 + ? SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(additionalArguments)) + : SyntaxFactory.ArgumentList(); + + var fullInvocation = SyntaxFactory.InvocationExpression(methodAccess, arguments); + + // Now wrap the entire thing in await: await Assert.That(actualValue).MethodName(args) + return SyntaxFactory.AwaitExpression(fullInvocation); + } + + protected bool IsFrameworkAssertion(InvocationExpressionSyntax invocation) + { + var symbol = SemanticModel.GetSymbolInfo(invocation).Symbol; + if (symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString() ?? ""; + return IsFrameworkAssertionNamespace(namespaceName); + } + + protected abstract bool IsFrameworkAssertionNamespace(string namespaceName); +} \ No newline at end of file diff --git a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs new file mode 100644 index 0000000000..50afd7384d --- /dev/null +++ b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs @@ -0,0 +1,169 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using TUnit.Analyzers.Migrators.Base; + +namespace TUnit.Analyzers.CodeFixers.Base; + +public abstract class BaseMigrationCodeFixProvider : CodeFixProvider +{ + protected abstract string FrameworkName { get; } + protected abstract string DiagnosticId { get; } + protected abstract string CodeFixTitle { get; } + + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticId); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var diagnostic = context.Diagnostics.First(); + + context.RegisterCodeFix( + CodeAction.Create( + title: CodeFixTitle, + createChangedDocument: async c => await ConvertCodeAsync(context.Document, root, c), + equivalenceKey: CodeFixTitle), + diagnostic); + } + + protected async Task ConvertCodeAsync(Document document, SyntaxNode? root, CancellationToken cancellationToken) + { + if (root is not CompilationUnitSyntax compilationUnit) + { + return document; + } + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + if (semanticModel == null) + { + return document; + } + + try + { + // Convert assertions FIRST (while semantic model still matches the syntax tree) + var assertionRewriter = CreateAssertionRewriter(semanticModel); + compilationUnit = (CompilationUnitSyntax)assertionRewriter.Visit(compilationUnit); + + // Framework-specific conversions (also use semantic model while it still matches) + compilationUnit = ApplyFrameworkSpecificConversions(compilationUnit, semanticModel); + + // Remove unnecessary base classes and interfaces + var baseTypeRewriter = CreateBaseTypeRewriter(semanticModel); + compilationUnit = (CompilationUnitSyntax)baseTypeRewriter.Visit(compilationUnit); + + // Update lifecycle methods + var lifecycleRewriter = CreateLifecycleRewriter(); + compilationUnit = (CompilationUnitSyntax)lifecycleRewriter.Visit(compilationUnit); + + // Convert attributes + var attributeRewriter = CreateAttributeRewriter(); + compilationUnit = (CompilationUnitSyntax)attributeRewriter.Visit(compilationUnit); + + // Remove framework usings and add TUnit usings (do this LAST) + compilationUnit = MigrationHelpers.RemoveFrameworkUsings(compilationUnit, FrameworkName); + compilationUnit = MigrationHelpers.AddTUnitUsings(compilationUnit); + + // Format the document first + var documentWithNewRoot = document.WithSyntaxRoot(compilationUnit); + var formattedDocument = await Formatter.FormatAsync(documentWithNewRoot, options: null, cancellationToken).ConfigureAwait(false); + + // Normalize all line endings to CRLF for cross-platform consistency + var text = await formattedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + var normalizedContent = text.ToString().Replace("\r\n", "\n").Replace("\n", "\r\n"); + var normalizedText = Microsoft.CodeAnalysis.Text.SourceText.From(normalizedContent, text.Encoding); + + return formattedDocument.WithText(normalizedText); + } + catch + { + // If any transformation fails, return the original document unchanged + return document; + } + } + + protected abstract AttributeRewriter CreateAttributeRewriter(); + protected abstract CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel); + protected abstract CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel); + protected abstract CSharpSyntaxRewriter CreateLifecycleRewriter(); + protected abstract CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel); +} + +public abstract class AttributeRewriter : CSharpSyntaxRewriter +{ + protected abstract string FrameworkName { get; } + + public override SyntaxNode? VisitAttributeList(AttributeListSyntax node) + { + var attributes = new List(); + + foreach (var attribute in node.Attributes) + { + var attributeName = MigrationHelpers.GetAttributeName(attribute); + + if (MigrationHelpers.ShouldRemoveAttribute(attributeName, FrameworkName)) + { + continue; + } + + if (MigrationHelpers.IsHookAttribute(attributeName, FrameworkName)) + { + var hookAttributeList = MigrationHelpers.ConvertHookAttribute(attribute, FrameworkName); + if (hookAttributeList != null) + { + // Preserve only the leading trivia (indentation) from the original node + // and strip any trailing trivia to prevent extra blank lines + return hookAttributeList + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + } + + var convertedAttribute = ConvertAttribute(attribute); + if (convertedAttribute != null) + { + attributes.Add(convertedAttribute); + } + } + + return attributes.Count > 0 + ? node.WithAttributes(SyntaxFactory.SeparatedList(attributes)) + : null; + } + + protected virtual AttributeSyntax? ConvertAttribute(AttributeSyntax attribute) + { + var attributeName = MigrationHelpers.GetAttributeName(attribute); + var newName = MigrationHelpers.ConvertTestAttributeName(attributeName, FrameworkName); + + if (newName == null) + { + return null; + } + + if (newName == attributeName && !IsFrameworkAttribute(attributeName)) + { + return attribute; + } + + var newAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName(newName)); + + if (attribute.ArgumentList != null && attribute.ArgumentList.Arguments.Count > 0) + { + newAttribute = newAttribute.WithArgumentList(ConvertAttributeArguments(attribute.ArgumentList, attributeName)); + } + + return newAttribute; + } + + protected abstract bool IsFrameworkAttribute(string attributeName); + protected abstract AttributeArgumentListSyntax? ConvertAttributeArguments(AttributeArgumentListSyntax argumentList, string attributeName); +} \ No newline at end of file diff --git a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs new file mode 100644 index 0000000000..c9624be642 --- /dev/null +++ b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs @@ -0,0 +1,405 @@ +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TUnit.Analyzers.CodeFixers.Base; +using TUnit.Analyzers.Migrators.Base; + +namespace TUnit.Analyzers.CodeFixers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MSTestMigrationCodeFixProvider)), Shared] +public class MSTestMigrationCodeFixProvider : BaseMigrationCodeFixProvider +{ + protected override string FrameworkName => "MSTest"; + protected override string DiagnosticId => Rules.MSTestMigration.Id; + protected override string CodeFixTitle => "Convert MSTest code to TUnit"; + + protected override AttributeRewriter CreateAttributeRewriter() + { + return new MSTestAttributeRewriter(); + } + + protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel) + { + return new MSTestAssertionRewriter(semanticModel); + } + + protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel) + { + return new MSTestBaseTypeRewriter(); + } + + protected override CSharpSyntaxRewriter CreateLifecycleRewriter() + { + return new MSTestLifecycleRewriter(); + } + + protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel) + { + // MSTest-specific conversions if needed + return compilationUnit; + } +} + +public class MSTestAttributeRewriter : AttributeRewriter +{ + protected override string FrameworkName => "MSTest"; + + protected override bool IsFrameworkAttribute(string attributeName) + { + return attributeName switch + { + "TestClass" or "TestMethod" or "DataRow" or "DynamicData" or + "TestInitialize" or "TestCleanup" or "ClassInitialize" or "ClassCleanup" or + "TestCategory" or "Ignore" or "Priority" or "Owner" => true, + _ => false + }; + } + + protected override AttributeArgumentListSyntax? ConvertAttributeArguments(AttributeArgumentListSyntax argumentList, string attributeName) + { + return attributeName switch + { + "DataRow" => argumentList, // Arguments attribute uses the same format + "DynamicData" => ConvertDynamicDataArguments(argumentList), + "TestCategory" => ConvertTestCategoryArguments(argumentList), + "Priority" => ConvertPriorityArguments(argumentList), + "ClassInitialize" or "ClassCleanup" => null, // These don't need arguments in TUnit + _ => argumentList + }; + } + + private AttributeArgumentListSyntax ConvertDynamicDataArguments(AttributeArgumentListSyntax argumentList) + { + // Convert DynamicData to MethodDataSource + if (argumentList.Arguments.Count > 0) + { + var firstArg = argumentList.Arguments[0]; + + // If it's a nameof expression, keep it as is + if (firstArg.Expression is InvocationExpressionSyntax { Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } }) + { + return SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList(firstArg) + ); + } + + // If it's a string literal, keep just the method name + if (firstArg.Expression is LiteralExpressionSyntax literal) + { + return SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList(firstArg) + ); + } + } + + return argumentList; + } + + private AttributeArgumentListSyntax ConvertTestCategoryArguments(AttributeArgumentListSyntax argumentList) + { + // Convert TestCategory to Property + var arguments = new List(); + + arguments.Add(SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Category")) + )); + + if (argumentList.Arguments.Count > 0) + { + arguments.Add(argumentList.Arguments[0]); + } + + return SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments)); + } + + private AttributeArgumentListSyntax ConvertPriorityArguments(AttributeArgumentListSyntax argumentList) + { + // Convert Priority to Property + var arguments = new List(); + + arguments.Add(SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Priority")) + )); + + if (argumentList.Arguments.Count > 0) + { + arguments.Add(SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(argumentList.Arguments[0].Expression.ToString())) + )); + } + + return SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments)); + } + + public override SyntaxNode? VisitAttributeList(AttributeListSyntax node) + { + // Handle ClassInitialize and ClassCleanup specially - they need static context parameter removed + var attributes = new List(); + + foreach (var attribute in node.Attributes) + { + var attributeName = MigrationHelpers.GetAttributeName(attribute); + + if (attributeName is "ClassInitialize" or "ClassCleanup") + { + var hookType = attributeName == "ClassInitialize" ? "Before" : "After"; + var newAttribute = SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName(hookType), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("HookType"), + SyntaxFactory.IdentifierName("Class") + ) + ) + ) + ) + ); + return SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(newAttribute)); + } + } + + return base.VisitAttributeList(node); + } +} + +public class MSTestAssertionRewriter : AssertionRewriter +{ + protected override string FrameworkName => "MSTest"; + + public MSTestAssertionRewriter(SemanticModel semanticModel) : base(semanticModel) + { + } + + protected override bool IsFrameworkAssertionNamespace(string namespaceName) + { + return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" || + namespaceName.StartsWith("Microsoft.VisualStudio.TestTools.UnitTesting."); + } + + protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) + { + if (!IsFrameworkAssertion(invocation)) + { + return null; + } + + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" }) + { + return ConvertMSTestAssertion(invocation, memberAccess.Name.Identifier.Text); + } + + // Handle CollectionAssert + if (invocation.Expression is MemberAccessExpressionSyntax collectionAccess && + collectionAccess.Expression is IdentifierNameSyntax { Identifier.Text: "CollectionAssert" }) + { + return ConvertCollectionAssertion(invocation, collectionAccess.Name.Identifier.Text); + } + + // Handle StringAssert + if (invocation.Expression is MemberAccessExpressionSyntax stringAccess && + stringAccess.Expression is IdentifierNameSyntax { Identifier.Text: "StringAssert" }) + { + return ConvertStringAssertion(invocation, stringAccess.Name.Identifier.Text); + } + + return null; + } + + private ExpressionSyntax? ConvertMSTestAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + return methodName switch + { + "AreEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsEqualTo", arguments[1].Expression, arguments[0]), + "AreNotEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]), + "IsTrue" when arguments.Count >= 1 => + CreateTUnitAssertion("IsTrue", arguments[0].Expression), + "IsFalse" when arguments.Count >= 1 => + CreateTUnitAssertion("IsFalse", arguments[0].Expression), + "IsNull" when arguments.Count >= 1 => + CreateTUnitAssertion("IsNull", arguments[0].Expression), + "IsNotNull" when arguments.Count >= 1 => + CreateTUnitAssertion("IsNotNull", arguments[0].Expression), + "AreSame" when arguments.Count >= 2 => + CreateTUnitAssertion("IsSameReference", arguments[1].Expression, arguments[0]), + "AreNotSame" when arguments.Count >= 2 => + CreateTUnitAssertion("IsNotSameReference", arguments[1].Expression, arguments[0]), + "IsInstanceOfType" when arguments.Count >= 2 => + CreateTUnitAssertion("IsAssignableTo", arguments[0].Expression, arguments[1]), + "IsNotInstanceOfType" when arguments.Count >= 2 => + CreateTUnitAssertion("IsNotAssignableTo", arguments[0].Expression, arguments[1]), + "ThrowsException" when arguments.Count >= 1 => + CreateThrowsAssertion(invocation), + "ThrowsExceptionAsync" when arguments.Count >= 1 => + CreateThrowsAsyncAssertion(invocation), + "Fail" => CreateFailAssertion(arguments), + _ => null + }; + } + + private ExpressionSyntax? ConvertCollectionAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + return methodName switch + { + "AreEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsEquivalentTo", arguments[1].Expression, arguments[0]), + "AreNotEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsNotEquivalentTo", arguments[1].Expression, arguments[0]), + "Contains" when arguments.Count >= 2 => + CreateTUnitAssertion("Contains", arguments[0].Expression, arguments[1]), + "DoesNotContain" when arguments.Count >= 2 => + CreateTUnitAssertion("DoesNotContain", arguments[0].Expression, arguments[1]), + "AllItemsAreNotNull" when arguments.Count >= 1 => + CreateTUnitAssertion("AllSatisfy", arguments[0].Expression, + SyntaxFactory.Argument( + SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("x")), + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + SyntaxFactory.IdentifierName("x"), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression) + ) + ) + )), + _ => null + }; + } + + private ExpressionSyntax? ConvertStringAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + return methodName switch + { + "Contains" when arguments.Count >= 2 => + CreateTUnitAssertion("Contains", arguments[0].Expression, arguments[1]), + "DoesNotMatch" when arguments.Count >= 2 => + CreateTUnitAssertion("DoesNotMatch", arguments[0].Expression, arguments[1]), + "EndsWith" when arguments.Count >= 2 => + CreateTUnitAssertion("EndsWith", arguments[0].Expression, arguments[1]), + "Matches" when arguments.Count >= 2 => + CreateTUnitAssertion("Matches", arguments[0].Expression, arguments[1]), + "StartsWith" when arguments.Count >= 2 => + CreateTUnitAssertion("StartsWith", arguments[0].Expression, arguments[1]), + _ => null + }; + } + + private ExpressionSyntax CreateThrowsAssertion(InvocationExpressionSyntax invocation) + { + // Convert Assert.ThrowsException(action) to await Assert.ThrowsAsync(action) + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is GenericNameSyntax genericName) + { + var exceptionType = genericName.TypeArgumentList.Arguments[0]; + var action = invocation.ArgumentList.Arguments[0].Expression; + + var invocationExpression = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Assert"), + SyntaxFactory.GenericName("ThrowsAsync") + .WithTypeArgumentList( + SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(exceptionType) + ) + ) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(action) + ) + ) + ); + + return SyntaxFactory.AwaitExpression(invocationExpression); + } + + return CreateTUnitAssertion("Throws", invocation.ArgumentList.Arguments[0].Expression); + } + + private ExpressionSyntax CreateThrowsAsyncAssertion(InvocationExpressionSyntax invocation) + { + // Similar to CreateThrowsAssertion but for async + return CreateThrowsAssertion(invocation); + } + + private ExpressionSyntax CreateFailAssertion(SeparatedSyntaxList arguments) + { + // Convert Assert.Fail(message) to await Assert.Fail(message) + var failInvocation = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Assert"), + SyntaxFactory.IdentifierName("Fail") + ), + arguments.Count > 0 + ? SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(arguments[0])) + : SyntaxFactory.ArgumentList() + ); + + return SyntaxFactory.AwaitExpression(failInvocation); + } +} + +public class MSTestBaseTypeRewriter : CSharpSyntaxRewriter +{ + public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node) + { + // MSTest doesn't require specific base classes + return base.VisitClassDeclaration(node); + } +} + +public class MSTestLifecycleRewriter : CSharpSyntaxRewriter +{ + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + // Handle ClassInitialize, ClassCleanup, TestInitialize, TestCleanup - remove TestContext parameter where applicable + var lifecycleAttributes = node.AttributeLists + .SelectMany(al => al.Attributes) + .Select(a => MigrationHelpers.GetAttributeName(a)) + .ToList(); + + var hasClassLifecycle = lifecycleAttributes.Any(name => name is "ClassInitialize" or "ClassCleanup"); + var hasTestLifecycle = lifecycleAttributes.Any(name => name is "TestInitialize" or "TestCleanup"); + + if (hasClassLifecycle || hasTestLifecycle) + { + // Remove TestContext parameter if present + var parameters = node.ParameterList?.Parameters ?? default; + if (parameters.Count == 1 && parameters[0].Type?.ToString().Contains("TestContext") == true) + { + node = node.WithParameterList(SyntaxFactory.ParameterList()); + } + + // Make sure method is public + if (!node.Modifiers.Any(SyntaxKind.PublicKeyword)) + { + node = node.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); + } + + // Make sure ClassInitialize/ClassCleanup are static + if (hasClassLifecycle && !node.Modifiers.Any(SyntaxKind.StaticKeyword)) + { + node = node.AddModifiers(SyntaxFactory.Token(SyntaxKind.StaticKeyword)); + } + } + + return base.VisitMethodDeclaration(node); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs new file mode 100644 index 0000000000..13d2c71b82 --- /dev/null +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -0,0 +1,291 @@ +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TUnit.Analyzers.CodeFixers.Base; +using TUnit.Analyzers.Migrators.Base; + +namespace TUnit.Analyzers.CodeFixers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NUnitMigrationCodeFixProvider)), Shared] +public class NUnitMigrationCodeFixProvider : BaseMigrationCodeFixProvider +{ + protected override string FrameworkName => "NUnit"; + protected override string DiagnosticId => Rules.NUnitMigration.Id; + protected override string CodeFixTitle => "Convert NUnit code to TUnit"; + + protected override AttributeRewriter CreateAttributeRewriter() + { + return new NUnitAttributeRewriter(); + } + + protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel) + { + return new NUnitAssertionRewriter(semanticModel); + } + + protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel) + { + return new NUnitBaseTypeRewriter(); + } + + protected override CSharpSyntaxRewriter CreateLifecycleRewriter() + { + return new NUnitLifecycleRewriter(); + } + + protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel) + { + // NUnit-specific conversions if needed + return compilationUnit; + } +} + +public class NUnitAttributeRewriter : AttributeRewriter +{ + protected override string FrameworkName => "NUnit"; + + protected override bool IsFrameworkAttribute(string attributeName) + { + return attributeName switch + { + "Test" or "TestCase" or "TestCaseSource" or + "SetUp" or "TearDown" or "OneTimeSetUp" or "OneTimeTearDown" or + "TestFixture" or "Category" or "Ignore" or "Explicit" => true, + _ => false + }; + } + + protected override AttributeArgumentListSyntax? ConvertAttributeArguments(AttributeArgumentListSyntax argumentList, string attributeName) + { + return attributeName switch + { + "TestCase" => argumentList, // Arguments attribute uses the same format + "TestCaseSource" => ConvertTestCaseSourceArguments(argumentList), + "Category" => ConvertCategoryArguments(argumentList), + _ => argumentList + }; + } + + private AttributeArgumentListSyntax ConvertTestCaseSourceArguments(AttributeArgumentListSyntax argumentList) + { + // Convert TestCaseSource to MethodDataSource + if (argumentList.Arguments.Count > 0) + { + var firstArg = argumentList.Arguments[0]; + + // If it's a nameof expression, keep it as is + if (firstArg.Expression is InvocationExpressionSyntax { Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } }) + { + return argumentList; + } + + // If it's a string literal, wrap it in quotes if needed + if (firstArg.Expression is LiteralExpressionSyntax literal) + { + return argumentList; + } + } + + return argumentList; + } + + private AttributeArgumentListSyntax ConvertCategoryArguments(AttributeArgumentListSyntax argumentList) + { + // Convert Category to Property + var arguments = new List(); + + arguments.Add(SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Category")) + )); + + if (argumentList.Arguments.Count > 0) + { + arguments.Add(argumentList.Arguments[0]); + } + + return SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments)); + } +} + +public class NUnitAssertionRewriter : AssertionRewriter +{ + protected override string FrameworkName => "NUnit"; + + public NUnitAssertionRewriter(SemanticModel semanticModel) : base(semanticModel) + { + } + + protected override bool IsFrameworkAssertionNamespace(string namespaceName) + { + // Exclude NUnit.Framework.Legacy - ClassicAssert should not be converted + return (namespaceName == "NUnit.Framework" || namespaceName.StartsWith("NUnit.Framework.")) + && namespaceName != "NUnit.Framework.Legacy"; + } + + protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) + { + if (!IsFrameworkAssertion(invocation)) + { + return null; + } + + // Handle Assert.That(value, constraint) + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "That" && + invocation.ArgumentList.Arguments.Count >= 2) + { + return ConvertAssertThat(invocation); + } + + // Handle classic assertions like Assert.AreEqual, ClassicAssert.AreEqual, etc. + if (invocation.Expression is MemberAccessExpressionSyntax classicMemberAccess && + classicMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" or "ClassicAssert" }) + { + return ConvertClassicAssertion(invocation, classicMemberAccess.Name.Identifier.Text); + } + + return null; + } + + private ExpressionSyntax ConvertAssertThat(InvocationExpressionSyntax invocation) + { + var arguments = invocation.ArgumentList.Arguments; + var actualValue = arguments[0].Expression; + var constraint = arguments[1].Expression; + + // Parse the constraint to determine the TUnit assertion method + if (constraint is InvocationExpressionSyntax constraintInvocation) + { + return ConvertConstraintToTUnit(actualValue, constraintInvocation); + } + + if (constraint is MemberAccessExpressionSyntax constraintMember) + { + return ConvertConstraintMemberToTUnit(actualValue, constraintMember); + } + + return CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)); + } + + private ExpressionSyntax ConvertConstraintToTUnit(ExpressionSyntax actualValue, InvocationExpressionSyntax constraint) + { + if (constraint.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + + // Handle Does.StartWith, Does.EndWith, Contains.Substring + if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Does" or "Contains" }) + { + return methodName switch + { + "StartWith" => CreateTUnitAssertion("StartsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "EndWith" => CreateTUnitAssertion("EndsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "Substring" => CreateTUnitAssertion("Contains", actualValue, constraint.ArgumentList.Arguments.ToArray()), + _ => CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)) + }; + } + + return methodName switch + { + "EqualTo" => CreateTUnitAssertion("IsEqualTo", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "GreaterThan" => CreateTUnitAssertion("IsGreaterThan", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "LessThan" => CreateTUnitAssertion("IsLessThan", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "Contains" => CreateTUnitAssertion("Contains", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "StartsWith" => CreateTUnitAssertion("StartsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()), + "EndsWith" => CreateTUnitAssertion("EndsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()), + _ => CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)) + }; + } + + return CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)); + } + + private ExpressionSyntax ConvertConstraintMemberToTUnit(ExpressionSyntax actualValue, MemberAccessExpressionSyntax constraint) + { + var memberName = constraint.Name.Identifier.Text; + + // Handle Is.Not.X patterns + if (constraint.Expression is MemberAccessExpressionSyntax innerMemberAccess && + innerMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Is" } && + innerMemberAccess.Name.Identifier.Text == "Not") + { + return memberName switch + { + "Null" => CreateTUnitAssertion("IsNotNull", actualValue), + "Empty" => CreateTUnitAssertion("IsNotEmpty", actualValue), + _ => CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)) + }; + } + + return memberName switch + { + "True" => CreateTUnitAssertion("IsTrue", actualValue), + "False" => CreateTUnitAssertion("IsFalse", actualValue), + "Null" => CreateTUnitAssertion("IsNull", actualValue), + "Empty" => CreateTUnitAssertion("IsEmpty", actualValue), + _ => CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint)) + }; + } + + private ExpressionSyntax? ConvertClassicAssertion(InvocationExpressionSyntax invocation, string methodName) + { + var arguments = invocation.ArgumentList.Arguments; + + return methodName switch + { + "AreEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsEqualTo", arguments[1].Expression, arguments[0]), + "AreNotEqual" when arguments.Count >= 2 => + CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]), + "IsTrue" when arguments.Count >= 1 => + CreateTUnitAssertion("IsTrue", arguments[0].Expression), + "IsFalse" when arguments.Count >= 1 => + CreateTUnitAssertion("IsFalse", arguments[0].Expression), + "IsNull" when arguments.Count >= 1 => + CreateTUnitAssertion("IsNull", arguments[0].Expression), + "IsNotNull" when arguments.Count >= 1 => + CreateTUnitAssertion("IsNotNull", arguments[0].Expression), + "IsEmpty" when arguments.Count >= 1 => + CreateTUnitAssertion("IsEmpty", arguments[0].Expression), + "IsNotEmpty" when arguments.Count >= 1 => + CreateTUnitAssertion("IsNotEmpty", arguments[0].Expression), + "Greater" when arguments.Count >= 2 => + CreateTUnitAssertion("IsGreaterThan", arguments[0].Expression, arguments[1]), + "Less" when arguments.Count >= 2 => + CreateTUnitAssertion("IsLessThan", arguments[0].Expression, arguments[1]), + _ => null + }; + } +} + +public class NUnitBaseTypeRewriter : CSharpSyntaxRewriter +{ + public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node) + { + // NUnit doesn't require specific base classes, but might have IDisposable for cleanup + // For now, just return the node as is + return base.VisitClassDeclaration(node); + } +} + +public class NUnitLifecycleRewriter : CSharpSyntaxRewriter +{ + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + // Lifecycle methods are handled by attribute conversion + // Just ensure they're public and have correct signature + var hasLifecycleAttribute = node.AttributeLists + .SelectMany(al => al.Attributes) + .Any(a => a.Name.ToString() is "Before" or "After"); + + if (hasLifecycleAttribute && !node.Modifiers.Any(SyntaxKind.PublicKeyword)) + { + return node.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); + } + + return base.VisitMethodDeclaration(node); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers.Tests/Extensions/StringExtensions.cs b/TUnit.Analyzers.Tests/Extensions/StringExtensions.cs index 95c4d09460..43e4135285 100644 --- a/TUnit.Analyzers.Tests/Extensions/StringExtensions.cs +++ b/TUnit.Analyzers.Tests/Extensions/StringExtensions.cs @@ -2,6 +2,11 @@ public static class StringExtensions { + /// + /// Normalizes line endings to CRLF (Windows format). + /// This ensures test inputs match the CRLF line endings that Roslyn's + /// Formatter.FormatAsync() generates by default on all platforms. + /// public static string NormalizeLineEndings(this string value) { return value.Replace("\r\n", "\n").Replace("\n", "\r\n"); diff --git a/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs new file mode 100644 index 0000000000..804dd2af62 --- /dev/null +++ b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs @@ -0,0 +1,695 @@ +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using CodeFixer = TUnit.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; +using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.Analyzers.Tests; + +public class MSTestMigrationAnalyzerTests +{ + [Test] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethod")] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.DataRow")] + public async Task MSTest_Attribute_Flagged(string attribute) + { + await Verifier.VerifyAnalyzerAsync( + $$""" + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass + { + {|#0:[{{attribute}}]|} + public void MyMethod() { } + } + """, + ConfigureMSTestTest, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0) + ); + } + + [Test] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethod", "Test")] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.DataRow(1, 2, 3)", "Arguments(1, 2, 3)")] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.TestInitialize", "Before(HookType.Test)")] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanup", "After(HookType.Test)")] + [Arguments("Microsoft.VisualStudio.TestTools.UnitTesting.DynamicData(\"SomeMethod\")", "MethodDataSource(\"SomeMethod\")")] + public async Task MSTest_Attribute_Can_Be_Converted(string attribute, string expected) + { + await CodeFixer.VerifyCodeFixAsync( + $$""" + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass + { + {|#0:[{{attribute}}]|} + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + $$""" + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [{{expected}}] + public void MyMethod() { } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_TestClass_Attribute_Removed() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:[TestClass]|} + public class MyClass + { + [TestMethod] + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void MyMethod() { } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Assertions_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestMethod] + public void MyMethod() + { + Assert.AreEqual(5, 5); + Assert.IsTrue(true); + Assert.IsNull(null); + Assert.AreNotEqual(3, 5); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void MyMethod() + { + await Assert.That(5).IsEqualTo(5); + await Assert.That(true).IsTrue(); + await Assert.That(null).IsNull(); + await Assert.That(5).IsNotEqualTo(3); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Directive_Flagged() + { + await Verifier.VerifyAnalyzerAsync( + """ + {|#0:using Microsoft.VisualStudio.TestTools.UnitTesting;|} + + public class MyClass + { + public void MyMethod() { } + } + """, + ConfigureMSTestTest, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0) + ); + } + + [Test] + public async Task MSTest_Directive_Can_Be_Removed() + { + await CodeFixer.VerifyCodeFixAsync( + """ + {|#0:using Microsoft.VisualStudio.TestTools.UnitTesting;|} + + public class MyClass + { + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + public void MyMethod() { } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_TestInitialize_TestCleanup_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestInitialize] + public void Setup() { } + + [TestCleanup] + public void Teardown() { } + + [TestMethod] + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Before(HookType.Test)] + public void Setup() { } + + [After(HookType.Test)] + public void Teardown() { } + + [Test] + public void MyMethod() { } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_ClassInitialize_ClassCleanup_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [ClassInitialize] + public static void ClassSetup(TestContext context) { } + + [ClassCleanup] + public static void ClassTeardown() { } + + [TestMethod] + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Before(HookType.Class)] + public static void ClassSetup() { } + + [After(HookType.Class)] + public static void ClassTeardown() { } + + [Test] + public void MyMethod() { } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_CollectionAssert_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestMethod] + public void MyMethod() + { + var list1 = new[] { 1, 2, 3 }; + var list2 = new[] { 1, 2, 3 }; + CollectionAssert.AreEqual(list1, list2); + CollectionAssert.Contains(list1, 2); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void MyMethod() + { + var list1 = new[] { 1, 2, 3 }; + var list2 = new[] { 1, 2, 3 }; + await Assert.That(list2).IsEquivalentTo(list1); + await Assert.That(list1).Contains(2); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_StringAssert_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestMethod] + public void StringTests() + { + StringAssert.Contains("hello world", "world"); + StringAssert.StartsWith("hello world", "hello"); + StringAssert.EndsWith("hello world", "world"); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void StringTests() + { + await Assert.That("hello world").Contains("world"); + await Assert.That("hello world").StartsWith("hello"); + await Assert.That("hello world").EndsWith("world"); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Nested_Class_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class OuterClass|} + { + public class InnerTests + { + [TestMethod] + public void InnerTest() + { + Assert.IsTrue(true); + } + } + + [TestMethod] + public void OuterTest() + { + Assert.IsFalse(false); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class OuterClass + { + public class InnerTests + { + [Test] + public void InnerTest() + { + await Assert.That(true).IsTrue(); + } + } + + [Test] + public void OuterTest() + { + await Assert.That(false).IsFalse(); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Generic_Test_Class_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class GenericTestClass|} + { + [TestMethod] + public void GenericTest() + { + var instance = default(T); + Assert.AreEqual(default(T), instance); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class GenericTestClass + { + [Test] + public void GenericTest() + { + var instance = default(T); + await Assert.That(instance).IsEqualTo(default(T)); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Complete_File_Transformation() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + + {|#0:[TestClass]|} + public class CompleteTestClass + { + private int _counter; + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + // Class setup + } + + [TestInitialize] + public void Setup() + { + _counter = 1; + } + + [TestMethod] + public void Test1() + { + Assert.IsTrue(_counter > 0); + Assert.IsNotNull(_counter); + } + + [DataRow(1, 2, 3)] + [DataRow(5, 5, 10)] + [TestMethod] + public void AdditionTest(int a, int b, int expected) + { + var result = a + b; + Assert.AreEqual(expected, result); + } + + [DynamicData(nameof(GetTestData))] + [TestMethod] + public void DataDrivenTest(string input) + { + Assert.IsNotNull(input); + } + + public static System.Collections.Generic.IEnumerable GetTestData() + { + yield return new object[] { "test1" }; + yield return new object[] { "test2" }; + } + + [TestCleanup] + public void Teardown() + { + // Cleanup + } + + [ClassCleanup] + public static void ClassTeardown() + { + // Class cleanup + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class CompleteTestClass + { + private int _counter; + + [Before(HookType.Class)] + public static void ClassSetup() + { + // Class setup + } + + [Before(HookType.Test)] + public void Setup() + { + _counter = 1; + } + + [Test] + public void Test1() + { + await Assert.That(_counter > 0).IsTrue(); + await Assert.That(_counter).IsNotNull(); + } + + [Arguments(1, 2, 3)] + [Arguments(5, 5, 10)] + [Test] + public void AdditionTest(int a, int b, int expected) + { + var result = a + b; + await Assert.That(result).IsEqualTo(expected); + } + + [MethodDataSource(nameof(GetTestData))] + [Test] + public void DataDrivenTest(string input) + { + await Assert.That(input).IsNotNull(); + } + + public static System.Collections.Generic.IEnumerable GetTestData() + { + yield return new object[] { "test1" }; + yield return new object[] { "test2" }; + } + + [After(HookType.Test)] + public void Teardown() + { + // Cleanup + } + + [After(HookType.Class)] + public static void ClassTeardown() + { + // Class cleanup + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Multiple_Assertion_Types() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestMethod] + public void TestMultipleAssertionTypes() + { + var value = 42; + var list = new[] { 1, 2, 3 }; + var text = "hello"; + + // Standard assertions + Assert.AreEqual(42, value); + Assert.IsNotNull(value); + Assert.IsTrue(value > 0); + + // Collection assertions + CollectionAssert.Contains(list, 2); + CollectionAssert.AreNotEqual(list, new[] { 4, 5, 6 }); + + // String assertions + StringAssert.Contains(text, "ell"); + StringAssert.StartsWith(text, "hel"); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void TestMultipleAssertionTypes() + { + var value = 42; + var list = new[] { 1, 2, 3 }; + var text = "hello"; + await Assert.That(value).IsEqualTo(42); + await Assert.That(value).IsNotNull(); + await Assert.That(value > 0).IsTrue(); + await Assert.That(list).Contains(2); + await Assert.That(new[] { 4, 5, 6 }).IsNotEquivalentTo(list); + await Assert.That(text).Contains("ell"); + await Assert.That(text).StartsWith("hel"); + } + } + """, + ConfigureMSTestTest + ); + } + + [Test] + public async Task MSTest_Reference_Type_Assertions() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + {|#0:public class MyClass|} + { + [TestMethod] + public void TestReferences() + { + var obj1 = new object(); + var obj2 = obj1; + var obj3 = new object(); + + Assert.AreSame(obj1, obj2); + Assert.AreNotSame(obj1, obj3); + } + } + """, + Verifier.Diagnostic(Rules.MSTestMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + public void TestReferences() + { + var obj1 = new object(); + var obj2 = obj1; + var obj3 = new object(); + await Assert.That(obj2).IsSameReference(obj1); + await Assert.That(obj3).IsNotSameReference(obj1); + } + } + """, + ConfigureMSTestTest + ); + } + + private static void ConfigureMSTestTest(Verifier.Test test) + { + test.TestState.AdditionalReferences.Add(typeof(TestMethodAttribute).Assembly); + } + + private static void ConfigureMSTestTest(CodeFixer.Test test) + { + test.TestState.AdditionalReferences.Add(typeof(TestMethodAttribute).Assembly); + // FixedState should only have TUnit assemblies, not MSTest + test.FixedState.AdditionalReferences.Add(typeof(TUnit.Core.TestAttribute).Assembly); + test.FixedState.AdditionalReferences.Add(typeof(TUnit.Assertions.Assert).Assembly); + } +} diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs new file mode 100644 index 0000000000..ef9ea4e83b --- /dev/null +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -0,0 +1,597 @@ +using Microsoft.CodeAnalysis.Text; +using NUnit.Framework.Legacy; +using CodeFixer = TUnit.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; +using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.Analyzers.Tests; + +public class NUnitMigrationAnalyzerTests +{ + [Test] + [Arguments("NUnit.Framework.Test")] + [Arguments("NUnit.Framework.TestCase")] + public async Task NUnit_Attribute_Flagged(string attribute) + { + await Verifier.VerifyAnalyzerAsync( + $$""" + using NUnit.Framework; + + public class MyClass + { + {|#0:[{{attribute}}]|} + public void MyMethod() { } + } + """, + ConfigureNUnitTest, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0) + ); + } + + [Test] + [Arguments("NUnit.Framework.Test", "Test")] + [Arguments("NUnit.Framework.TestCase(1, 2, 3)", "Arguments(1, 2, 3)")] + [Arguments("NUnit.Framework.SetUp", "Before(HookType.Test)")] + [Arguments("NUnit.Framework.TearDown", "After(HookType.Test)")] + [Arguments("NUnit.Framework.OneTimeSetUp", "Before(HookType.Class)")] + [Arguments("NUnit.Framework.OneTimeTearDown", "After(HookType.Class)")] + [Arguments("NUnit.Framework.TestCaseSource(\"SomeMethod\")", "MethodDataSource(\"SomeMethod\")")] + public async Task NUnit_Attribute_Can_Be_Converted(string attribute, string expected) + { + await CodeFixer.VerifyCodeFixAsync( + $$""" + using NUnit.Framework; + + public class MyClass + { + {|#0:[{{attribute}}]|} + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + $$""" + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [{{expected}}] + public void MyMethod() { } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_TestFixture_Attribute_Removed() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:[TestFixture]|} + public class MyClass + { + [Test] + public void MyMethod() { } + } + """, + 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] + public void MyMethod() { } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Assert_That_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void MyMethod() + { + Assert.That(5, Is.EqualTo(5)); + Assert.That(true, Is.True); + Assert.That(null, Is.Null); + } + } + """, + 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] + public void MyMethod() + { + await Assert.That(5).IsEqualTo(5); + await Assert.That(true).IsTrue(); + await Assert.That(null).IsNull(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Classic_Assertions_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + using NUnit.Framework.Legacy; + + {|#0:public class MyClass|} + { + [Test] + public void MyMethod() + { + ClassicAssert.AreEqual(5, 5); + ClassicAssert.IsTrue(true); + ClassicAssert.IsNull(null); + ClassicAssert.Greater(10, 5); + } + } + """, + 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] + public void MyMethod() + { + ClassicAssert.AreEqual(5, 5); + ClassicAssert.IsTrue(true); + ClassicAssert.IsNull(null); + ClassicAssert.Greater(10, 5); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Directive_Flagged() + { + await Verifier.VerifyAnalyzerAsync( + """ + {|#0:using NUnit.Framework;|} + + public class MyClass + { + public void MyMethod() { } + } + """, + ConfigureNUnitTest, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0) + ); + } + + [Test] + public async Task NUnit_Directive_Can_Be_Removed() + { + await CodeFixer.VerifyCodeFixAsync( + """ + {|#0:using NUnit.Framework;|} + + public class MyClass + { + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + public void MyMethod() { } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_SetUp_TearDown_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [SetUp] + public void Setup() { } + + [TearDown] + public void Teardown() { } + + [Test] + public void MyMethod() { } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Before(HookType.Test)] + public void Setup() { } + + [After(HookType.Test)] + public void Teardown() { } + + [Test] + public void MyMethod() { } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Nested_Class_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class OuterClass|} + { + public class InnerTests + { + [Test] + public void InnerTest() + { + Assert.That(true, Is.True); + } + } + + [Test] + public void OuterTest() + { + Assert.That(false, Is.False); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class OuterClass + { + public class InnerTests + { + [Test] + public void InnerTest() + { + await Assert.That(true).IsTrue(); + } + } + + [Test] + public void OuterTest() + { + await Assert.That(false).IsFalse(); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Generic_Test_Class_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class GenericTestClass|} + { + [Test] + public void GenericTest() + { + var instance = default(T); + Assert.That(instance, Is.EqualTo(default(T))); + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class GenericTestClass + { + [Test] + public void GenericTest() + { + var instance = default(T); + await Assert.That(instance).IsEqualTo(default(T)); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Complex_Constraint_Chains_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + {|#0:public class MyClass|} + { + [Test] + public void ComplexConstraints() + { + Assert.That(10, Is.GreaterThan(5)); + Assert.That(3, Is.LessThan(10)); + Assert.That("hello", Is.Not.Null); + Assert.That("test", Contains.Substring("es")); + Assert.That("world", Does.StartWith("wor")); + Assert.That("hello", Does.EndWith("llo")); + } + } + """, + 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] + public void ComplexConstraints() + { + await Assert.That(10).IsGreaterThan(5); + await Assert.That(3).IsLessThan(10); + await Assert.That("hello").IsNotNull(); + await Assert.That("test").Contains("es"); + await Assert.That("world").StartsWith("wor"); + await Assert.That("hello").EndsWith("llo"); + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Complete_File_Transformation() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + using System; + + {|#0:[TestFixture]|} + public class CompleteTestClass + { + private int _counter; + + [OneTimeSetUp] + public void ClassSetup() + { + _counter = 0; + } + + [SetUp] + public void Setup() + { + _counter++; + } + + [Test] + public void Test1() + { + Assert.That(_counter, Is.GreaterThan(0)); + ClassicAssert.IsTrue(true); + } + + [TestCase(1, 2, 3)] + [TestCase(5, 5, 10)] + public void AdditionTest(int a, int b, int expected) + { + var result = a + b; + Assert.That(result, Is.EqualTo(expected)); + } + + [TestCaseSource(nameof(GetTestData))] + public void DataDrivenTest(string input) + { + Assert.That(input, Is.Not.Null); + } + + public static object[] GetTestData() + { + return new object[] { "test1", "test2" }; + } + + [TearDown] + public void Teardown() + { + // Cleanup + } + + [OneTimeTearDown] + public void ClassTeardown() + { + _counter = 0; + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class CompleteTestClass + { + private int _counter; + + [Before(HookType.Class)] + public void ClassSetup() + { + _counter = 0; + } + + [Before(HookType.Test)] + public void Setup() + { + _counter++; + } + + [Test] + public void Test1() + { + await Assert.That(_counter).IsGreaterThan(0); + ClassicAssert.IsTrue(true); + } + + [Arguments(1, 2, 3)] + [Arguments(5, 5, 10)] + public void AdditionTest(int a, int b, int expected) + { + var result = a + b; + await Assert.That(result).IsEqualTo(expected); + } + + [MethodDataSource(nameof(GetTestData))] + public void DataDrivenTest(string input) + { + await Assert.That(input).IsNotNull(); + } + + public static object[] GetTestData() + { + return new object[] { "test1", "test2" }; + } + + [After(HookType.Test)] + public void Teardown() + { + // Cleanup + } + + [After(HookType.Class)] + public void ClassTeardown() + { + _counter = 0; + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Multiple_Assertions_In_Single_Test() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + using NUnit.Framework.Legacy; + + {|#0:public class MyClass|} + { + [Test] + public void TestMultipleAssertions() + { + var value = 42; + Assert.That(value, Is.Not.Null); + ClassicAssert.IsNotNull(value); + ClassicAssert.AreEqual(42, value); + Assert.That(value, Is.GreaterThan(0)); + ClassicAssert.Less(0, value); + } + } + """, + 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] + public void TestMultipleAssertions() + { + var value = 42; + await Assert.That(value).IsNotNull(); + ClassicAssert.IsNotNull(value); + ClassicAssert.AreEqual(42, value); + await Assert.That(value).IsGreaterThan(0); + ClassicAssert.Less(0, value); + } + } + """, + ConfigureNUnitTest + ); + } + + private static void ConfigureNUnitTest(Verifier.Test test) + { + test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly); + } + + private static void ConfigureNUnitTest(CodeFixer.Test test) + { + test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly); + test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.Legacy.ClassicAssert).Assembly); + // FixedState should only have TUnit assemblies, not NUnit + test.FixedState.AdditionalReferences.Add(typeof(TUnit.Core.TestAttribute).Assembly); + test.FixedState.AdditionalReferences.Add(typeof(TUnit.Assertions.Assert).Assembly); + } +} diff --git a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj index 1a56243f60..4118449776 100644 --- a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj +++ b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj @@ -8,12 +8,16 @@ + + + + diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs index 66c5a8854d..b1268538d6 100644 --- a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs +++ b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -9,7 +9,7 @@ namespace TUnit.Analyzers.Tests.Verifiers; public static partial class CSharpAnalyzerVerifier where TAnalyzer : DiagnosticAnalyzer, new() { - public class Test : CSharpAnalyzerTest + public class Test : CSharpAnalyzerTest { public Test() { diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs index 3a0ad0e8fb..79656d8ff4 100644 --- a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs +++ b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -10,7 +10,7 @@ public static partial class CSharpCodeFixVerifier where TAnalyzer : DiagnosticAnalyzer, new() where TCodeFix : CodeFixProvider, new() { - public class Test : CSharpCodeFixTest + public class Test : CSharpCodeFixTest { public Test() { diff --git a/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs new file mode 100644 index 0000000000..5938aa70bf --- /dev/null +++ b/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs @@ -0,0 +1,90 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.Analyzers.Tests.Verifiers; + +/// +/// A custom verifier that normalizes line endings before comparison to support cross-platform testing. +/// This prevents tests from failing on Unix systems (Linux/macOS) which use LF line endings +/// while Windows uses CRLF line endings. +/// +public class LineEndingNormalizingVerifier : IVerifier +{ + private readonly DefaultVerifier _defaultVerifier = new(); + + public void Empty(string collectionName, IEnumerable collection) + { + _defaultVerifier.Empty(collectionName, collection); + } + + public void Equal(T expected, T actual, string? message = null) + { + // Normalize line endings for string comparisons + if (expected is string expectedString && actual is string actualString) + { + var normalizedExpected = NormalizeLineEndings(expectedString); + var normalizedActual = NormalizeLineEndings(actualString); + _defaultVerifier.Equal(normalizedExpected, normalizedActual, message); + } + else + { + _defaultVerifier.Equal(expected, actual, message); + } + } + + public void True(bool assert, string? message = null) + { + _defaultVerifier.True(assert, message); + } + + public void False(bool assert, string? message = null) + { + _defaultVerifier.False(assert, message); + } + + [DoesNotReturn] + public void Fail(string? message = null) + { + _defaultVerifier.Fail(message); + } + + public void LanguageIsSupported(string language) + { + _defaultVerifier.LanguageIsSupported(language); + } + + public void NotEmpty(string collectionName, IEnumerable collection) + { + _defaultVerifier.NotEmpty(collectionName, collection); + } + + public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null) + { + _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message); + } + + public IVerifier PushContext(string context) + { + // Create a new verifier that wraps the result of PushContext on the default verifier + return new LineEndingNormalizingVerifierWithContext(_defaultVerifier.PushContext(context)); + } + + private static string NormalizeLineEndings(string value) + { + // Normalize all line endings to CRLF for consistent comparison + return value.Replace("\r\n", "\n").Replace("\n", "\r\n"); + } + + /// + /// Internal helper class to wrap a verifier with context + /// + private class LineEndingNormalizingVerifierWithContext : LineEndingNormalizingVerifier + { + private readonly IVerifier _wrappedVerifier; + + public LineEndingNormalizingVerifierWithContext(IVerifier wrappedVerifier) + { + _wrappedVerifier = wrappedVerifier; + } + } +} diff --git a/TUnit.Analyzers/AnalyzerReleases.Shipped.md b/TUnit.Analyzers/AnalyzerReleases.Shipped.md index 345c503454..3985959552 100644 --- a/TUnit.Analyzers/AnalyzerReleases.Shipped.md +++ b/TUnit.Analyzers/AnalyzerReleases.Shipped.md @@ -82,4 +82,6 @@ TUnit0055 | Usage | Warning | Do not overwrite Console.Out/Error - it breaks TUn #### Migration and Legacy Support Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------ -TUXU0001 | Usage | Info | XUnit code can be migrated to TUnit \ No newline at end of file +TUXU0001 | Usage | Info | XUnit code can be migrated to TUnit +TUNU0001 | Usage | Info | NUnit code can be migrated to TUnit +TUMS0001 | Usage | Info | MSTest code can be migrated to TUnit \ No newline at end of file diff --git a/TUnit.Analyzers/Migrators/Base/BaseMigrationAnalyzer.cs b/TUnit.Analyzers/Migrators/Base/BaseMigrationAnalyzer.cs new file mode 100644 index 0000000000..7ee398b985 --- /dev/null +++ b/TUnit.Analyzers/Migrators/Base/BaseMigrationAnalyzer.cs @@ -0,0 +1,280 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace TUnit.Analyzers.Migrators.Base; + +public abstract class BaseMigrationAnalyzer : ConcurrentDiagnosticAnalyzer +{ + protected abstract string TargetFrameworkNamespace { get; } + protected abstract DiagnosticDescriptor DiagnosticRule { get; } + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticRule); + + protected override void InitializeInternal(AnalysisContext context) + { + context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.CompilationUnit); + } + + private void AnalyzeSyntax(SyntaxNodeAnalysisContext context) + { + if (context.Node is not CompilationUnitSyntax compilationUnitSyntax) + { + return; + } + + var classDeclarationSyntaxes = compilationUnitSyntax + .DescendantNodes() + .OfType(); + + foreach (var classDeclarationSyntax in classDeclarationSyntaxes) + { + var symbol = context.SemanticModel.GetDeclaredSymbol(classDeclarationSyntax); + + if (symbol is null) + { + return; + } + + // Priority 1: Framework interfaces on the class + if (HasFrameworkInterfaces(symbol)) + { + Flag(context, classDeclarationSyntax.GetLocation()); + return; + } + + // Priority 2: Framework attributes on the class + var classAttributeLocation = AnalyzeAttributes(context, symbol, classDeclarationSyntax); + if (classAttributeLocation != null) + { + Flag(context, classAttributeLocation); + return; + } + + // Priority 3: Framework types in the class (e.g., Assert calls) + // Flag on the class declaration so the entire class can be migrated + if (HasFrameworkTypes(context, symbol, classDeclarationSyntax)) + { + // Flag the class declaration line: from start of modifiers to end of class name + var classHeaderSpan = TextSpan.FromBounds( + classDeclarationSyntax.SpanStart, + classDeclarationSyntax.Identifier.Span.End); + var classHeaderLocation = Location.Create( + classDeclarationSyntax.SyntaxTree, + classHeaderSpan); + Flag(context, classHeaderLocation); + return; + } + + // Priority 4: Framework attributes on methods + // Collect all methods with framework attributes + var methodsWithFrameworkAttributes = new List<(IMethodSymbol symbol, Location location)>(); + foreach (var methodSymbol in symbol.GetMembers().OfType()) + { + var syntaxReferences = methodSymbol.DeclaringSyntaxReferences; + if (syntaxReferences.Length == 0) + { + continue; + } + + var methodSyntax = syntaxReferences[0].GetSyntax(); + var methodAttributeLocation = AnalyzeAttributes(context, methodSymbol, methodSyntax); + if (methodAttributeLocation != null) + { + methodsWithFrameworkAttributes.Add((methodSymbol, methodAttributeLocation)); + } + } + + // If multiple methods have framework attributes, flag the class declaration + // If only one method has framework attributes, flag that specific attribute + if (methodsWithFrameworkAttributes.Count > 1) + { + var classHeaderSpan = TextSpan.FromBounds( + classDeclarationSyntax.SpanStart, + classDeclarationSyntax.Identifier.Span.End); + var classHeaderLocation = Location.Create( + classDeclarationSyntax.SyntaxTree, + classHeaderSpan); + Flag(context, classHeaderLocation); + return; + } + else if (methodsWithFrameworkAttributes.Count == 1) + { + Flag(context, methodsWithFrameworkAttributes[0].location); + return; + } + + // Priority 5 (lowest): Using directives - only flag if nothing else was found + var usingLocation = CheckUsingDirectives(classDeclarationSyntax); + if (usingLocation != null) + { + Flag(context, usingLocation); + return; + } + } + } + + protected virtual bool HasFrameworkInterfaces(INamedTypeSymbol symbol) + { + return symbol.AllInterfaces.Any(i => + i.ContainingNamespace?.Name.StartsWith(TargetFrameworkNamespace) is true || + IsFrameworkNamespace(i.ContainingNamespace?.ToDisplayString())); + } + + protected virtual Location? CheckUsingDirectives(ClassDeclarationSyntax classDeclarationSyntax) + { + var usingDirectiveSyntaxes = classDeclarationSyntax + .SyntaxTree + .GetCompilationUnitRoot() + .Usings; + + foreach (var usingDirectiveSyntax in usingDirectiveSyntaxes) + { + var nameString = usingDirectiveSyntax.Name?.ToString() ?? ""; + if (IsFrameworkUsing(nameString)) + { + return usingDirectiveSyntax.GetLocation(); + } + } + + return null; + } + + protected virtual bool HasFrameworkTypes(SyntaxNodeAnalysisContext context, INamedTypeSymbol namedTypeSymbol, ClassDeclarationSyntax classDeclarationSyntax) + { + var members = namedTypeSymbol.GetMembers(); + + // Check properties, return types, and fields + var types = members.OfType() + .Where(x => IsFrameworkType(x.Type)) + .Select(x => x.Type) + .Concat(members.OfType() + .Where(x => IsFrameworkType(x.ReturnType)) + .Select(x => x.ReturnType)) + .Concat(members.OfType() + .Where(x => IsFrameworkType(x.Type)) + .Select(x => x.Type)) + .ToArray(); + + if (types.Any()) + { + return true; + } + + // Check for framework type usage in method bodies (e.g., Assert.AreEqual(), CollectionAssert.Contains()) + var invocationExpressions = classDeclarationSyntax + .DescendantNodes() + .OfType(); + + foreach (var invocation in invocationExpressions) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is IMethodSymbol methodSymbol) + { + var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString(); + + // Explicitly exclude TUnit types (already converted code) + if (namespaceName != null && + (namespaceName == "TUnit.Assertions" || + namespaceName.StartsWith("TUnit.Assertions.") || + namespaceName == "TUnit.Core" || + namespaceName.StartsWith("TUnit.Core."))) + { + continue; // Skip TUnit types - they're not framework types to migrate + } + + // Check if the method belongs to a framework type + if (IsFrameworkType(methodSymbol.ContainingType)) + { + return true; + } + } + else if (symbolInfo.Symbol == null) + { + // Fallback: if symbol resolution fails completely, check the syntax directly + // This handles cases where the semantic model hasn't fully resolved types + // BUT: Don't apply this fallback if TUnit using directives are present (already converted code) + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + // Check if TUnit using directives are present + var usings = classDeclarationSyntax.SyntaxTree.GetCompilationUnitRoot().Usings; + var hasTUnitUsings = usings.Any(u => + { + var name = u.Name?.ToString() ?? ""; + return name == "TUnit.Core" || name == "TUnit.Assertions" || name.StartsWith("TUnit."); + }); + + // If TUnit usings are present, don't apply fallback detection + if (!hasTUnitUsings) + { + var typeExpression = memberAccess.Expression.ToString(); + if (IsFrameworkTypeName(typeExpression)) + { + return true; + } + } + } + } + } + + return false; + } + + protected virtual bool IsFrameworkTypeName(string typeName) + { + // Override in derived classes to provide framework-specific type names + return false; + } + + protected virtual bool IsFrameworkType(ITypeSymbol type) + { + return type.ContainingNamespace?.Name.StartsWith(TargetFrameworkNamespace) is true || + IsFrameworkNamespace(type.ContainingNamespace?.ToDisplayString()); + } + + protected virtual Location? AnalyzeAttributes(SyntaxNodeAnalysisContext context, ISymbol symbol, SyntaxNode syntaxNode) + { + var attributes = symbol.GetAttributes(); + + for (var i = 0; i < attributes.Length; i++) + { + var attributeData = attributes[i]; + var namespaceName = attributeData.AttributeClass?.ContainingNamespace?.Name; + var fullNamespace = attributeData.AttributeClass?.ContainingNamespace?.ToDisplayString(); + + if (namespaceName == TargetFrameworkNamespace || IsFrameworkNamespace(fullNamespace)) + { + // Get the attribute syntax for this specific attribute + var attributeSyntax = attributeData.ApplicationSyntaxReference?.GetSyntax(); + if (attributeSyntax != null) + { + // Get the parent AttributeListSyntax to include the brackets [...] + if (attributeSyntax.Parent is AttributeListSyntax attributeListSyntax) + { + // Return the location including brackets but excluding leading trivia + return Location.Create( + attributeListSyntax.SyntaxTree, + attributeListSyntax.Span); + } + return attributeSyntax.GetLocation(); + } + } + } + + return null; + } + + protected abstract bool IsFrameworkUsing(string usingName); + protected abstract bool IsFrameworkNamespace(string? namespaceName); + + protected void Flag(SyntaxNodeAnalysisContext context, Location location) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticRule, location)); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs new file mode 100644 index 0000000000..d5950980cd --- /dev/null +++ b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs @@ -0,0 +1,213 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace TUnit.Analyzers.Migrators.Base; + +public static class MigrationHelpers +{ + public static string ConvertTestAttributeName(string attributeName, string framework) + { + return framework switch + { + "XUnit" => attributeName switch + { + "Fact" => "Test", + "Theory" => "Test", + "InlineData" => "Arguments", + "MemberData" => "MethodDataSource", + "ClassData" => "MethodDataSource", + _ => attributeName + }, + "NUnit" => attributeName switch + { + "Test" => "Test", + "TestCase" => "Arguments", + "TestCaseSource" => "MethodDataSource", + "SetUp" => "Before", + "TearDown" => "After", + "OneTimeSetUp" => "Before", + "OneTimeTearDown" => "After", + "TestFixture" => null!, // Remove + _ => attributeName + }, + "MSTest" => attributeName switch + { + "TestMethod" => "Test", + "DataRow" => "Arguments", + "DynamicData" => "MethodDataSource", + "TestInitialize" => "Before", + "TestCleanup" => "After", + "ClassInitialize" => "Before", + "ClassCleanup" => "After", + "TestClass" => null!, // Remove + _ => attributeName + }, + _ => attributeName + }; + } + + public static AttributeListSyntax? ConvertHookAttribute(AttributeSyntax attribute, string framework) + { + var attributeName = GetAttributeName(attribute); + + var (newName, hookType) = framework switch + { + "NUnit" => attributeName switch + { + "SetUp" => ("Before", "Test"), + "TearDown" => ("After", "Test"), + "OneTimeSetUp" => ("Before", "Class"), + "OneTimeTearDown" => ("After", "Class"), + _ => (null, null) + }, + "MSTest" => attributeName switch + { + "TestInitialize" => ("Before", "Test"), + "TestCleanup" => ("After", "Test"), + "ClassInitialize" => ("Before", "Class"), + "ClassCleanup" => ("After", "Class"), + _ => (null, null) + }, + _ => (null, null) + }; + + if (newName == null || hookType == null) + { + return null; + } + + var newAttribute = SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName(newName), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("HookType"), + SyntaxFactory.IdentifierName(hookType) + ) + ) + ) + ) + ); + + return SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(newAttribute)); + } + + public static string GetAttributeName(AttributeSyntax attribute) + { + return attribute.Name switch + { + SimpleNameSyntax simpleName => simpleName.Identifier.Text, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.Text, + _ => "" + }; + } + + public static string GetSimpleName(string fullName) + { + var lastDot = fullName.LastIndexOf('.'); + return lastDot >= 0 ? fullName.Substring(lastDot + 1) : fullName; + } + + public static bool ShouldRemoveAttribute(string attributeName, string framework) + { + return framework switch + { + "NUnit" => attributeName is "TestFixture", + "MSTest" => attributeName is "TestClass", + _ => false + }; + } + + public static bool IsTestAttribute(string attributeName, string framework) + { + return framework switch + { + "XUnit" => attributeName is "Fact" or "Theory", + "NUnit" => attributeName is "Test" or "TestCase", + "MSTest" => attributeName is "TestMethod", + _ => false + }; + } + + public static bool IsDataAttribute(string attributeName, string framework) + { + return framework switch + { + "XUnit" => attributeName is "InlineData" or "MemberData" or "ClassData", + "NUnit" => attributeName is "TestCase" or "TestCaseSource", + "MSTest" => attributeName is "DataRow" or "DynamicData", + _ => false + }; + } + + public static bool IsHookAttribute(string attributeName, string framework) + { + return framework switch + { + "XUnit" => false, // XUnit uses constructors and IDisposable + "NUnit" => attributeName is "SetUp" or "TearDown" or "OneTimeSetUp" or "OneTimeTearDown", + "MSTest" => attributeName is "TestInitialize" or "TestCleanup" or "ClassInitialize" or "ClassCleanup", + _ => false + }; + } + + public static CompilationUnitSyntax RemoveFrameworkUsings(CompilationUnitSyntax compilationUnit, string framework) + { + var namespacesToRemove = framework switch + { + "XUnit" => new[] { "Xunit", "Xunit.Abstractions" }, + "NUnit" => new[] { "NUnit.Framework", "NUnit.Framework.Legacy" }, + "MSTest" => new[] { "Microsoft.VisualStudio.TestTools.UnitTesting" }, + _ => Array.Empty() + }; + + var usingsToKeep = compilationUnit.Usings + .Where(u => + { + var nameString = u.Name?.ToString() ?? ""; + return !namespacesToRemove.Any(ns => + nameString == ns || nameString.StartsWith(ns + ".")); + }) + .ToArray(); + + return compilationUnit.WithUsings(SyntaxFactory.List(usingsToKeep)); + } + + public static CompilationUnitSyntax AddTUnitUsings(CompilationUnitSyntax compilationUnit) + { + var tunitUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Core")); + // Add namespace using so Assert type name is available for Assert.That(...) syntax + var assertionsNamespaceUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Assertions")); + var assertionsStaticUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Assertions.Assert")) + .WithStaticKeyword(SyntaxFactory.Token(SyntaxKind.StaticKeyword)); + var extensionsUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Assertions.Extensions")); + + var existingUsings = compilationUnit.Usings.ToList(); + + if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Core")) + { + existingUsings.Add(tunitUsing); + } + + // Add namespace using so Assert type name is resolvable + if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions" && !u.StaticKeyword.IsKind(SyntaxKind.StaticKeyword))) + { + existingUsings.Add(assertionsNamespaceUsing); + } + + if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions.Assert" && u.StaticKeyword.IsKind(SyntaxKind.StaticKeyword))) + { + existingUsings.Add(assertionsStaticUsing); + } + + if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions.Extensions")) + { + existingUsings.Add(extensionsUsing); + } + + return compilationUnit.WithUsings(SyntaxFactory.List(existingUsings)); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/Migrators/MSTestMigrationAnalyzer.cs b/TUnit.Analyzers/Migrators/MSTestMigrationAnalyzer.cs new file mode 100644 index 0000000000..0d92e6f4af --- /dev/null +++ b/TUnit.Analyzers/Migrators/MSTestMigrationAnalyzer.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Migrators.Base; + +namespace TUnit.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MSTestMigrationAnalyzer : BaseMigrationAnalyzer +{ + protected override string TargetFrameworkNamespace => "Microsoft.VisualStudio.TestTools.UnitTesting"; + + protected override DiagnosticDescriptor DiagnosticRule => Rules.MSTestMigration; + + protected override bool IsFrameworkUsing(string usingName) + { + return usingName == "Microsoft.VisualStudio.TestTools.UnitTesting" || + usingName.StartsWith("Microsoft.VisualStudio.TestTools.UnitTesting."); + } + + protected override bool IsFrameworkNamespace(string? namespaceName) + { + if (namespaceName == null) + { + return false; + } + + return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" || + namespaceName.StartsWith("Microsoft.VisualStudio.TestTools.UnitTesting."); + } + + protected override bool IsFrameworkTypeName(string typeName) + { + // Check for MSTest assertion types by name (fallback when semantic model doesn't resolve) + return typeName == "Assert" || + typeName == "CollectionAssert" || + typeName == "StringAssert"; + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/Migrators/NUnitMigrationAnalyzer.cs b/TUnit.Analyzers/Migrators/NUnitMigrationAnalyzer.cs new file mode 100644 index 0000000000..f0e79fe843 --- /dev/null +++ b/TUnit.Analyzers/Migrators/NUnitMigrationAnalyzer.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Migrators.Base; + +namespace TUnit.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class NUnitMigrationAnalyzer : BaseMigrationAnalyzer +{ + protected override string TargetFrameworkNamespace => "NUnit"; + + protected override DiagnosticDescriptor DiagnosticRule => Rules.NUnitMigration; + + protected override bool IsFrameworkUsing(string usingName) + { + return usingName == "NUnit" || + usingName == "NUnit.Framework" || + usingName.StartsWith("NUnit.Framework."); + } + + protected override bool IsFrameworkNamespace(string? namespaceName) + { + if (namespaceName == null) + { + return false; + } + + return namespaceName == "NUnit.Framework" || + namespaceName.StartsWith("NUnit.Framework."); + } + + protected override bool IsFrameworkTypeName(string typeName) + { + // Check for NUnit assertion types by name (fallback when semantic model doesn't resolve) + // ClassicAssert is included to ensure analyzer detects it, even though code fixer doesn't convert it + return typeName == "Assert" || + typeName == "ClassicAssert" || + typeName == "CollectionAssert" || + typeName == "StringAssert" || + typeName == "FileAssert" || + typeName == "DirectoryAssert"; + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/Resources.resx index 1738e8ee1b..9fd6e42667 100644 --- a/TUnit.Analyzers/Resources.resx +++ b/TUnit.Analyzers/Resources.resx @@ -390,6 +390,24 @@ xUnit code can be converted to TUnit code + + NUnit code can be converted to TUnit code. + + + NUnit code can be converted to TUnit code + + + NUnit code can be converted to TUnit code + + + MSTest code can be converted to TUnit code. + + + MSTest code can be converted to TUnit code + + + MSTest code can be converted to TUnit code + Overwriting the Console writer can break TUnit logging. diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs index d09771845c..ef1829f032 100644 --- a/TUnit.Analyzers/Rules.cs +++ b/TUnit.Analyzers/Rules.cs @@ -129,6 +129,12 @@ public static class Rules public static readonly DiagnosticDescriptor XunitMigration = CreateDescriptor("TUXU0001", UsageCategory, DiagnosticSeverity.Info); + public static readonly DiagnosticDescriptor NUnitMigration = + CreateDescriptor("TUNU0001", UsageCategory, DiagnosticSeverity.Info); + + public static readonly DiagnosticDescriptor MSTestMigration = + CreateDescriptor("TUMS0001", UsageCategory, DiagnosticSeverity.Info); + public static readonly DiagnosticDescriptor OverwriteConsole = CreateDescriptor("TUnit0055", UsageCategory, DiagnosticSeverity.Warning); diff --git a/docs/docs/migration/mstest.md b/docs/docs/migration/mstest.md new file mode 100644 index 0000000000..57d8fe1a93 --- /dev/null +++ b/docs/docs/migration/mstest.md @@ -0,0 +1,296 @@ +# Migrating from MSTest + +## Using TUnit's Code Fixers + +TUnit has code fixers to help automate the migration from MSTest to TUnit. + +These code fixers will handle most common scenarios, but you'll likely still need to do some manual adjustments. If you encounter issues or have suggestions for improvements, please raise an issue. + +### Steps + +#### Install the TUnit packages to your test projects +Use your IDE or the dotnet CLI to add the TUnit packages to your test projects + +#### Remove the automatically added global usings +In your csproj add: + +```xml + + false + false + +``` + +This is temporary - Just to make sure no types clash, and so the code fixers can distinguish between MSTest and TUnit types with similar names. + +#### Rebuild the project +This ensures the TUnit packages have been restored and the analyzers should be loaded. + +#### Run the code fixer via the dotnet CLI + +`dotnet format analyzers --severity info --diagnostics TUMS0001` + +#### Revert step `Remove the automatically added global usings` + +#### Perform any manual bits that are still necessary +Review the converted code and make any necessary manual adjustments. +Raise an issue if you think something could be automated. + +#### Remove the MSTest packages +Simply uninstall them once you've migrated + +#### Done! (Hopefully) + +## Manual Migration Guide + +### Test Attributes + +`[TestClass]` - Remove this attribute (not needed in TUnit) + +`[TestMethod]` becomes `[Test]` + +`[DataRow]` becomes `[Arguments]` + +`[DynamicData]` becomes `[MethodDataSource]` + +`[TestCategory]` becomes `[Property("Category", "value")]` + +`[Ignore]` becomes `[Skip]` + +`[Priority]` becomes `[Property("Priority", "value")]` + +`[Owner]` becomes `[Property("Owner", "value")]` + +### Setup and Teardown + +`[TestInitialize]` becomes `[Before(HookType.Test)]` + +`[TestCleanup]` becomes `[After(HookType.Test)]` + +`[ClassInitialize]` becomes `[Before(HookType.Class)]` and remove the TestContext parameter + +`[ClassCleanup]` becomes `[After(HookType.Class)]` + +`[AssemblyInitialize]` becomes `[Before(HookType.Assembly)]` and remove the TestContext parameter + +`[AssemblyCleanup]` becomes `[After(HookType.Assembly)]` + +### Assertions + +#### Basic Assertions +```csharp +// MSTest +Assert.AreEqual(expected, actual); +Assert.AreNotEqual(expected, actual); +Assert.IsTrue(condition); +Assert.IsFalse(condition); +Assert.IsNull(value); +Assert.IsNotNull(value); + +// TUnit +await Assert.That(actual).IsEqualTo(expected); +await Assert.That(actual).IsNotEqualTo(expected); +await Assert.That(condition).IsTrue(); +await Assert.That(condition).IsFalse(); +await Assert.That(value).IsNull(); +await Assert.That(value).IsNotNull(); +``` + +#### Reference Assertions +```csharp +// MSTest +Assert.AreSame(expected, actual); +Assert.AreNotSame(expected, actual); + +// TUnit +await Assert.That(actual).IsSameReference(expected); +await Assert.That(actual).IsNotSameReference(expected); +``` + +#### Type Assertions +```csharp +// MSTest +Assert.IsInstanceOfType(value, typeof(string)); +Assert.IsNotInstanceOfType(value, typeof(int)); + +// TUnit +await Assert.That(value).IsAssignableTo(); +await Assert.That(value).IsNotAssignableTo(); +``` + +### Collection Assertions + +```csharp +// MSTest +CollectionAssert.AreEqual(expected, actual); +CollectionAssert.AreNotEqual(expected, actual); +CollectionAssert.Contains(collection, item); +CollectionAssert.DoesNotContain(collection, item); +CollectionAssert.AllItemsAreNotNull(collection); + +// TUnit +await Assert.That(actual).IsEquivalentTo(expected); +await Assert.That(actual).IsNotEquivalentTo(expected); +await Assert.That(collection).Contains(item); +await Assert.That(collection).DoesNotContain(item); +await Assert.That(collection).AllSatisfy(x => x != null); +``` + +### String Assertions + +```csharp +// MSTest +StringAssert.Contains(text, substring); +StringAssert.StartsWith(text, prefix); +StringAssert.EndsWith(text, suffix); +StringAssert.Matches(text, pattern); + +// TUnit +await Assert.That(text).Contains(substring); +await Assert.That(text).StartsWith(prefix); +await Assert.That(text).EndsWith(suffix); +await Assert.That(text).Matches(pattern); +``` + +### Exception Testing + +```csharp +// MSTest +Assert.ThrowsException(() => DoSomething()); +await Assert.ThrowsExceptionAsync(() => DoSomethingAsync()); + +// TUnit +await Assert.ThrowsAsync(() => DoSomething()); +await Assert.ThrowsAsync(() => DoSomethingAsync()); +``` + +### Test Data Sources + +#### DataRow +```csharp +// MSTest +[TestMethod] +[DataRow(1, 2, 3)] +[DataRow(10, 20, 30)] +public void AdditionTest(int a, int b, int expected) +{ + Assert.AreEqual(expected, a + b); +} + +// TUnit +[Test] +[Arguments(1, 2, 3)] +[Arguments(10, 20, 30)] +public async Task AdditionTest(int a, int b, int expected) +{ + await Assert.That(a + b).IsEqualTo(expected); +} +``` + +#### DynamicData +```csharp +// MSTest +[TestMethod] +[DynamicData(nameof(TestData), DynamicDataSourceType.Method)] +public void TestMethod(int value, string text) +{ + // Test implementation +} + +private static IEnumerable TestData() +{ + yield return new object[] { 1, "one" }; + yield return new object[] { 2, "two" }; +} + +// TUnit +[Test] +[MethodDataSource(nameof(TestData))] +public async Task TestMethod(int value, string text) +{ + // Test implementation +} + +private static IEnumerable<(int, string)> TestData() +{ + yield return (1, "one"); + yield return (2, "two"); +} +``` + +### TestContext Usage + +```csharp +// MSTest +[TestClass] +public class MyTests +{ + public TestContext TestContext { get; set; } + + [TestMethod] + public void MyTest() + { + TestContext.WriteLine("Test output"); + } + + [ClassInitialize] + public static void ClassInit(TestContext context) + { + // Setup code + } +} + +// TUnit +public class MyTests +{ + [Test] + public async Task MyTest(TestContext context) + { + await context.OutputWriter.WriteLineAsync("Test output"); + } + + [Before(HookType.Class)] + public static async Task ClassInit() + { + // Setup code - no TestContext parameter needed + } +} +``` + +### Assert.Fail + +```csharp +// MSTest +Assert.Fail("Test failed with reason"); + +// TUnit +Assert.Fail("Test failed with reason"); +``` + +### Inconclusive Tests + +```csharp +// MSTest +Assert.Inconclusive("Test is inconclusive"); + +// TUnit +Skip.Test("Test is inconclusive"); +``` + +## Key Differences to Note + +1. **Async by Default**: TUnit tests and assertions are async by default. Add `async Task` to your test methods and `await` assertions. + +2. **No TestClass Required**: TUnit doesn't require a `[TestClass]` attribute on test classes. + +3. **Fluent Assertions**: TUnit uses a fluent assertion style with `Assert.That()` as the starting point. + +4. **TestContext Changes**: + - TestContext is injected as a parameter rather than a property + - ClassInitialize and AssemblyInitialize don't receive TestContext parameters + +5. **Dependency Injection**: TUnit has built-in support for dependency injection in test classes and methods. + +6. **Hooks Instead of Initialize/Cleanup**: TUnit uses `[Before]` and `[After]` attributes with `HookType` to specify when they run. + +7. **Static Class-Level Hooks**: Class-level setup and teardown methods should be static in TUnit. \ No newline at end of file diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md new file mode 100644 index 0000000000..7b8d6658b6 --- /dev/null +++ b/docs/docs/migration/nunit.md @@ -0,0 +1,225 @@ +# Migrating from NUnit + +## Using TUnit's Code Fixers + +TUnit has code fixers to help automate the migration from NUnit to TUnit. + +These code fixers will handle most common scenarios, but you'll likely still need to do some manual adjustments. If you encounter issues or have suggestions for improvements, please raise an issue. + +### Steps + +#### Install the TUnit packages to your test projects +Use your IDE or the dotnet CLI to add the TUnit packages to your test projects + +#### Remove the automatically added global usings +In your csproj add: + +```xml + + false + false + +``` + +This is temporary - Just to make sure no types clash, and so the code fixers can distinguish between NUnit and TUnit types with similar names. + +#### Rebuild the project +This ensures the TUnit packages have been restored and the analyzers should be loaded. + +#### Run the code fixer via the dotnet CLI + +`dotnet format analyzers --severity info --diagnostics TUNU0001` + +#### Revert step `Remove the automatically added global usings` + +#### Perform any manual bits that are still necessary +Review the converted code and make any necessary manual adjustments. +Raise an issue if you think something could be automated. + +#### Remove the NUnit packages +Simply uninstall them once you've migrated + +#### Done! (Hopefully) + +## Manual Migration Guide + +### Test Attributes + +`[TestFixture]` - Remove this attribute (not needed in TUnit) + +`[Test]` remains `[Test]` + +`[TestCase]` becomes `[Arguments]` + +`[TestCaseSource]` becomes `[MethodDataSource]` + +`[Category]` becomes `[Property("Category", "value")]` + +`[Ignore]` becomes `[Skip]` + +`[Explicit]` becomes `[Explicit]` + +### Setup and Teardown + +`[SetUp]` becomes `[Before(HookType.Test)]` + +`[TearDown]` becomes `[After(HookType.Test)]` + +`[OneTimeSetUp]` becomes `[Before(HookType.Class)]` + +`[OneTimeTearDown]` becomes `[After(HookType.Class)]` + +### Assertions + +#### Classic Assertions +```csharp +// NUnit +Assert.AreEqual(expected, actual); +Assert.IsTrue(condition); +Assert.IsNull(value); +Assert.Greater(value1, value2); + +// TUnit +await Assert.That(actual).IsEqualTo(expected); +await Assert.That(condition).IsTrue(); +await Assert.That(value).IsNull(); +await Assert.That(value1).IsGreaterThan(value2); +``` + +#### Constraint-Based Assertions +```csharp +// NUnit +Assert.That(actual, Is.EqualTo(expected)); +Assert.That(value, Is.True); +Assert.That(value, Is.Null); +Assert.That(text, Does.Contain("substring")); +Assert.That(collection, Has.Count.EqualTo(5)); + +// TUnit +await Assert.That(actual).IsEqualTo(expected); +await Assert.That(value).IsTrue(); +await Assert.That(value).IsNull(); +await Assert.That(text).Contains("substring"); +await Assert.That(collection).HasCount().EqualTo(5); +``` + +### Collection Assertions + +```csharp +// NUnit +CollectionAssert.AreEqual(expected, actual); +CollectionAssert.Contains(collection, item); +CollectionAssert.IsEmpty(collection); + +// TUnit +await Assert.That(actual).IsEquivalentTo(expected); +await Assert.That(collection).Contains(item); +await Assert.That(collection).IsEmpty(); +``` + +### String Assertions + +```csharp +// NUnit +StringAssert.Contains(substring, text); +StringAssert.StartsWith(prefix, text); +StringAssert.EndsWith(suffix, text); + +// TUnit +await Assert.That(text).Contains(substring); +await Assert.That(text).StartsWith(prefix); +await Assert.That(text).EndsWith(suffix); +``` + +### Exception Testing + +```csharp +// NUnit +Assert.Throws(() => DoSomething()); +Assert.ThrowsAsync(async () => await DoSomethingAsync()); + +// TUnit +await Assert.ThrowsAsync(() => DoSomething()); +await Assert.ThrowsAsync(async () => await DoSomethingAsync()); +``` + +### Test Data Sources + +#### TestCaseSource +```csharp +// NUnit +[TestCaseSource(nameof(TestData))] +public void TestMethod(int value, string text) +{ + // Test implementation +} + +private static IEnumerable TestData() +{ + yield return new object[] { 1, "one" }; + yield return new object[] { 2, "two" }; +} + +// TUnit +[MethodDataSource(nameof(TestData))] +public async Task TestMethod(int value, string text) +{ + // Test implementation +} + +private static IEnumerable<(int, string)> TestData() +{ + yield return (1, "one"); + yield return (2, "two"); +} +``` + +### Parameterized Tests + +```csharp +// NUnit +[TestCase(1, 2, 3)] +[TestCase(10, 20, 30)] +public void AdditionTest(int a, int b, int expected) +{ + Assert.AreEqual(expected, a + b); +} + +// TUnit +[Test] +[Arguments(1, 2, 3)] +[Arguments(10, 20, 30)] +public async Task AdditionTest(int a, int b, int expected) +{ + await Assert.That(a + b).IsEqualTo(expected); +} +``` + +### Test Output + +```csharp +// NUnit +TestContext.WriteLine("Test output"); +TestContext.Out.WriteLine("More output"); + +// TUnit (inject TestContext) +public async Task MyTest(TestContext context) +{ + await context.OutputWriter.WriteLineAsync("Test output"); + await context.OutputWriter.WriteLineAsync("More output"); +} +``` + +## Key Differences to Note + +1. **Async by Default**: TUnit tests and assertions are async by default. Add `async Task` to your test methods and `await` assertions. + +2. **No TestFixture Required**: TUnit doesn't require a `[TestFixture]` attribute on test classes. + +3. **Fluent Assertions**: TUnit uses a fluent assertion style with `Assert.That()` as the starting point. + +4. **Dependency Injection**: TUnit has built-in support for dependency injection in test classes and methods. + +5. **Hooks Instead of Setup/Teardown**: TUnit uses `[Before]` and `[After]` attributes with `HookType` to specify when they run. + +6. **TestContext Injection**: Instead of a static `TestContext`, TUnit injects it as a parameter where needed. \ No newline at end of file