diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index 5195eea..c4ca56b 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -49,6 +49,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation" docs\Compilation\PosInfoMoq2009.md = docs\Compilation\PosInfoMoq2009.md docs\Compilation\PosInfoMoq2010.md = docs\Compilation\PosInfoMoq2010.md docs\Compilation\PosInfoMoq2011.md = docs\Compilation\PosInfoMoq2011.md + docs\Compilation\PosInfoMoq2012.md = docs\Compilation\PosInfoMoq2012.md + docs\Compilation\PosInfoMoq2013.md = docs\Compilation\PosInfoMoq2013.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Moq.Analyzers.Sandbox", "tests\Moq.Analyzers.Sandbox\Moq.Analyzers.Sandbox.csproj", "{07F970A1-1477-4D4C-B233-C9B4DA6E3AD6}" diff --git a/README.md b/README.md index 9f2f2d0..f84a756 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ All the rules of this category should not be disabled (or changed their severity | [PosInfoMoq2009: `Mock.Of` method must be used only to mock non-sealed class](docs/Compilation/PosInfoMoq2009.md) | The `Mock.Of` method can mock only interfaces or non-`sealed` classes | | [PosInfoMoq2010: `Mock.Of` method must be used only with types that contains parameterless contructor](docs/Compilation/PosInfoMoq2010.md) | The `Mock.Of` method requires a non-private parameterless contructor | | [PosInfoMoq2011: Constructor of the mocked class must be accessible.](docs/Compilation/PosInfoMoq2011.md) | The constructor of the instantiate mocked class must non-private. | +| [PosInfoMoq2012: The delegate in the argument of the `Returns()` method must return a value with same type of the mocked method.](docs/Compilation/PosInfoMoq2012.md) | The lambda expression, anonymous method or method in the argument of the `Returns()` must return return a value of the same type as the mocked method or property. | +| [PosInfoMoq2013: The delegate in the argument of the `Returns()`/`ReturnsAsync()` method must have the same parameter types of the mocked method/property.](docs/Compilation/PosInfoMoq2013.md) | The lambda expression, anonymous method or method in the argument of the `Returns()`/`ReturnsAsync()` must have the same arguments type of the mocked method or property. | diff --git a/docs/Compilation/PosInfoMoq2012.md b/docs/Compilation/PosInfoMoq2012.md new file mode 100644 index 0000000..b626c70 --- /dev/null +++ b/docs/Compilation/PosInfoMoq2012.md @@ -0,0 +1,63 @@ +# PosInfoMoq2012: The delegate in the argument of the `Returns()` method must return a value with same type of the mocked method. + +| Property | Value | +|-------------------------------------|-------------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2012 | +| **Title** | The delegate in the argument of the `Returns()` method must return a value with same type of the mocked method. | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +The delegate in the argument of the `Returns()` must return return a value of the same type as the mocked method or property. + +## Rule description + +The lambda expression, anonymous method or method in the argument of the `Returns()` must return return a value of the same type as the mocked method or property. + +```csharp +[Fact] +public void Test() +{ + var validMock = new Mock(); + validMock.Setup(s => s.GetData()) + .Returns(() => + { + return 1234; // OK, the mocked GetData() method return an int. + }); + validMock.Setup(s => s.IsAvailable) + .Returns(() => + { + return true; // OK, the mocked IsAvailable property return a bool. + }); + + var invalidMock = new Mock(); + invalidMock.Setup(s => s.GetData()) + .Returns(() => + { + return "Foobar"; // Error, the mocked GetData() method must return an int. + }); + invalidMock.Setup(s => s.IsAvailable) + .Returns(() => + { + return "Foobar"; // Error, the mocked IsAvailable property must return a bool. + }); +} + +public interface IService +{ + int GetData(); + + bool IsAvailable { get; } +} +``` + +## How to fix violations + +To fix a violation of this rule, be sure the lambda expression, anonymous method or method as parameter of the `Returns()` +method returns values with the same type of mocked method/property. + +## When to suppress warnings + +Do not suppress an error from this rule. If bypassed, the execution of the unit test will be failed with a `ArgumentException` +thrown with the *"Invalid callback. Setup on method with return type 'xxx' cannot invoke callback with return type 'yyy'."* message. diff --git a/docs/Compilation/PosInfoMoq2013.md b/docs/Compilation/PosInfoMoq2013.md new file mode 100644 index 0000000..a101d52 --- /dev/null +++ b/docs/Compilation/PosInfoMoq2013.md @@ -0,0 +1,69 @@ +# PosInfoMoq2013: The delegate in the argument of the `Returns()`/`ReturnsAsync()` method must have the same parameter types of the mocked method/property. + +| Property | Value | +|-------------------------------------|-------------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2013 | +| **Title** | The delegate in the argument of the `Returns()`/`ReturnsAsync()` method must have the same parameter types of the mocked method/property. | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +The delegate in the argument of the `Returns()`/`ReturnsAsync()` method must have the same parameter types of the mocked method/property. + +## Rule description + +The lambda expression, anonymous method or method in the argument of the `Returns()` must have the same parameter types of the mocked method/property. +> NB: Moq allows to pass a delegate with no argument in the `Returns()`/`ReturnsAsync()` method even the setup method contains arguments. + +```csharp +[Fact] +public void Test() +{ + var validMock = new Mock(); + validMock.Setup(s => s.GetData(1234)) + .Returns((int a) => // OK, the mocked GetData() take a int value as argument. + { + return 1234; + }); + validMock.Setup(s => s.GetData(1234)) + .Returns(() => // OK, Moq allows no arguments. + { + return 1234; + }); + validMock.Setup(s => s.IsAvailable) // OK, property don't have arguments. + .Returns(() => + { + return true; + }); + + var invalidMock = new Mock(); + invalidMock.Setup(s => s.GetData(1234)) + .Returns((string s) => // Error, the mocked GetData() take a int value as argument. + { + return "Foobar"; + }); + invalidMock.Setup(s => s.IsAvailable) + .Returns((string s) => // Error, mocked property have no arguments. + { + return "Foobar"; + }); +} + +public interface IService +{ + int GetData(int id); + + bool IsAvailable { get; } +} +``` + +## How to fix violations + +To fix a violation of this rule, be sure the lambda expression, anonymous method or method as parameter of the `Returns()`/`ReturnsAsync()` +method must have the same arguments type of the setup property/method. + +## When to suppress warnings + +Do not suppress an error from this rule. If bypassed, the execution of the unit test will be failed with a `ArgumentException` +thrown with the *"Object of type 'xxx' cannot be converted to type 'yyy'."* message. diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index baaa713..2111b71 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -1,4 +1,12 @@ -## Release 1.9.1 +## Release 1.10.0 + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +PosInfoMoq2012 | Compilation | Error | The delegate in the argument of the `Returns()` method must return a value with same type of the mocked method. +PosInfoMoq2013 | Compilation | Error | The delegate in the argument of the `Returns()`/`ReturnsAsync()` method must have the same parameter types of the mocked method/property. + +## Release 1.9.1 ### New Rules Rule ID | Category | Severity | Notes diff --git a/src/Moq.Analyzers/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.cs b/src/Moq.Analyzers/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.cs new file mode 100644 index 0000000..5b072d2 --- /dev/null +++ b/src/Moq.Analyzers/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.cs @@ -0,0 +1,145 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers +{ + using System.Collections.Immutable; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ReturnsMethodDelegateMustMatchMockedMethodAnalyzer : DiagnosticAnalyzer + { + internal static readonly DiagnosticDescriptor ReturnValueMustMatchRule = new DiagnosticDescriptor( + "PosInfoMoq2012", + "The delegate in the argument of the Returns() method must return a value with same type of the mocked method/property", + "The delegate in the argument of the Returns() method must return a '{0}' type value", + "Compilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The delegate in the argument of the Returns() method must return a value with same type of the mocked method/property.", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2012.html"); + + private static readonly DiagnosticDescriptor ArgumentMustMatchRule = new DiagnosticDescriptor( + "PosInfoMoq2013", + "The delegate in the argument of the Returns()/ReturnsAsync() method must have the same parameter types of the mocked method/property", + "The delegate in the argument of the Returns()/ReturnsAsync() method must have the same parameter types of the mocked method/property", + "Compilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The delegate in the argument of the Returns()/ReturnsAsync() method must have the same parameter types of the mocked method/property.", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2012.html"); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(ReturnValueMustMatchRule, ArgumentMustMatchRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var invocationExpression = (InvocationExpressionSyntax)context.Node; + + var moqSymbols = MoqSymbols.FromCompilation(context.Compilation); + + if (moqSymbols is null) + { + return; + } + + var invocationExpressionSymbol = context.SemanticModel.GetSymbolInfo(invocationExpression, context.CancellationToken); + + if (invocationExpressionSymbol.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + if (!moqSymbols.IsReturnsMethod(methodSymbol) && !moqSymbols.IsReturnsAsyncMethod(methodSymbol)) + { + return; + } + + // Gets the first argument of the Returns() method. + if (invocationExpression.ArgumentList.Arguments.Count != 1) + { + return; + } + + var firstArgumentExpression = invocationExpression.ArgumentList.Arguments[0].Expression; + + if (firstArgumentExpression is not ParenthesizedLambdaExpressionSyntax delegateMethodSyntax) + { + return; + } + + var firstArgumentSymbol = context.SemanticModel.GetSymbolInfo(firstArgumentExpression, context.CancellationToken); + + if (firstArgumentSymbol.Symbol is not IMethodSymbol delegateMethodSymbol) + { + return; + } + + var moqExpressionAnalyzer = new MoqExpressionAnalyzer(moqSymbols, context.SemanticModel); + + // Extract the Setup() method. + var setupMethod = moqExpressionAnalyzer.ExtractSetupMethod(invocationExpression, context.CancellationToken); + + if (setupMethod is null) + { + return; + } + + // Check the return type + if (!moqSymbols.IsReturnsAsyncMethod(methodSymbol)) + { + var expectedReturnType = setupMethod.ReturnType; + + if (!SymbolEqualityComparer.Default.Equals(delegateMethodSymbol.ReturnType, expectedReturnType)) + { + context.ReportDiagnostic(ReturnValueMustMatchRule, firstArgumentExpression.GetLocation(), expectedReturnType.Name); + } + } + + // Check the argument types. + if (setupMethod.IsProperty) + { + if (delegateMethodSymbol.Parameters.Length > 0) + { + // With property, the Returns() method must have no arguments. + context.ReportDiagnostic(ArgumentMustMatchRule, delegateMethodSyntax.ParameterList.GetLocation()); + } + + return; + } + + if (delegateMethodSymbol.Parameters.Length == 0) + { + // No argument in the delegate method, Moq accept it. + return; + } + + if (delegateMethodSymbol.Parameters.Length != setupMethod.InvocationArguments.Count) + { + context.ReportDiagnostic(ArgumentMustMatchRule, delegateMethodSyntax.ParameterList.GetLocation()); + return; + } + + for (var i = 0; i < delegateMethodSymbol.Parameters.Length; i++) + { + if (!SymbolEqualityComparer.Default.Equals(delegateMethodSymbol.Parameters[i].Type, setupMethod.InvocationArguments[i].ParameterSymbol.Type)) + { + context.ReportDiagnostic(ArgumentMustMatchRule, delegateMethodSyntax.ParameterList.Parameters[i].GetLocation()); + } + } + } + } +} diff --git a/src/Moq.Analyzers/ChainMembersInvocation.cs b/src/Moq.Analyzers/ChainMembersInvocation.cs index 05e2a54..7e1ee71 100644 --- a/src/Moq.Analyzers/ChainMembersInvocation.cs +++ b/src/Moq.Analyzers/ChainMembersInvocation.cs @@ -20,6 +20,32 @@ public ChainMembersInvocation(IReadOnlyList members, IReadOnlyList< public IReadOnlyList InvocationArguments { get; } + public ITypeSymbol ReturnType + { + get + { + if (this.Members[0].Symbol is IMethodSymbol methodSymbol) + { + return methodSymbol.ReturnType; + } + + return ((IPropertySymbol)this.Members[0].Symbol).Type; + } + } + + public bool IsProperty + { + get + { + if (this.Members[0].Symbol is IMethodSymbol methodSymbol) + { + return false; + } + + return true; + } + } + public bool HasSameMembers(ChainMembersInvocation otherChainInvocation) { if (this.Members.Count != otherChainInvocation.Members.Count) diff --git a/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs new file mode 100644 index 0000000..3aac2c0 --- /dev/null +++ b/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs @@ -0,0 +1,389 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers.Tests +{ + using Microsoft.CodeAnalysis.Testing; + using Verifier = MoqCSharpAnalyzerVerifier; + + public class ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest + { + [Theory] + [InlineData("Returns")] + [InlineData("ReturnsAsync")] + public async Task Returns_ValidReturnsType_Method_NoDiagnosticReported(string methodName) + { + var returnType = "int"; + + if (methodName == "ReturnsAsync") + { + returnType = "Task<" + returnType + ">"; + } + + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod()) + .Callback(() => { }) + ." + methodName + @"(() => { return 1234; }); + } + } + + public interface I + { + " + returnType + @" TestMethod(); + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task Returns_InvalidReturnsType_Method_DiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod()) + .Callback(() => { }) + .Returns(() => { return ""Foobar""; }); + } + } + + public interface I + { + int TestMethod(); + } + }"; + + await Verifier.VerifyAnalyzerAsync( + source, + new DiagnosticResult(ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.ReturnValueMustMatchRule) + .WithSpan(13, 42, 13, 68).WithArguments("Int32")); + } + + [Fact] + public async Task Returns_InvalidReturnsType_Property_DiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestProperty) + .Callback(() => { }) + .Returns(() => { return ""Foobar""; }); + } + } + + public interface I + { + int TestProperty { get; set; } + } + }"; + + await Verifier.VerifyAnalyzerAsync( + source, + new DiagnosticResult(ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.ReturnValueMustMatchRule) + .WithSpan(13, 42, 13, 68).WithArguments("Int32")); + } + + [Theory] + [InlineData("TestProperty")] + [InlineData("GetData()")] + public async Task Returns_ValidReturnsValue_NoDiagnosticReported(string member) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i." + member + @") + .Callback(() => { }) + .Returns(1234); + } + } + + public interface I + { + int GetData(); + + int TestProperty { get; set; } + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task Returns_ValidReturnsType_Property_NoDiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestProperty) + .Callback(() => { }) + .Returns(() => { return 1234; }); + } + } + + public interface I + { + int TestProperty { get; set; } + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Theory] + [InlineData("Returns")] + [InlineData("ReturnsAsync")] + public async Task Returns_InvalidArgumentType_DiagnosticReported(string methodName) + { + var returnType = "int"; + + if (methodName == "ReturnsAsync") + { + returnType = "Task<" + returnType + ">"; + } + + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod(""Ignored"")) + .Callback(() => { }) + ." + methodName + @"(({|PosInfoMoq2013:int s|}) => { return 1234; }); + } + } + + public interface I + { + " + returnType + @" TestMethod(string a); + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Theory] + [InlineData("Returns")] + [InlineData("ReturnsAsync")] + public async Task Returns_DifferentArgumentCount_DiagnosticReported(string methodName) + { + var returnType = "int"; + + if (methodName == "ReturnsAsync") + { + returnType = "Task<" + returnType + ">"; + } + + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod(""Ignored"")) + .Callback(() => { }) + ." + methodName + @"({|PosInfoMoq2013:(int s, bool b)|} => { return 1234; }); + } + } + + public interface I + { + " + returnType + @" TestMethod(string a); + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Theory] + [InlineData("Returns")] + [InlineData("ReturnsAsync")] + public async Task Returns_ValidArgumentType_NoDiagnosticReported(string methodName) + { + var returnType = "int"; + + if (methodName == "ReturnsAsync") + { + returnType = "Task<" + returnType + ">"; + } + + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod(""Ignored"")) + .Callback(() => { }) + ." + methodName + @"((string z) => { return 1234; }); + } + } + + public interface I + { + " + returnType + @" TestMethod(string a); + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Theory] + [InlineData("Returns")] + [InlineData("ReturnsAsync")] + public async Task Returns_WithNoArgument_NoDiagnosticReported(string methodName) + { + var returnType = "int"; + + if (methodName == "ReturnsAsync") + { + returnType = "Task<" + returnType + ">"; + } + + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestMethod(""Ignored"")) + .Callback(() => { }) + ." + methodName + @"(() => { return 1234; }); + } + } + + public interface I + { + " + returnType + @" TestMethod(string a); + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task Returns_WithArgumentsForProperty_DiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.TestProperty) + .Callback(() => { }) + .Returns({|PosInfoMoq2013:(int s)|} => { return 1234; }); + } + } + + public interface I + { + int TestProperty { get; set; } + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoMoqLibrary() + { + var source = @" + namespace ConsoleApplication1 + { + using OtherNamespace; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(); + } + } + + public interface I + { + } + } + + namespace OtherNamespace + { + public class Mock + { + public Mock() { } + + public void Setup() { } + } + }"; + + await Verifier.VerifyAnalyzerWithNoMoqLibraryAsync(source); + } + } +} \ No newline at end of file