diff --git a/Source/Mockolate.Migration.Analyzers.CodeFixers/MoqCodeFixProvider.cs b/Source/Mockolate.Migration.Analyzers.CodeFixers/MoqCodeFixProvider.cs index 37d98f4..f723db7 100644 --- a/Source/Mockolate.Migration.Analyzers.CodeFixers/MoqCodeFixProvider.cs +++ b/Source/Mockolate.Migration.Analyzers.CodeFixers/MoqCodeFixProvider.cs @@ -93,6 +93,9 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con Dictionary verifyEventCallReplacements = FindAndBuildVerifyEventCallReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken); + Dictionary verifyPropertyCallReplacements = + FindAndBuildVerifyPropertyCallReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken); + Dictionary raiseCallReplacements = FindAndBuildRaiseCallReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken); @@ -109,6 +112,7 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con nodesToReplace.AddRange(callbackReplacements.Keys); nodesToReplace.AddRange(verifyCallReplacements.Keys); nodesToReplace.AddRange(verifyEventCallReplacements.Keys); + nodesToReplace.AddRange(verifyPropertyCallReplacements.Keys); nodesToReplace.AddRange(raiseCallReplacements.Keys); compilationUnit = compilationUnit.ReplaceNodes( @@ -157,6 +161,11 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con return verifyEventReplacement; } + if (verifyPropertyCallReplacements.TryGetValue(invocation, out InvocationExpressionSyntax? verifyPropertyReplacement)) + { + return verifyPropertyReplacement; + } + if (raiseCallReplacements.TryGetValue(invocation, out InvocationExpressionSyntax? raiseReplacement)) { return raiseReplacement; @@ -175,7 +184,7 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con compilationUnit = compilationUnit.AddUsings(usingDirective); } - if (verifyCallReplacements.Count > 0 || verifyEventCallReplacements.Count > 0) + if (verifyCallReplacements.Count > 0 || verifyEventCallReplacements.Count > 0 || verifyPropertyCallReplacements.Count > 0) { bool hasVerifyUsing = compilationUnit.Usings.Any(u => u.Name?.ToString() == "Mockolate.Verify"); if (!hasVerifyUsing) @@ -538,7 +547,7 @@ private static Dictionary FindAndBuildVerifyPropertyCallReplacements( + IReadOnlyList allInvocations, + SemanticModel? semanticModel, + ISymbol? mockSymbol, + CancellationToken cancellationToken) + { + if (semanticModel is null || mockSymbol is null) + { + return []; + } + + Dictionary result = []; + foreach (InvocationExpressionSyntax invocation in allInvocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + string methodName = memberAccess.Name.Identifier.Text; + if (methodName is not ("VerifyGet" or "VerifySet")) + { + continue; + } + + SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken); + if (!SymbolEqualityComparer.Default.Equals(symbolInfo.Symbol, mockSymbol)) + { + continue; + } + + if (invocation.ArgumentList.Arguments.Count is 0 or > 2 || + invocation.ArgumentList.Arguments[0].Expression is not LambdaExpressionSyntax lambda) + { + continue; + } + + string? lambdaParamName = GetSingleLambdaParamName(lambda); + if (lambdaParamName is null) + { + continue; + } + + MemberAccessExpressionSyntax? propertyAccess; + ArgumentListSyntax? setValueArgs = null; + + if (methodName == "VerifyGet") + { + if (lambda.Body is not MemberAccessExpressionSyntax getAccess) + { + continue; + } + + propertyAccess = getAccess; + } + else + { + // VerifySet: lambda body is either an assignment (= value) or a property access (no value matcher) + if (lambda.Body is AssignmentExpressionSyntax { Left: MemberAccessExpressionSyntax setAccess, } assignment + && assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) + { + propertyAccess = setAccess; + setValueArgs = BuildVerifySetArgs(assignment.Right); + } + else if (lambda.Body is MemberAccessExpressionSyntax bareAccess) + { + propertyAccess = bareAccess; + setValueArgs = SyntaxFactory.ArgumentList(); + } + else + { + continue; + } + } + + List? navigationChain = ExtractNavigationChain(propertyAccess.Expression, lambdaParamName); + if (navigationChain is null) + { + continue; + } + + SimpleNameSyntax propertyNameSyntax = propertyAccess.Name; + + ExpressionSyntax receiver = memberAccess.Expression; + foreach (SimpleNameSyntax nav in navigationChain) + { + receiver = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + nav); + } + + MemberAccessExpressionSyntax mockAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName("Mock")); + MemberAccessExpressionSyntax verifyAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + mockAccess, + SyntaxFactory.IdentifierName("Verify")); + MemberAccessExpressionSyntax propertyMemberAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + verifyAccess, + propertyNameSyntax); + + string verifyMethod = methodName == "VerifyGet" ? "Got" : "Set"; + InvocationExpressionSyntax baseInvocation = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + propertyMemberAccess, + SyntaxFactory.IdentifierName(verifyMethod)), + setValueArgs ?? SyntaxFactory.ArgumentList()); + + InvocationExpressionSyntax atLeastOnceFallback = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + baseInvocation, + SyntaxFactory.IdentifierName("AtLeastOnce")), + SyntaxFactory.ArgumentList()); + + // Fall back to AtLeastOnce when the Times argument can't be translated — the + // Mock() construction is unconditionally rewritten, so leaving the original + // VerifyGet/VerifySet in place would produce non-compiling code. + InvocationExpressionSyntax replacement = invocation.ArgumentList.Arguments.Count == 2 + ? (ApplyTimesChain(baseInvocation, invocation.ArgumentList.Arguments[1].Expression) ?? atLeastOnceFallback) + .WithTriviaFrom(invocation) + : atLeastOnceFallback.WithTriviaFrom(invocation); + + result[invocation] = replacement; + } + + return result; + } + + private static ArgumentListSyntax BuildVerifySetArgs( + ExpressionSyntax valueExpression) + { + // Rewrite any Moq It.* matchers inside the value expression first. + ArgumentListSyntax wrapped = SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(valueExpression))); + ArgumentListSyntax transformed = TransformMoqItReferences(wrapped); + ExpressionSyntax transformedValue = transformed.Arguments[0].Expression; + + // If the transformed value is rooted in an It.* matcher invocation (including + // chained forms like It.IsInRange(...).Exclusive() or It.Matches(...).AsRegex()), + // use it directly instead of wrapping it in It.Is(...). + if (IsRootedInItInvocation(transformedValue)) + { + return SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(transformedValue))); + } + + // Otherwise wrap the value in It.Is(...) + InvocationExpressionSyntax itIs = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("It"), + SyntaxFactory.IdentifierName("Is")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(transformedValue)))); + return SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(itIs))); + } + private static InvocationExpressionSyntax? ApplyTimesChain( InvocationExpressionSyntax baseInvocation, ExpressionSyntax timesArg) { @@ -1250,6 +1423,30 @@ private static ArgumentListSyntax TransformMoqItReferences(ArgumentListSyntax ar .Where(inv => IsMoqItCall(inv, out _, out _)), TransformMoqItInvocation); + // Returns true when the expression is an It.* matcher invocation, or a chained + // invocation whose receiver ultimately resolves to an It.* matcher invocation + // (e.g. It.IsInRange(...).Exclusive(), It.Is(...).Using(comparer), It.Matches(...).AsRegex()). + private static bool IsRootedInItInvocation(ExpressionSyntax expression) + { + ExpressionSyntax current = expression; + while (current is InvocationExpressionSyntax invocation) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return false; + } + + if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "It", }) + { + return true; + } + + current = memberAccess.Expression; + } + + return false; + } + private static SyntaxNode TransformMoqItInvocation(InvocationExpressionSyntax original, InvocationExpressionSyntax rewritten) { diff --git a/Tests/Mockolate.Migration.Example.Tests/MoqMigrationExamples.cs b/Tests/Mockolate.Migration.Example.Tests/MoqMigrationExamples.cs index 6ce7604..d62dca6 100644 --- a/Tests/Mockolate.Migration.Example.Tests/MoqMigrationExamples.cs +++ b/Tests/Mockolate.Migration.Example.Tests/MoqMigrationExamples.cs @@ -71,6 +71,15 @@ public async Task ExpectedMigrationResult() // alternatively, provide a default value for the stubbed property mock.Mock.Setup.Name.InitializeWith("foo"); + // setup getter explicitly + mock.Mock.Setup.Name.Returns("bar"); + + // interact with the property and verify + _ = mock.Name; + mock.Name = "baz"; + mock.Mock.Verify.Name.Got().AtLeastOnce(); + mock.Mock.Verify.Name.Set(It.Is("baz")).Once(); + /* ------ Events ------ */ // subscribing to and raising an event mock.MyEvent += (_, _) => { }; @@ -150,6 +159,15 @@ public async Task MoqCreation() // alternatively, provide a default value for the stubbed property mock.SetupProperty(f => f.Name, "foo"); + // setup getter explicitly + mock.SetupGet(foo => foo.Name).Returns("bar"); + + // interact with the property and verify + _ = mock.Object.Name; + mock.Object.Name = "baz"; + mock.VerifyGet(foo => foo.Name); + mock.VerifySet(foo => foo.Name = "baz", Times.Once()); + /* ------ Events ------ */ // subscribing to and raising an event mock.Object.MyEvent += (_, _) => { }; diff --git a/Tests/Mockolate.Migration.Tests/MoqCodeFixProviderTests.PropertyTests.cs b/Tests/Mockolate.Migration.Tests/MoqCodeFixProviderTests.PropertyTests.cs new file mode 100644 index 0000000..630233f --- /dev/null +++ b/Tests/Mockolate.Migration.Tests/MoqCodeFixProviderTests.PropertyTests.cs @@ -0,0 +1,351 @@ +using Verifier = Mockolate.Migration.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace Mockolate.Migration.Tests; + +public partial class MoqCodeFixProviderTests +{ + public sealed class PropertyTests + { + [Fact] + public async Task SetupGet_MigratesToSetupPropertyAccess() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.SetupGet(m => m.Name).Returns("bar"); + } + } + """, + """ + using Moq; + using Mockolate; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Setup.Name.Returns("bar"); + } + } + """); + + [Fact] + public async Task VerifyGet_WithoutTimes_MigratesToGotAtLeastOnce() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifyGet(m => m.Name); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Name.Got().AtLeastOnce(); + } + } + """); + + [Fact] + public async Task VerifyGet_WithTimesOnce_MigratesToGotOnce() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifyGet(m => m.Name, Times.Once()); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Name.Got().Once(); + } + } + """); + + [Fact] + public async Task VerifySet_WithLiteral_WrapsInItIs() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Name = "foo"); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Name.Set(It.Is("foo")).AtLeastOnce(); + } + } + """); + + [Fact] + public async Task VerifySet_WithTimesNever_MigratesToSetNever() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Name = "foo", Times.Never); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Name.Set(It.Is("foo")).Never(); + } + } + """); + + [Fact] + public async Task VerifySet_WithItIsAnyMatcher_PreservesMatcher() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Value = It.IsAny(), Times.Once()); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Value.Set(It.IsAny()).Once(); + } + } + """); + + [Fact] + public async Task VerifySet_WithItIsInRange_TransformsMatcher() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Value = It.IsInRange(1, 5, Range.Inclusive)); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Value.Set(It.IsInRange(1, 5)).AtLeastOnce(); + } + } + """); + + [Fact] + public async Task VerifySet_WithItIsInRangeExclusive_PreservesChainedMatcher() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Value = It.IsInRange(1, 5, Range.Exclusive)); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { int Value { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Value.Set(It.IsInRange(1, 5).Exclusive()).AtLeastOnce(); + } + } + """); + + [Fact] + public async Task VerifySet_WithItIsRegex_PreservesChainedMatcher() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifySet(m => m.Name = It.IsRegex("^foo$"), Times.Once()); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IFoo { string Name { get; set; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Mock.Verify.Name.Set(It.Matches("^foo$").AsRegex()).Once(); + } + } + """); + + [Fact] + public async Task VerifyGet_WithNestedProperty_UsesNavigationChain() + => await Verifier.VerifyCodeFixAsync( + """ + using Moq; + + public interface IBar { string Name { get; set; } } + public interface IFoo { IBar Child { get; } } + + public class Tests + { + public void Test() + { + var mock = [|new Mock()|]; + mock.VerifyGet(m => m.Child.Name, Times.Exactly(2)); + } + } + """, + """ + using Moq; + using Mockolate; + using Mockolate.Verify; + + public interface IBar { string Name { get; set; } } + public interface IFoo { IBar Child { get; } } + + public class Tests + { + public void Test() + { + var mock = IFoo.CreateMock(); + mock.Child.Mock.Verify.Name.Got().Exactly(2); + } + } + """); + } +}