-
-
Notifications
You must be signed in to change notification settings - Fork 108
feat: properly migrate NUnit TestCase properties to TUnit attributes #4209
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
Instead of adding TODO comments for unsupported TestCase properties,
this change properly converts them to their TUnit equivalents:
- TestName → [DisplayName("...")]
- Category → [Category("...")] (all unique categories collected)
- Description → [Property("Description", "...")]
- Author → [Property("Author", "...")]
- Explicit/ExplicitReason → [Explicit] + [Property("ExplicitReason", "...")]
Changes:
- Add NUnitTestCasePropertyRewriter: method-level rewriter that extracts
properties from all TestCase attributes on a method and generates
corresponding TUnit attributes
- Fix ConvertCategoryArguments: keep [Category] as-is since TUnit has a
native Category attribute (was incorrectly converting to Property)
- Reorder rewriters: property extraction runs BEFORE ExpectedResult
conversion so properties are captured from original TestCase attributes
- Add 8 new tests covering property mapping scenarios
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryImproves NUnit→TUnit migration by properly converting TestCase properties (TestName, Category, Description, Author, Explicit) to their TUnit equivalents instead of leaving TODO comments. Critical Issues1. Potential String Interpolation Parsing Bug (NUnitTestCasePropertyRewriter.cs:138) The GetStringValue method has a fallback that may produce incorrect results for non-literal strings. The expression.ToString().Trim('"') will produce invalid results for interpolated strings, string concatenation, or const references. Impact: When users have TestCase properties with non-literal strings, the migration will produce incorrect DisplayName/Category/Property values that may not compile or have wrong runtime values. Recommendation: Return null for non-literal expressions and document that only literal strings are migrated automatically. 2. Missing Snapshot Test Verification Per TUnit Rule 2 (Snapshot Testing), changes to analyzer output require verifying snapshot tests. This PR modifies code fixer output significantly. Need to verify whether snapshots were updated if the test framework uses them. Suggestions1. Performance: Unnecessary Allocations (NUnitTestCasePropertyRewriter.cs:60-66) Multiple HashSet allocations per method even when no properties exist. Suggest early return if no named arguments exist before allocating TestCaseProperties. 2. DisplayName Conflict Handling (NUnitTestCasePropertyRewriter.cs:208-221) When multiple TestCases have different TestNames, the code uses .First() which may be arbitrary. Consider not generating DisplayName if TestNames differ. 3. Code Clarity: Comment Accuracy (NUnitMigrationCodeFixProvider.cs:40-43) The old comment at line 40 should be removed to avoid confusion with the new ordering. Verdict |
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 improves NUnit to TUnit migration by properly converting TestCase properties to their TUnit equivalents instead of leaving TODO comments. The implementation adds a new NUnitTestCasePropertyRewriter that extracts properties from TestCase attributes and generates corresponding TUnit attributes (TestName → DisplayName, Category → Category, Description/Author → Property, Explicit → Explicit). The rewriter runs before ExpectedResult conversion to capture properties from original attributes.
Key changes:
- New property extraction rewriter that consolidates properties from all TestCase attributes on a method
- Fixed Category conversion to preserve native TUnit Category attribute instead of incorrectly converting to Property
- Execution order ensures properties are extracted before TestCase attributes are transformed
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs | New rewriter that extracts TestCase properties and generates TUnit attributes |
| TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs | Integrates property rewriter and fixes Category conversion |
| TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs | Updates to skip properties now handled by property rewriter |
| TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs | Adds 8 tests covering property conversion scenarios |
| TestCaseProperties properties, | ||
| SyntaxList<AttributeListSyntax> existingAttributeLists) | ||
| { | ||
| var newLists = new List<AttributeListSyntax>(existingAttributeLists); |
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.
According to TUnit coding guidelines, use collection expressions (C# 12+). This should be:
var newLists = new List<AttributeListSyntax>(existingAttributeLists);should be:
List<AttributeListSyntax> newLists = [..existingAttributeLists];| public HashSet<string> TestNames { get; } = new(); | ||
| public HashSet<string> Categories { get; } = new(); | ||
| public HashSet<string> Descriptions { get; } = new(); | ||
| public HashSet<string> Authors { get; } = new(); | ||
| public bool IsExplicit { get; set; } | ||
| public HashSet<string> ExplicitReasons { get; } = new(); |
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.
According to TUnit coding guidelines, use collection expressions (C# 12+). These HashSet initializations in the TestCaseProperties class should use collection expression syntax:
public HashSet<string> TestNames { get; } = new();should be:
public HashSet<string> TestNames { get; } = [];Apply the same change to all other HashSet properties (Categories, Descriptions, Authors, ExplicitReasons).
| [Test] | ||
| [Arguments(1)] | ||
| [Arguments(2)] | ||
| [DisplayName("Addition of one")] |
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 test has a logical issue: when there are two TestCase attributes with different TestName values ("Addition of one" and "Addition of two"), only one DisplayName attribute is generated, using the first value. This means the second test case loses its custom name.
In NUnit, each TestCase with TestName creates a separate test with that name. In TUnit, each Arguments attribute creates a separate test, but there's only one DisplayName that applies to all of them. The expected output should either:
- Not generate a DisplayName when TestNames differ across TestCase attributes (preserving default behavior)
- Generate multiple test methods instead of one parameterized method
- Document that this is a known limitation
The current behavior silently drops "Addition of two" which could be unexpected.
| [DisplayName("Addition of one")] |
| // Handle interpolated strings or other expressions by getting the text | ||
| return expression.ToString().Trim('"'); |
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 fallback logic here is problematic. When the expression is not a string literal (e.g., a constant, interpolated string, or complex expression), calling ToString().Trim('"') on the syntax node is unreliable:
- Interpolated strings like $"Test {value}" will have their syntax representation trimmed, not their actual value
- Constants or other expressions won't have quotes to trim
- This could produce invalid attribute syntax
Consider either:
- Only handling string literals and returning null for other cases (safest)
- Using the semantic model to evaluate compile-time constants
- Preserving the expression as-is without string manipulation
The current implementation risks generating malformed code for non-literal expressions.
| // Handle interpolated strings or other expressions by getting the text | |
| return expression.ToString().Trim('"'); | |
| // For non-literal expressions, preserve the expression text as-is. | |
| // This avoids trying to interpret or partially trim the syntax representation. | |
| return expression.ToString(); |
| @@ -0,0 +1,281 @@ | |||
| using System.Linq; | |||
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.
Missing using directive for System.Collections.Generic. The code uses List and HashSet but only includes System.Linq. While this might compile due to implicit global usings in newer .NET versions, it's better to be explicit for clarity and compatibility.
| [Category("Unit")] | ||
| [Category("Integration")] |
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 test expects both "Unit" and "Integration" categories to be applied to the method, but this doesn't match NUnit semantics. In NUnit, each TestCase attribute creates a separate test instance, so TestCase(1, Category = "Unit") creates one test in the "Unit" category, and TestCase(2, Category = "Integration") creates a different test in the "Integration" category.
The converted TUnit code applies BOTH categories to the same method, meaning both Arguments(1) and Arguments(2) will be tagged with both categories, which is incorrect. Each test variant should only have its corresponding category.
TUnit's architecture may not support per-argument categorization (since [Category] is a method-level attribute), making this an architectural limitation that should be documented rather than silently producing incorrect behavior.
| [Category("Unit")] | |
| [Category("Integration")] | |
| // NOTE: Per-argument categories (Unit/Integration) from NUnit TestCase attributes | |
| // are not preserved because TUnit only supports method-level categories. |
|
|
||
| private List<AttributeSyntax> GetTestCaseAttributes(MethodDeclarationSyntax method) | ||
| { | ||
| var result = new List<AttributeSyntax>(); |
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.
According to TUnit coding guidelines, use collection expressions (C# 12+) instead of the old initialization syntax. This should be:
var result = new List<AttributeSyntax>();should be:
List<AttributeSyntax> result = [];| return attribute; | ||
| } | ||
|
|
||
| var newArgs = new List<AttributeArgumentSyntax>(); |
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.
According to TUnit coding guidelines, use collection expressions (C# 12+). This should be:
var newArgs = new List<AttributeArgumentSyntax>();should be:
List<AttributeArgumentSyntax> newArgs = [];| foreach (var trivia in triviaList) | ||
| { | ||
| if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) | ||
| { | ||
| return trivia; | ||
| } |
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 implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
| foreach (var trivia in triviaList) | |
| { | |
| if (trivia.IsKind(SyntaxKind.WhitespaceTrivia)) | |
| { | |
| return trivia; | |
| } | |
| var whitespaceTrivia = triviaList.FirstOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); | |
| if (whitespaceTrivia.IsKind(SyntaxKind.WhitespaceTrivia)) | |
| { | |
| return whitespaceTrivia; |
| foreach (var category in properties.Categories.Distinct()) | ||
| { | ||
| var categoryAttr = SyntaxFactory.Attribute( | ||
| SyntaxFactory.IdentifierName("Category"), | ||
| SyntaxFactory.AttributeArgumentList( | ||
| SyntaxFactory.SingletonSeparatedList( | ||
| SyntaxFactory.AttributeArgument( | ||
| SyntaxFactory.LiteralExpression( | ||
| SyntaxKind.StringLiteralExpression, | ||
| SyntaxFactory.Literal(category)))))); | ||
| newLists.Add(CreateAttributeList(categoryAttr, indentation)); | ||
| } |
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 category in properties.Categories.Distinct()) | |
| { | |
| var categoryAttr = SyntaxFactory.Attribute( | |
| SyntaxFactory.IdentifierName("Category"), | |
| SyntaxFactory.AttributeArgumentList( | |
| SyntaxFactory.SingletonSeparatedList( | |
| SyntaxFactory.AttributeArgument( | |
| SyntaxFactory.LiteralExpression( | |
| SyntaxKind.StringLiteralExpression, | |
| SyntaxFactory.Literal(category)))))); | |
| newLists.Add(CreateAttributeList(categoryAttr, indentation)); | |
| } | |
| var categoryAttributeLists = properties.Categories | |
| .Distinct() | |
| .Select(category => | |
| CreateAttributeList( | |
| SyntaxFactory.Attribute( | |
| SyntaxFactory.IdentifierName("Category"), | |
| SyntaxFactory.AttributeArgumentList( | |
| SyntaxFactory.SingletonSeparatedList( | |
| SyntaxFactory.AttributeArgument( | |
| SyntaxFactory.LiteralExpression( | |
| SyntaxKind.StringLiteralExpression, | |
| SyntaxFactory.Literal(category)))))), | |
| indentation)); | |
| newLists = newLists.AddRange(categoryAttributeLists); |
1. Fix GetStringValue bug: return null for non-literal expressions (interpolated strings, concatenation, const refs are not migrated) 2. Add performance optimization: early return when no named arguments exist in TestCase attributes (avoids unnecessary HashSet allocations) 3. Fix DisplayName conflict: only generate DisplayName when all TestCases have the same TestName (multiple different names can't be represented) 4. Add test for different TestName handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR properly migrates NUnit TestCase properties (TestName, Category, Description, Author, Explicit, ExplicitReason) to their TUnit equivalents instead of leaving TODO comments. Critical IssuesNone found ✅ SuggestionsCode Quality - All GoodThe implementation is well-structured with:
Test Coverage - ComprehensiveAll 8 new tests cover the property mapping scenarios well:
TUnit Rules Compliance✅ Dual-Mode: Not applicable (analyzer code, not core engine) Previous Review StatusUnable to retrieve previous comments due to token scope limitations, but all code changes appear clean and well-tested. Verdict✅ APPROVE - No critical issues. This is a solid improvement that properly handles NUnit TestCase properties instead of leaving TODOs. The implementation is correct, well-tested, and follows TUnit best practices. |
Summary
Instead of adding TODO comments for unsupported NUnit TestCase properties, this PR properly converts them to their TUnit equivalents:
TestName = "..."[DisplayName("...")]Category = "..."[Category("...")]Description = "..."[Property("Description", "...")]Author = "..."[Property("Author", "...")]Explicit = true[Explicit]ExplicitReason = "..."[Explicit]+[Property("ExplicitReason", "...")]Changes
NUnitTestCasePropertyRewriter: Method-level rewriter that extracts properties from all TestCase attributes on a method and generates corresponding TUnit attributesConvertCategoryArguments: Keep[Category]as-is since TUnit has a native Category attribute (was incorrectly converting to[Property("Category", "...")])Example
Before (NUnit):
After (TUnit):
Test plan
🤖 Generated with Claude Code