diff --git a/src/FluentAssertions.Analyzers.Tests/Tips/XunitTests.cs b/src/FluentAssertions.Analyzers.Tests/Tips/XunitTests.cs index 7c3719d9..3a140e84 100644 --- a/src/FluentAssertions.Analyzers.Tests/Tips/XunitTests.cs +++ b/src/FluentAssertions.Analyzers.Tests/Tips/XunitTests.cs @@ -694,6 +694,81 @@ public void AssertEquivalent_TestAnalyzer(string arguments, string assertion) => public void AssertEquivalent_TestCodeFix(string oldAssertion, string newAssertion) => VerifyCSharpFix("object actual, object expected", oldAssertion, newAssertion); + [DataTestMethod] + [DataRow("Action action", "Assert.Throws(typeof(ArgumentException), action);")] + [DataRow("Action action, Type exceptionType", "Assert.Throws(exceptionType, action);")] + [DataRow("Action action", "Assert.Throws(action);")] + [DataRow("Action action", "Assert.Throws(\"propertyName\", action);")] + [Implemented] + public void AssertThrows_TestAnalyzer(string arguments, string assertion) + => VerifyCSharpDiagnostic(arguments, assertion); + + [DataTestMethod] + [DataRow("Action action", + /* oldAssertion */ "Assert.Throws(typeof(ArgumentException), action);", + /* newAssertion */ "action.Should().ThrowExactly();")] + [DataRow("Action action", + /* oldAssertion */ "Assert.Throws(action);", + /* newAssertion */ "action.Should().ThrowExactly();")] + [DataRow("Action action", + /* oldAssertion */ "Assert.Throws(\"propertyName\", action);", + /* newAssertion */ "action.Should().ThrowExactly().WithParameterName(\"propertyName\");")] + [Implemented] + public void AssertThrows_TestCodeFix(string arguments, string oldAssertion, string newAssertion) + => VerifyCSharpFix(arguments, oldAssertion, newAssertion); + + [DataTestMethod] + [DataRow("Func action", "Assert.ThrowsAsync(typeof(ArgumentException), action);")] + [DataRow("Func action, Type exceptionType", "Assert.ThrowsAsync(exceptionType, action);")] + [DataRow("Func action", "Assert.ThrowsAsync(action);")] + [DataRow("Func action", "Assert.ThrowsAsync(\"propertyName\", action);")] + [Implemented] + public void AssertThrowsAsync_TestAnalyzer(string arguments, string assertion) + => VerifyCSharpDiagnostic(arguments, assertion); + + [DataTestMethod] + [DataRow("Func action", + /* oldAssertion */ "Assert.ThrowsAsync(typeof(ArgumentException), action);", + /* newAssertion */ "action.Should().ThrowExactlyAsync();")] + [DataRow("Func action", + /* oldAssertion */ "Assert.ThrowsAsync(action);", + /* newAssertion */ "action.Should().ThrowExactlyAsync();")] + [DataRow("Func action", + /* oldAssertion */ "Assert.ThrowsAsync(\"propertyName\", action);", + /* newAssertion */ "action.Should().ThrowExactlyAsync().WithParameterName(\"propertyName\");")] + [Implemented] + public void AssertThrowsAsync_TestCodeFix(string arguments, string oldAssertion, string newAssertion) + => VerifyCSharpFix(arguments, oldAssertion, newAssertion); + + [DataTestMethod] + [DataRow("Action action", "Assert.ThrowsAny(action);")] + [Implemented] + public void AssertThrowsAny_TestAnalyzer(string arguments, string assertion) + => VerifyCSharpDiagnostic(arguments, assertion); + + [DataTestMethod] + [DataRow("Action action", + /* oldAssertion */ "Assert.ThrowsAny(action);", + /* newAssertion */ "action.Should().Throw();")] + [Implemented] + public void AssertThrowsAny_TestCodeFix(string arguments, string oldAssertion, string newAssertion) + => VerifyCSharpFix(arguments, oldAssertion, newAssertion); + + [DataTestMethod] + [DataRow("Func action", "Assert.ThrowsAnyAsync(action);")] + [Implemented] + public void AssertThrowsAnyAsync_TestAnalyzer(string arguments, string assertion) + => VerifyCSharpDiagnostic(arguments, assertion); + + [DataTestMethod] + [DataRow("Func action", + /* oldAssertion */ "Assert.ThrowsAnyAsync(action);", + /* newAssertion */ "action.Should().ThrowAsync();")] + [Implemented] + public void AssertThrowsAnyAsync_TestCodeFix(string arguments, string oldAssertion, string newAssertion) + => VerifyCSharpFix(arguments, oldAssertion, newAssertion); + + private void VerifyCSharpDiagnostic(string methodArguments, string assertion) { var source = GenerateCode.XunitAssertion(methodArguments, assertion); diff --git a/src/FluentAssertions.Analyzers/Tips/DocumentEditorUtils.cs b/src/FluentAssertions.Analyzers/Tips/DocumentEditorUtils.cs index 2e5aa3a9..a622d55a 100644 --- a/src/FluentAssertions.Analyzers/Tips/DocumentEditorUtils.cs +++ b/src/FluentAssertions.Analyzers/Tips/DocumentEditorUtils.cs @@ -15,10 +15,8 @@ public class DocumentEditorUtils { public static CreateChangedDocument RenameMethodToSubjectShouldAssertion(IInvocationOperation invocation, CodeFixContext context, string newName, int subjectIndex, int[] argumentsToRemove) { - var invocationExpression = (InvocationExpressionSyntax)invocation.Syntax; - - return async ctx => await RewriteExpression(invocationExpression, [ - ..Array.ConvertAll(argumentsToRemove, arg => EditAction.RemoveNode(invocationExpression.ArgumentList.Arguments[arg])), + return async ctx => await RewriteExpression(invocation, [ + ..Array.ConvertAll(argumentsToRemove, arg => EditAction.RemoveInvocationArgument(arg)), EditAction.SubjectShouldAssertion(subjectIndex, newName) ], context, ctx); } @@ -27,33 +25,39 @@ public static CreateChangedDocument RenameGenericMethodToSubjectShouldGenericAss => RenameMethodToSubjectShouldGenericAssertion(invocation, invocation.TargetMethod.TypeArguments, context, newName, subjectIndex, argumentsToRemove); public static CreateChangedDocument RenameMethodToSubjectShouldGenericAssertion(IInvocationOperation invocation, ImmutableArray genericTypes, CodeFixContext context, string newName, int subjectIndex, int[] argumentsToRemove) { - var invocationExpression = (InvocationExpressionSyntax)invocation.Syntax; - - return async ctx => await RewriteExpression(invocationExpression, [ - ..Array.ConvertAll(argumentsToRemove, arg => EditAction.RemoveNode(invocationExpression.ArgumentList.Arguments[arg])), - EditAction.SubjectShouldGenericAssertion(subjectIndex, newName, genericTypes) + return async ctx => await RewriteExpression(invocation, [ + ..Array.ConvertAll(argumentsToRemove, arg => EditAction.RemoveInvocationArgument(arg)), + EditAction.SubjectShouldGenericAssertion(subjectIndex, newName, genericTypes) ], context, ctx); } public static CreateChangedDocument RenameMethodToSubjectShouldAssertionWithOptionsLambda(IInvocationOperation invocation, CodeFixContext context, string newName, int subjectIndex, int optionsIndex) { - var invocationExpression = (InvocationExpressionSyntax)invocation.Syntax; - - return async ctx => await RewriteExpression(invocationExpression, [ + return async ctx => await RewriteExpression(invocation, [ EditAction.SubjectShouldAssertion(subjectIndex, newName), EditAction.CreateEquivalencyAssertionOptionsLambda(optionsIndex) ], context, ctx); } - private static async Task RewriteExpression(InvocationExpressionSyntax invocationExpression, Action[] actions, CodeFixContext context, CancellationToken cancellationToken) + public static async Task RewriteExpression(IInvocationOperation invocation, Action[] actions, CodeFixContext context, CancellationToken cancellationToken) { + var invocationExpression = (InvocationExpressionSyntax)invocation.Syntax; + var editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken); + var editActionContext = new EditActionContext(editor, invocationExpression); foreach (var action in actions) { - action(editor, invocationExpression); + action(editActionContext); } return editor.GetChangedDocument(); } } + +public class EditActionContext(DocumentEditor editor, InvocationExpressionSyntax invocationExpression) { + public DocumentEditor Editor { get; } = editor; + public InvocationExpressionSyntax InvocationExpression { get; } = invocationExpression; + + public InvocationExpressionSyntax FluentAssertion { get; set; } +} \ No newline at end of file diff --git a/src/FluentAssertions.Analyzers/Tips/Editing/EditAction.cs b/src/FluentAssertions.Analyzers/Tips/Editing/EditAction.cs index 58c45a0d..caf604d9 100644 --- a/src/FluentAssertions.Analyzers/Tips/Editing/EditAction.cs +++ b/src/FluentAssertions.Analyzers/Tips/Editing/EditAction.cs @@ -8,15 +8,18 @@ namespace FluentAssertions.Analyzers; public static class EditAction { - public static Action RemoveNode(SyntaxNode node) - => (editor, invocationExpression) => editor.RemoveNode(node); + public static Action RemoveNode(SyntaxNode node) + => context => context.Editor.RemoveNode(node); - public static Action SubjectShouldAssertion(int argumentIndex, string assertion) - => (editor, invocationExpression) => new SubjectShouldAssertionAction(argumentIndex, assertion).Apply(editor, invocationExpression); + public static Action RemoveInvocationArgument(int argumentIndex) + => context => context.Editor.RemoveNode(context.InvocationExpression.ArgumentList.Arguments[argumentIndex]); - public static Action SubjectShouldGenericAssertion(int argumentIndex, string assertion, ImmutableArray genericTypes) - => (editor, invocationExpression) => new SubjectShouldGenericAssertionAction(argumentIndex, assertion, genericTypes).Apply(editor, invocationExpression); + public static Action SubjectShouldAssertion(int argumentIndex, string assertion) + => context => new SubjectShouldAssertionAction(argumentIndex, assertion).Apply(context); - public static Action CreateEquivalencyAssertionOptionsLambda(int optionsIndex) - => (editor, invocationExpression) => new CreateEquivalencyAssertionOptionsLambdaAction(optionsIndex).Apply(editor, invocationExpression); + public static Action SubjectShouldGenericAssertion(int argumentIndex, string assertion, ImmutableArray genericTypes) + => context => new SubjectShouldGenericAssertionAction(argumentIndex, assertion, genericTypes).Apply(context); + + public static Action CreateEquivalencyAssertionOptionsLambda(int optionsIndex) + => context => new CreateEquivalencyAssertionOptionsLambdaAction(optionsIndex).Apply(context.Editor, context.InvocationExpression); } \ No newline at end of file diff --git a/src/FluentAssertions.Analyzers/Tips/Editing/RemoveNodeAction.cs b/src/FluentAssertions.Analyzers/Tips/Editing/RemoveNodeAction.cs deleted file mode 100644 index e33ab287..00000000 --- a/src/FluentAssertions.Analyzers/Tips/Editing/RemoveNodeAction.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Editing; - -namespace FluentAssertions.Analyzers; - -public class RemoveNodeAction(SyntaxNode node) : IEditAction -{ - public void Apply(DocumentEditor editor, InvocationExpressionSyntax invocationExpression) => editor.RemoveNode(node); -} diff --git a/src/FluentAssertions.Analyzers/Tips/Editing/SubjectShouldAssertionAction.cs b/src/FluentAssertions.Analyzers/Tips/Editing/SubjectShouldAssertionAction.cs index 1a0262ee..40ce1952 100644 --- a/src/FluentAssertions.Analyzers/Tips/Editing/SubjectShouldAssertionAction.cs +++ b/src/FluentAssertions.Analyzers/Tips/Editing/SubjectShouldAssertionAction.cs @@ -1,10 +1,11 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Editing; namespace FluentAssertions.Analyzers; -public class SubjectShouldAssertionAction : IEditAction +public class SubjectShouldAssertionAction { private readonly int _argumentIndex; protected readonly string _assertion; @@ -15,13 +16,21 @@ public SubjectShouldAssertionAction(int argumentIndex, string assertion) _assertion = assertion; } - public void Apply(DocumentEditor editor, InvocationExpressionSyntax invocationExpression) + public void Apply(EditActionContext context) { - var generator = editor.Generator; - var subject = invocationExpression.ArgumentList.Arguments[_argumentIndex]; + var generator = context.Editor.Generator; + var arguments = context.InvocationExpression.ArgumentList.Arguments; + + var subject = arguments[_argumentIndex]; var should = generator.InvocationExpression(generator.MemberAccessExpression(subject.Expression, "Should")); - editor.RemoveNode(subject); - editor.ReplaceNode(invocationExpression.Expression, generator.MemberAccessExpression(should, GenerateAssertion(generator)).WithTriviaFrom(invocationExpression.Expression)); + context.Editor.RemoveNode(subject); + + var memberAccess = (MemberAccessExpressionSyntax) generator.MemberAccessExpression(should, GenerateAssertion(generator)).WithTriviaFrom(context.InvocationExpression.Expression); + + context.Editor.ReplaceNode(context.InvocationExpression.Expression, memberAccess); + context.FluentAssertion = context.InvocationExpression + .WithExpression(memberAccess) + .WithArgumentList(SyntaxFactory.ArgumentList(arguments.RemoveAt(_argumentIndex))); } protected virtual SyntaxNode GenerateAssertion(SyntaxGenerator generator) => generator.IdentifierName(_assertion); diff --git a/src/FluentAssertions.Analyzers/Tips/XunitCodeFixProvider.cs b/src/FluentAssertions.Analyzers/Tips/XunitCodeFixProvider.cs index 6daf1b28..19032fdb 100644 --- a/src/FluentAssertions.Analyzers/Tips/XunitCodeFixProvider.cs +++ b/src/FluentAssertions.Analyzers/Tips/XunitCodeFixProvider.cs @@ -1,10 +1,10 @@ using System.Collections.Immutable; using System.Composition; -using System.Runtime.InteropServices.ComTypes; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.Operations; using CreateChangedDocument = System.Func>; +using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace FluentAssertions.Analyzers; @@ -33,7 +33,8 @@ protected override CreateChangedDocument TryComputeFix(IInvocationOperation invo { if (invocation.Arguments[2].Value is ILiteralOperation literal) { - return literal.ConstantValue.Value switch { + return literal.ConstantValue.Value switch + { false => DocumentEditorUtils.RenameMethodToSubjectShouldAssertion(invocation, context, "BeEquivalentTo", subjectIndex: 1, argumentsToRemove: literal.IsImplicit ? [] : [2]), _ => null }; @@ -150,7 +151,52 @@ protected override CreateChangedDocument TryComputeFix(IInvocationOperation invo return DocumentEditorUtils.RenameMethodToSubjectShouldAssertion(invocation, context, "BeInRange", subjectIndex: 0, argumentsToRemove: []); case "NotInRange" when ArgumentsCount(invocation, 3): // Assert.NotInRange(T actual, T low, T high) return DocumentEditorUtils.RenameMethodToSubjectShouldAssertion(invocation, context, "NotBeInRange", subjectIndex: 0, argumentsToRemove: []); + case "Throws" when ArgumentsCount(invocation, 1): // Assert.Throws(Action testCode) where T : Exception + return DocumentEditorUtils.RenameGenericMethodToSubjectShouldGenericAssertion(invocation, context, "ThrowExactly", subjectIndex: 0, argumentsToRemove: []); + case "Throws" when ArgumentsAreTypeOf(invocation, t.Type, t.Action): // Assert.Throws(Type exceptionType, Action testCode) + { + if (invocation.Arguments[0].Value is not ITypeOfOperation typeOf) + { + return null; // no fix for this + } + + return DocumentEditorUtils.RenameMethodToSubjectShouldGenericAssertion(invocation, ImmutableArray.Create(typeOf.TypeOperand), context, "ThrowExactly", subjectIndex: 1, argumentsToRemove: [0]); + } + case "Throws" when ArgumentsAreTypeOf(invocation, t.String, t.Action): // Assert.Throws(string paramName, Action testCode) + return RewriteThrowArgumentExceptionAssertion("ThrowExactly"); + case "ThrowsAsync" when ArgumentsCount(invocation, 1): // Assert.ThrowsAsync(Func testCode) where T : Exception + return DocumentEditorUtils.RenameGenericMethodToSubjectShouldGenericAssertion(invocation, context, "ThrowExactlyAsync", subjectIndex: 0, argumentsToRemove: []); + case "ThrowsAsync" when ArgumentsAreTypeOf(invocation, t.Type, t.FuncOfTask): // Assert.ThrowsAsync(Type exceptionType, Func testCode) + { + if (invocation.Arguments[0].Value is not ITypeOfOperation typeOf) + { + return null; // no fix for this + } + + return DocumentEditorUtils.RenameMethodToSubjectShouldGenericAssertion(invocation, ImmutableArray.Create(typeOf.TypeOperand), context, "ThrowExactlyAsync", subjectIndex: 1, argumentsToRemove: [0]); + } + case "ThrowsAsync" when ArgumentsAreTypeOf(invocation, t.String, t.FuncOfTask): // Assert.ThrowsAsync(string paramName, Func testCode) + return RewriteThrowArgumentExceptionAssertion("ThrowExactlyAsync"); + case "ThrowsAny" when ArgumentsCount(invocation, 1): // Assert.ThrowsAny(Action testCode) where T : Exception + return DocumentEditorUtils.RenameGenericMethodToSubjectShouldGenericAssertion(invocation, context, "Throw", subjectIndex: 0, argumentsToRemove: []); + case "ThrowsAnyAsync" when ArgumentsCount(invocation, 1): // Assert.ThrowsAnyAsync(Func testCode) where T : Exception + return DocumentEditorUtils.RenameGenericMethodToSubjectShouldGenericAssertion(invocation, context, "ThrowAsync", subjectIndex: 0, argumentsToRemove: []); } return null; + + CreateChangedDocument RewriteThrowArgumentExceptionAssertion(string newName) + { + return ctx => DocumentEditorUtils.RewriteExpression(invocation, [ + EditAction.SubjectShouldGenericAssertion(argumentIndex: 1, newName, invocation.TargetMethod.TypeArguments), + (editActionContext) => + { + var generator = editActionContext.Editor.Generator; + var withParameterName = generator.MemberAccessExpression(editActionContext.FluentAssertion.WithArgumentList(SF.ArgumentList()), "WithParameterName"); + var chainedAssertion = generator.InvocationExpression(withParameterName, editActionContext.InvocationExpression.ArgumentList.Arguments[0]); + + editActionContext.Editor.ReplaceNode(editActionContext.InvocationExpression, chainedAssertion); + } + ], context, ctx); + } } } \ No newline at end of file