-
-
Notifications
You must be signed in to change notification settings - Fork 108
feat: comprehensive migration code fixer improvements #4202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Add extensive assertion conversion support for NUnit, MSTest, and xUnit migration: **Base Infrastructure (AssertionRewriter.cs)** - Format string message support via ExtractMessageWithFormatArgs() and CreateMessageExpression() - Custom comparer detection via IsLikelyComparerArgument() using semantic model - TODO comment generation for unsupported features **NUnit Additions** - Assert.Throws<T>() / Assert.ThrowsAsync<T>() → Throws.InstanceOf<T>() - Assert.Pass(), Assert.Fail(), Assert.Inconclusive(), Assert.Ignore() - Is.SubsetOf(), Is.SupersetOf(), Is.EquivalentTo() - Is.Unique → HasDistinctItems() - Is.Ordered → IsInAscendingOrder() - Is.InRange() → IsBetween() - Custom comparer detection with TODO comments **MSTest Additions** - Assert.Inconclusive() → Assert.Skip() - CollectionAssert.AreEquivalent() / AreNotEquivalent() - CollectionAssert.AllItemsAreUnique() → HasDistinctItems() - CollectionAssert.IsSubsetOf() / IsNotSubsetOf() - CollectionAssert.AllItemsAreInstancesOfType() **xUnit Additions** - Assert.ThrowsAny<T>() / ThrowsAnyAsync<T>() - Assert.Single() → HasSingleItem() - Assert.Distinct() → HasDistinctItems() - Assert.Subset() / Assert.Superset() - Assert.Equivalent() → IsEquivalentTo() - Assert.All() → AllSatisfy() - Comparer detection with TODO comments **New Rewriters** - AsyncMethodSignatureRewriter: converts void test methods to async Task when assertions are converted - TestAttributeEnsurer: adds [Test] attribute when data attributes exist (NUnit migration) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
SummaryThis PR adds comprehensive migration code fixer improvements for NUnit, MSTest, and xUnit migrations to TUnit, including format string message preservation, custom comparer detection, async method signature conversion, and test attribute ensuring. Critical IssuesNone found ✅ Suggestions1. String Format Safety (Low Priority)In /// <summary>
/// Creates a message expression, wrapping in string.Format if format args are present.
/// NOTE: This generates code; it does not execute string.Format. The format string
/// comes from the original test framework assertion being migrated.
/// </summary>This clarifies the security model for future maintainers. 2. AllInterfaces Performance (Low Priority)In 3. Test Coverage ClarificationThe PR description mentions "xUnit migration tests: 131/132 passed (1 infrastructure failure on net10.0)". Consider opening a follow-up issue to track this infrastructure failure, even if it's unrelated to this PR. TUnit Rules Compliance✅ Rule 1 (Dual-Mode): Not applicable - changes are to analyzers/code fixers only, not core engine metadata collection. ✅ Rule 2 (Snapshot Testing): No ✅ Rule 3 (No VSTest): No references to ✅ Rule 4 (Performance): Not applicable - code fixers run during migration (one-time), not in test execution hot paths. ✅ Rule 5 (AOT): No new reflection usage introduced. The Code Quality ObservationsStrengths:
Design Decisions:
Verdict✅ APPROVE - No critical issues. The suggestions above are optional improvements that don't block merging. This is a solid enhancement to TUnit's migration capabilities. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds comprehensive migration code fixer improvements for NUnit, MSTest, and xUnit, significantly expanding assertion conversion support and adding infrastructure for automatic async/await transformation and test attribute management.
Key Changes:
- Added
AsyncMethodSignatureRewriterto automatically convert void test methods to async Task when assertions are converted to await - Added
TestAttributeEnsurerto add [Test] attributes when data attributes exist (handles NUnit's [TestCase] → TUnit's [Test] + [Arguments] requirement) - Extended
AssertionRewriterbase class with message preservation support, format string handling viastring.Format(), and custom comparer detection using semantic model - Added extensive assertion conversions across all three frameworks (30+ new assertion types including collection assertions, subset/superset operations, and exception handling improvements)
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| TUnit.Analyzers.CodeFixers/Base/AsyncMethodSignatureRewriter.cs | New class to convert void methods to async Task when they contain await expressions |
| TUnit.Analyzers.CodeFixers/Base/TestAttributeEnsurer.cs | New class to ensure [Test] attribute is present when data attributes exist |
| TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs | Extended with message support, format args handling, and comparer detection |
| TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs | Integrated AsyncMethodSignatureRewriter and TestAttributeEnsurer into migration pipeline |
| TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs | Added comprehensive NUnit assertion conversions including Throws, Pass/Fail/Skip, and negation patterns |
| TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs | Added MSTest message preservation and collection assertions (AreEquivalent, AllItemsAreUnique, IsSubsetOf, etc.) |
| TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs | Added xUnit assertion conversions including ThrowsAny, IsNotType, Single, Distinct, Subset/Superset, and Equivalent |
| TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs | Added tests for new conversions and async signature transformation; removed redundant attribute mapping tests |
| TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs | Added tests for message preservation across assertion types |
| // Check if there are additional format arguments after the message | ||
| if (arguments.Count > messageIndex + 1) | ||
| { | ||
| var formatArgs = arguments.Skip(messageIndex + 1).ToArray(); | ||
| return (message, formatArgs); | ||
| } | ||
|
|
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ExtractMessageWithFormatArgs method doesn't validate that format arguments are actually format placeholders before attempting to wrap them in string.Format. If a testing framework allows passing extra parameters that aren't format args (like tolerance values or comparers), this could incorrectly wrap them in string.Format. Consider checking if the message expression is actually a string literal with format placeholders before treating additional arguments as format args.
| // Check if there are additional format arguments after the message | |
| if (arguments.Count > messageIndex + 1) | |
| { | |
| var formatArgs = arguments.Skip(messageIndex + 1).ToArray(); | |
| return (message, formatArgs); | |
| } | |
| // If there are no additional arguments after the message, there's nothing to format. | |
| if (arguments.Count <= messageIndex + 1) | |
| { | |
| return (message, null); | |
| } | |
| // Only treat trailing arguments as format arguments when the message is a string | |
| // literal that actually contains format placeholders like "{0}", "{1}", etc. | |
| if (message is LiteralExpressionSyntax literal && | |
| literal.IsKind(SyntaxKind.StringLiteralExpression)) | |
| { | |
| var text = literal.Token.ValueText; | |
| var hasPlaceholder = false; | |
| for (var i = 0; i < text.Length - 1; i++) | |
| { | |
| if (text[i] == '{' && char.IsDigit(text[i + 1])) | |
| { | |
| hasPlaceholder = true; | |
| break; | |
| } | |
| } | |
| if (hasPlaceholder) | |
| { | |
| var formatArgs = arguments.Skip(messageIndex + 1).ToArray(); | |
| return (message, formatArgs); | |
| } | |
| } | |
| // Message is not a format string; ignore trailing arguments for formatting purposes. |
| // Create a lambda: x => x.GetType() == expectedType or x is Type | ||
| var isExpression = SyntaxFactory.IsPatternExpression( | ||
| SyntaxFactory.IdentifierName("x"), | ||
| SyntaxFactory.DeclarationPattern( | ||
| SyntaxFactory.IdentifierName("_"), | ||
| SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("_")) | ||
| ) | ||
| ); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 392 mentions creating "x => x.GetType() == expectedType or x is Type", but the implementation on lines 393-399 creates an incomplete IsPatternExpression that is never used. The variable isExpression is declared but never referenced. This dead code should be removed.
| return result.WithLeadingTrivia( | ||
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); | ||
| } | ||
|
|
||
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | ||
| { | ||
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | ||
| return result.WithLeadingTrivia( | ||
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded whitespace string with 16 spaces creates fragile indentation that will break when placed at different nesting levels. The leading trivia from the original node should be preserved instead to maintain consistent indentation in the migrated code.
| return result.WithLeadingTrivia( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | |
| SyntaxFactory.EndOfLine("\n"), | |
| SyntaxFactory.Whitespace(" ")); | |
| } | |
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | |
| { | |
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | |
| return result.WithLeadingTrivia( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | |
| SyntaxFactory.EndOfLine("\n"), | |
| SyntaxFactory.Whitespace(" ")); | |
| var originalLeadingTrivia = result.GetLeadingTrivia(); | |
| return result.WithLeadingTrivia( | |
| SyntaxFactory.TriviaList( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | |
| SyntaxFactory.EndOfLine("\n")) | |
| .AddRange(originalLeadingTrivia)); | |
| } | |
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | |
| { | |
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | |
| var originalLeadingTrivia = result.GetLeadingTrivia(); | |
| return result.WithLeadingTrivia( | |
| SyntaxFactory.TriviaList( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | |
| SyntaxFactory.EndOfLine("\n")) | |
| .AddRange(originalLeadingTrivia)); |
| return result.WithLeadingTrivia( | ||
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); | ||
| } | ||
|
|
||
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | ||
| { | ||
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | ||
| return result.WithLeadingTrivia( | ||
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded whitespace string with 16 spaces creates fragile indentation that will break when placed at different nesting levels. The leading trivia from the original node should be preserved instead to maintain consistent indentation in the migrated code.
| return result.WithLeadingTrivia( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | |
| SyntaxFactory.EndOfLine("\n"), | |
| SyntaxFactory.Whitespace(" ")); | |
| } | |
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | |
| { | |
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | |
| return result.WithLeadingTrivia( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | |
| SyntaxFactory.EndOfLine("\n"), | |
| SyntaxFactory.Whitespace(" ")); | |
| var updatedTrivia = SyntaxFactory.TriviaList( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | |
| SyntaxFactory.ElasticCarriageReturnLineFeed) | |
| .AddRange(result.GetLeadingTrivia()); | |
| return result.WithLeadingTrivia(updatedTrivia); | |
| } | |
| private ExpressionSyntax CreateNotEqualWithComparerComment(SeparatedSyntaxList<ArgumentSyntax> arguments) | |
| { | |
| var result = CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]); | |
| var updatedTrivia = SyntaxFactory.TriviaList( | |
| SyntaxFactory.Comment("// TODO: TUnit migration - custom comparer was used. Consider using a custom condition."), | |
| SyntaxFactory.ElasticCarriageReturnLineFeed) | |
| .AddRange(result.GetLeadingTrivia()); | |
| return result.WithLeadingTrivia(updatedTrivia); |
| return result.WithLeadingTrivia( | ||
| CreateTodoComment("custom comparer was used - consider using Assert.That(...).IsEquivalentTo() or a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded whitespace string with 16 spaces creates fragile indentation that will break when placed at different nesting levels. The leading trivia from the original node should be preserved instead to maintain consistent indentation in the migrated code.
| return result.WithLeadingTrivia( | ||
| CreateTodoComment("custom comparer was used - consider using a custom condition."), | ||
| SyntaxFactory.EndOfLine("\n"), | ||
| SyntaxFactory.Whitespace(" ")); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded whitespace string with 16 spaces creates fragile indentation that will break when placed at different nesting levels. The leading trivia from the original node should be preserved instead to maintain consistent indentation in the migrated code.
| private ExpressionSyntax ConvertThrowsAnyAsync(InvocationExpressionSyntax invocation, SimpleNameSyntax nameNode) | ||
| { | ||
| // Same as ThrowsAny but for async | ||
| return ConvertThrowsAny(invocation, nameNode); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says "Same as ThrowsAny but for async", but ConvertThrowsAny already converts to ThrowsAsync. This means ThrowsAnyAsync is being converted to ThrowsAsync (correct), but ThrowsAny is also being converted to ThrowsAsync (incorrect - synchronous xUnit ThrowsAny should convert to synchronous TUnit Throws, not async ThrowsAsync). The ConvertThrowsAny method name and implementation are misleading and create a synchronous/async mismatch.
| // T -> Task<T> | ||
| var innerType = returnType.WithoutTrivia(); | ||
| return SyntaxFactory.GenericName("Task") | ||
| .WithTypeArgumentList( | ||
| SyntaxFactory.TypeArgumentList( | ||
| SyntaxFactory.SingletonSeparatedList(innerType))) | ||
| .WithLeadingTrivia(returnType.GetLeadingTrivia()) | ||
| .WithTrailingTrivia(returnType.GetTrailingTrivia()); |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The conversion logic incorrectly wraps any non-void return type in Task, which will break methods that already return Task or Task<T>. For example, a method returning Task<int> would become async Task<Task<int>>. The method should check if the return type is already a Task or Task<T> before wrapping it.
| foreach (var attribute in attributeList.Attributes) | ||
| { | ||
| var name = GetAttributeName(attribute); | ||
| if (DataAttributeNames.Contains(name)) | ||
| { | ||
| return true; | ||
| } | ||
| } |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.
| foreach (var attribute in attributeList.Attributes) | ||
| { | ||
| var name = GetAttributeName(attribute); | ||
| if (name == "Test") | ||
| { | ||
| return true; | ||
| } | ||
| } |
Copilot
AI
Jan 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.
Summary
Assert.AreEqual(5, x, "Expected {0}", x)→string.Format())AsyncMethodSignatureRewriterto convert void test methods to async Task when assertions are convertedTestAttributeEnsurerto add [Test] attribute when data attributes exist (NUnit → TUnit migration)NUnit Additions
Assert.Throws<T>()/Assert.ThrowsAsync<T>()→Throws.InstanceOf<T>()Assert.Pass(),Assert.Fail(),Assert.Inconclusive(),Assert.Ignore()Is.SubsetOf(),Is.SupersetOf(),Is.EquivalentTo()Is.Unique→HasDistinctItems()Is.Ordered→IsInAscendingOrder()Is.InRange()→IsBetween()MSTest Additions
Assert.Inconclusive()→Assert.Skip()CollectionAssert.AreEquivalent()/AreNotEquivalent()CollectionAssert.AllItemsAreUnique()→HasDistinctItems()CollectionAssert.IsSubsetOf()/IsNotSubsetOf()CollectionAssert.AllItemsAreInstancesOfType()xUnit Additions
Assert.ThrowsAny<T>()/ThrowsAnyAsync<T>()Assert.Single()→HasSingleItem()Assert.Distinct()→HasDistinctItems()Assert.Subset()/Assert.Superset()Assert.Equivalent()→IsEquivalentTo()Assert.All()→AllSatisfy()Test plan
🤖 Generated with Claude Code