Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
24b7c83
feat: add NUnit and MSTest migration analyzers and code fixers
thomhurst Sep 23, 2025
841edef
docs: add NUnit and MSTest migration guides
thomhurst Sep 24, 2025
fbb4798
fix: add missing using directive for MigrationHelpers in code fixers
thomhurst Sep 24, 2025
ee4c39e
Merge branch 'main' into feature/nunit-migrate
thomhurst Sep 24, 2025
70c484c
fix: add missing diagnostic verifications for MSTest and NUnit migrat…
thomhurst Sep 24, 2025
0a9cee1
fix: address critical issues in migration analyzers
claude[bot] Sep 24, 2025
89f3daa
Merge branch 'main' into feature/nunit-migrate
thomhurst Oct 2, 2025
bea79c6
Merge branch 'main' into feature/nunit-migrate
thomhurst Oct 8, 2025
e42ba6c
Merge branch 'main' into feature/nunit-migrate
thomhurst Oct 10, 2025
f5936d8
refactor: enhance attribute analysis to return location for diagnostics
thomhurst Oct 10, 2025
30d8f60
refactor: include brackets location in attribute analysis
thomhurst Oct 11, 2025
7a80e69
refactor: reorganize migration process and enhance framework type det…
thomhurst Oct 11, 2025
368aaab
refactor: update assertion conversion methods to return ExpressionSyn…
thomhurst Oct 12, 2025
f7e8772
Merge branch 'main' into feature/nunit-migrate
thomhurst Oct 12, 2025
6fbf564
refactor: update assertion methods to return ExpressionSyntax for imp…
thomhurst Oct 12, 2025
0d6cf5c
refactor: enhance migration analyzers and tests for TUnit compatibility
thomhurst Oct 12, 2025
e1b5c54
refactor: add solution transform to normalize line endings for cross-…
thomhurst Oct 12, 2025
bcf7007
refactor: replace DefaultVerifier with LineEndingNormalizingVerifier …
thomhurst Oct 12, 2025
066c33f
refactor: update NormalizeLineEndings method to ensure platform-nativ…
thomhurst Oct 12, 2025
70ce7b6
refactor: update NormalizeLineEndings method to ensure CRLF line endi…
thomhurst Oct 12, 2025
1905377
refactor: format document in BaseMigrationCodeFixProvider to ensure c…
thomhurst Oct 12, 2025
00da093
refactor: remove unused NUnitMigrationAnalyzerTests methods for clean…
thomhurst Oct 12, 2025
e53508b
fix: normalize line endings to CRLF after formatting for cross-platfo…
thomhurst Oct 12, 2025
8468d0d
refactor: remove unnecessary whitespace in MSTestMigrationAnalyzerTes…
thomhurst Oct 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: reorganize migration process and enhance framework type det…
…ection
  • Loading branch information
thomhurst committed Oct 11, 2025
commit 7a80e69507c1e59636f867e0c1f733ba482d5c2f
28 changes: 14 additions & 14 deletions TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,29 +48,29 @@ protected async Task<Document> ConvertCodeAsync(Document document, SyntaxNode? r

try
{
// Remove framework usings and add TUnit usings
compilationUnit = MigrationHelpers.RemoveFrameworkUsings(compilationUnit, FrameworkName);
compilationUnit = MigrationHelpers.AddTUnitUsings(compilationUnit);

// Convert attributes
var attributeRewriter = CreateAttributeRewriter();
compilationUnit = (CompilationUnitSyntax)attributeRewriter.Visit(compilationUnit);

// Convert assertions
// Convert assertions FIRST (while semantic model still matches the syntax tree)
var assertionRewriter = CreateAssertionRewriter(semanticModel);
compilationUnit = (CompilationUnitSyntax)assertionRewriter.Visit(compilationUnit);
// Framework-specific conversions

// 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);

return document.WithSyntaxRoot(compilationUnit);
}
catch
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName)
return ConvertAssertThat(invocation);
}

// Handle classic assertions like Assert.AreEqual, Assert.IsTrue, etc.
// Handle classic assertions like Assert.AreEqual, ClassicAssert.AreEqual, etc.
if (invocation.Expression is MemberAccessExpressionSyntax classicMemberAccess &&
classicMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" })
classicMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" or "ClassicAssert" })
{
return ConvertClassicAssertion(invocation, classicMemberAccess.Name.Identifier.Text);
}
Expand Down
6 changes: 1 addition & 5 deletions TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,11 @@ public void MyMethod()

private static void ConfigureMSTestTest(Verifier.Test test)
{
var globalUsings = ("GlobalUsings.cs", SourceText.From("global using Microsoft.VisualStudio.TestTools.UnitTesting;"));
test.TestState.Sources.Add(globalUsings);
test.TestState.AdditionalReferences.Add(typeof(TestMethodAttribute).Assembly);
}

private static void ConfigureMSTestTest(CodeFixer.Test test)
{
var globalUsings = ("GlobalUsings.cs", SourceText.From("global using Microsoft.VisualStudio.TestTools.UnitTesting;"));
test.TestState.Sources.Add(globalUsings);
test.TestState.AdditionalReferences.Add(typeof(TestMethodAttribute).Assembly);
test.FixedState.AdditionalReferences.Add(typeof(TestMethodAttribute).Assembly);
}
Expand Down
17 changes: 7 additions & 10 deletions TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,12 @@ 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()
public void MyMethod()
{
ClassicAssert.AreEqual(5, 5);
ClassicAssert.IsTrue(true);
Expand All @@ -160,7 +161,7 @@ public void MyMethod()
public class MyClass
{
[Test]
public void MyMethod()
public void MyMethod()
{
await Assert.That(5).IsEqualTo(5);
await Assert.That(true).IsTrue();
Expand Down Expand Up @@ -260,16 +261,12 @@ public void MyMethod() { }

private static void ConfigureNUnitTest(Verifier.Test test)
{
var globalUsings = ("GlobalUsings.cs", SourceText.From("global using NUnit.Framework;"));
test.TestState.Sources.Add(globalUsings);
test.TestState.AdditionalReferences.Add(typeof(TestAttribute).Assembly);
test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly);
}

private static void ConfigureNUnitTest(CodeFixer.Test test)
{
var globalUsings = ("GlobalUsings.cs", SourceText.From("global using NUnit.Framework;"));
test.TestState.Sources.Add(globalUsings);
test.TestState.AdditionalReferences.Add(typeof(TestAttribute).Assembly);
test.FixedState.AdditionalReferences.Add(typeof(TestAttribute).Assembly);
test.TestState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly);
test.FixedState.AdditionalReferences.Add(typeof(NUnit.Framework.TestAttribute).Assembly);
}
}
105 changes: 93 additions & 12 deletions TUnit.Analyzers/Migrators/Base/BaseMigrationAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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;

Expand Down Expand Up @@ -39,19 +42,39 @@ private void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
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<IMethodSymbol>())
{
var syntaxReferences = methodSymbol.DeclaringSyntaxReferences;
Expand All @@ -64,21 +87,34 @@ private void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
var methodAttributeLocation = AnalyzeAttributes(context, methodSymbol, methodSyntax);
if (methodAttributeLocation != null)
{
Flag(context, methodAttributeLocation);
return;
methodsWithFrameworkAttributes.Add((methodSymbol, methodAttributeLocation));
}
}

var usingLocation = CheckUsingDirectives(classDeclarationSyntax);
if (usingLocation != null)
// 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)
{
Flag(context, usingLocation);
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;
}

if (HasFrameworkTypes(symbol))
// Priority 5 (lowest): Using directives - only flag if nothing else was found
var usingLocation = CheckUsingDirectives(classDeclarationSyntax);
if (usingLocation != null)
{
Flag(context, classDeclarationSyntax.GetLocation());
Flag(context, usingLocation);
return;
}
}
Expand Down Expand Up @@ -110,10 +146,11 @@ protected virtual bool HasFrameworkInterfaces(INamedTypeSymbol symbol)
return null;
}

protected virtual bool HasFrameworkTypes(INamedTypeSymbol namedTypeSymbol)
protected virtual bool HasFrameworkTypes(SyntaxNodeAnalysisContext context, INamedTypeSymbol namedTypeSymbol, ClassDeclarationSyntax classDeclarationSyntax)
{
var members = namedTypeSymbol.GetMembers();

// Check properties, return types, and fields
var types = members.OfType<IPropertySymbol>()
.Where(x => IsFrameworkType(x.Type))
.Select(x => x.Type)
Expand All @@ -125,7 +162,49 @@ protected virtual bool HasFrameworkTypes(INamedTypeSymbol namedTypeSymbol)
.Select(x => x.Type))
.ToArray();

return types.Any();
if (types.Any())
{
return true;
}

// Check for framework type usage in method bodies (e.g., Assert.AreEqual(), CollectionAssert.Contains())
var invocationExpressions = classDeclarationSyntax
.DescendantNodes()
.OfType<InvocationExpressionSyntax>();

foreach (var invocation in invocationExpressions)
{
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
if (symbolInfo.Symbol is IMethodSymbol methodSymbol)
{
// 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
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
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)
Expand All @@ -151,10 +230,12 @@ protected virtual bool IsFrameworkType(ITypeSymbol type)
if (attributeSyntax != null)
{
// Get the parent AttributeListSyntax to include the brackets [...]
var attributeListSyntax = attributeSyntax.Parent;
if (attributeListSyntax != null)
if (attributeSyntax.Parent is AttributeListSyntax attributeListSyntax)
{
return attributeListSyntax.GetLocation();
// Return the location including brackets but excluding leading trivia
return Location.Create(
attributeListSyntax.SyntaxTree,
attributeListSyntax.Span);
}
return attributeSyntax.GetLocation();
}
Expand Down
16 changes: 8 additions & 8 deletions TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public static CompilationUnitSyntax RemoveFrameworkUsings(CompilationUnitSyntax
var namespacesToRemove = framework switch
{
"XUnit" => new[] { "Xunit", "Xunit.Abstractions" },
"NUnit" => new[] { "NUnit.Framework" },
"NUnit" => new[] { "NUnit.Framework", "NUnit.Framework.Legacy" },
"MSTest" => new[] { "Microsoft.VisualStudio.TestTools.UnitTesting" },
_ => Array.Empty<string>()
};
Expand All @@ -168,7 +168,7 @@ public static CompilationUnitSyntax RemoveFrameworkUsings(CompilationUnitSyntax
.Where(u =>
{
var nameString = u.Name?.ToString() ?? "";
return !namespacesToRemove.Any(ns =>
return !namespacesToRemove.Any(ns =>
nameString == ns || nameString.StartsWith(ns + "."));
})
.ToArray();
Expand All @@ -179,22 +179,22 @@ public static CompilationUnitSyntax RemoveFrameworkUsings(CompilationUnitSyntax
public static CompilationUnitSyntax AddTUnitUsings(CompilationUnitSyntax compilationUnit)
{
var tunitUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Core"));
var assertionsUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("TUnit.Assertions"))
var assertionsUsing = 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);
}
if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions" && u.StaticKeyword.IsKind(SyntaxKind.StaticKeyword)))

if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions.Assert" && u.StaticKeyword.IsKind(SyntaxKind.StaticKeyword)))
{
existingUsings.Add(assertionsUsing);
}

if (!existingUsings.Any(u => u.Name?.ToString() == "TUnit.Assertions.Extensions"))
{
existingUsings.Add(extensionsUsing);
Expand Down
12 changes: 10 additions & 2 deletions TUnit.Analyzers/Migrators/MSTestMigrationAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,16 @@ protected override bool IsFrameworkNamespace(string? namespaceName)
{
return false;
}
return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" ||

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";
}
}
15 changes: 13 additions & 2 deletions TUnit.Analyzers/Migrators/NUnitMigrationAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@ protected override bool IsFrameworkNamespace(string? namespaceName)
{
return false;
}
return namespaceName == "NUnit.Framework" ||

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)
return typeName == "Assert" ||
typeName == "ClassicAssert" ||
typeName == "CollectionAssert" ||
typeName == "StringAssert" ||
typeName == "FileAssert" ||
typeName == "DirectoryAssert";
}
}
Loading