From 8f8d95db3124a2cbd7d6663da193b4bdd48e7124 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 12 Jun 2024 17:28:47 +0200 Subject: [PATCH] Add the PosInfoMoq2007 rule to check the As() use interface. --- PosInformatique.Moq.Analyzers.sln | 1 + README.md | 2 + docs/Compilation/PosInfoMoq2006.md | 2 +- docs/Compilation/PosInfoMoq2007.md | 45 ++++++ src/Moq.Analyzers/AnalyzerReleases.Shipped.md | 10 +- .../AsMustBeUsedWithInterfaceAnalyzer.cs | 66 ++++++++ src/Moq.Analyzers/MoqExpressionAnalyzer.cs | 31 ++++ src/Moq.Analyzers/MoqSymbols.cs | 14 ++ .../AsMustBeUsedWithInterfaceAnalyzerTest.cs | 146 ++++++++++++++++++ tests/Moq.Analyzers.Tests/MoqLibrary.cs | 2 + 10 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 docs/Compilation/PosInfoMoq2007.md create mode 100644 src/Moq.Analyzers/Analyzers/AsMustBeUsedWithInterfaceAnalyzer.cs create mode 100644 tests/Moq.Analyzers.Tests/Analyzers/AsMustBeUsedWithInterfaceAnalyzerTest.cs diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index 3485aa5..6e5601e 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -40,6 +40,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation" docs\Compilation\PosInfoMoq2004.md = docs\Compilation\PosInfoMoq2004.md docs\Compilation\PosInfoMoq2005.md = docs\Compilation\PosInfoMoq2005.md docs\Compilation\PosInfoMoq2006.md = docs\Compilation\PosInfoMoq2006.md + docs\Compilation\PosInfoMoq2007.md = docs\Compilation\PosInfoMoq2007.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 126135f..8e77105 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,6 @@ All the rules of this category should not be disabled (or changed their severity | [PosInfoMoq2004: Constructor arguments cannot be passed for interface mocks](docs/Compilation/PosInfoMoq2004.md) | No arguments can be passed to a mocked interface. | | [PosInfoMoq2005: Constructor arguments must match the constructors of the mocked class](docs/Compilation/PosInfoMoq2005.md) | When instantiating a `Mock`, the parameters must match one of the constructors of the mocked type. | | [PosInfoMoq2006: The Protected().Setup() method must be use with overridable protected or internal methods](docs/Compilation/PosInfoMoq2006.md) | When using the `Protected().Setup()` configuration, the method mocked must be overridable and protected or internal. | +| [PosInfoMoq2007: The `As()` method can be used only with interfaces.](docs/Compilation/PosInfoMoq2007.md) | The `As()` can only be use with the interfaces. | + diff --git a/docs/Compilation/PosInfoMoq2006.md b/docs/Compilation/PosInfoMoq2006.md index 244c507..c479384 100644 --- a/docs/Compilation/PosInfoMoq2006.md +++ b/docs/Compilation/PosInfoMoq2006.md @@ -23,7 +23,7 @@ and must be overridable (`virtual`, `abstract` and `override`, but not `sealed`) [Fact] public void Test() { - var service = new Mock(1, 2, 3); + var service = new Mock(); service.Protected().Setup("GetData") // The GetData() is public and can be mocked with Protected() feature. .Returns(10); service.Protected().Setup("NotExists") // The NotExists() method does not exist. diff --git a/docs/Compilation/PosInfoMoq2007.md b/docs/Compilation/PosInfoMoq2007.md new file mode 100644 index 0000000..fe4b66c --- /dev/null +++ b/docs/Compilation/PosInfoMoq2007.md @@ -0,0 +1,45 @@ +# PosInfoMoq2007: The `As()` method can be used only with interfaces. + +| Property | Value | +|-------------------------------------|-----------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2007 | +| **Title** | The `As()` method can be used only with interfaces. | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +The `As()` method is used with a type which is not an interface. + +## Rule description + +Moq allows to add additional implementations for mocked class (or interface) by adding additional interfaces +with the `As()` method. + +```csharp +[Fact] +public void Test() +{ + var service = new Mock(); + service.As() // Add IDisposable implementation for the mocked Service class. + .Setup(s => s.Dispose()); + service.As(); // An error will be raised, because we can't mock additional implementation of a class. +} + +public abstract class Service +{ +} + +public abstract class OtherService +{ +} +``` + +## How to fix violations + +To fix a violation of this rule, use an interface when using the `As()` 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 `MoqException` +thrown with the *"Can only add interfaces to the mock."* message. diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index 4a969ea..cc4fa46 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -1,4 +1,12 @@ -## Release 1.5.0 +## Release 1.6.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +PosInfoMoq2007 | Compilation | Error | The `As()` method can be used only with interfaces. + +## Release 1.5.0 ### New Rules diff --git a/src/Moq.Analyzers/Analyzers/AsMustBeUsedWithInterfaceAnalyzer.cs b/src/Moq.Analyzers/Analyzers/AsMustBeUsedWithInterfaceAnalyzer.cs new file mode 100644 index 0000000..c7fab3a --- /dev/null +++ b/src/Moq.Analyzers/Analyzers/AsMustBeUsedWithInterfaceAnalyzer.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------- +// +// 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 AsMustBeUsedWithInterfaceAnalyzer : DiagnosticAnalyzer + { + internal static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + "PosInfoMoq2007", + "The As() method can be used only with interfaces", + "The As() method can be used only with interfaces", + "Compilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The As() method can be used only with interfaces."); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + 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 moqExpressionAnalyzer = new MoqExpressionAnalyzer(context.SemanticModel); + + var asMethodType = moqExpressionAnalyzer.ExtractAsMethodType(moqSymbols, invocationExpression, out var typeSyntax, context.CancellationToken); + + if (asMethodType is null) + { + return; + } + + if (asMethodType.TypeKind == TypeKind.Interface) + { + return; + } + + var diagnostic = Diagnostic.Create(Rule, typeSyntax!.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs index 95bbd76..626888e 100644 --- a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs +++ b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs @@ -388,6 +388,37 @@ public IReadOnlyList ExtractSetupMembers(InvocationExpressionSyntax return methodSymbol; } + public ITypeSymbol? ExtractAsMethodType(MoqSymbols moqSymbols, InvocationExpressionSyntax invocationExpression, out TypeSyntax? typeSyntax, CancellationToken cancellationToken) + { + typeSyntax = null; + + if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccessExpression) + { + return null; + } + + if (memberAccessExpression.Name is not GenericNameSyntax genericName) + { + return null; + } + + var symbol = this.semanticModel.GetSymbolInfo(memberAccessExpression.Name, cancellationToken); + + if (symbol.Symbol is not IMethodSymbol methodSymbol) + { + return null; + } + + if (!moqSymbols.IsAsMethod(methodSymbol)) + { + return null; + } + + typeSyntax = genericName.TypeArgumentList.Arguments[0]; + + return methodSymbol.TypeArguments[0]; + } + private static ObjectCreationExpressionSyntax? FindMockCreation(BlockSyntax block, string variableName) { foreach (var statement in block.Statements.OfType()) diff --git a/src/Moq.Analyzers/MoqSymbols.cs b/src/Moq.Analyzers/MoqSymbols.cs index 6dff960..8913fba 100644 --- a/src/Moq.Analyzers/MoqSymbols.cs +++ b/src/Moq.Analyzers/MoqSymbols.cs @@ -6,6 +6,7 @@ namespace PosInformatique.Moq.Analyzers { + using System; using Microsoft.CodeAnalysis; internal sealed class MoqSymbols @@ -22,6 +23,8 @@ internal sealed class MoqSymbols private readonly ISymbol isAnyTypeClass; + private readonly ISymbol asMethod; + private MoqSymbols(INamedTypeSymbol mockClass, INamedTypeSymbol mockBehaviorEnum, ISymbol isAnyTypeClass, INamedTypeSymbol protectedMockInterface) { this.mockClass = mockClass; @@ -31,6 +34,7 @@ private MoqSymbols(INamedTypeSymbol mockClass, INamedTypeSymbol mockBehaviorEnum this.setupMethods = mockClass.GetMembers("Setup").OfType().ToArray(); this.mockBehaviorStrictField = mockBehaviorEnum.GetMembers("Strict").First(); this.setupProtectedMethods = protectedMockInterface.GetMembers("Setup").OfType().ToArray(); + this.asMethod = mockClass.GetMembers("As").Single(); } public static MoqSymbols? FromCompilation(Compilation compilation) @@ -275,5 +279,15 @@ public bool IsMockable(ITypeSymbol type) return false; } + + public bool IsAsMethod(IMethodSymbol method) + { + if (!SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, this.asMethod)) + { + return false; + } + + return true; + } } } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/AsMustBeUsedWithInterfaceAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/AsMustBeUsedWithInterfaceAnalyzerTest.cs new file mode 100644 index 0000000..1a90df9 --- /dev/null +++ b/tests/Moq.Analyzers.Tests/Analyzers/AsMustBeUsedWithInterfaceAnalyzerTest.cs @@ -0,0 +1,146 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers.Tests +{ + using System.Threading.Tasks; + using Xunit; + using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier< + AsMustBeUsedWithInterfaceAnalyzer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; + + public class AsMustBeUsedWithInterfaceAnalyzerTest + { + [Fact] + public async Task AsMethod_WithInterface() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.As(); + } + } + + public class C + { + } + + public interface I + { + } + }" + MoqLibrary.Code; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task AsMethod_WithClass() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.As<[|C2|]>(); + } + } + + public class C + { + } + + public class C2 + { + } + }" + MoqLibrary.Code; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoAsMethod() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + + public class TestClass + { + + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(m => m.Method()); + + var action = new System.Action(() => { }); + action(); // Ignored by the ExtractAsMethodType() method because not a MemberAccessExpressionSyntax. + + var otherClass = new OtherClass(); + otherClass.GenericMethodNotAs(); // Ignored by the ExtractAsMethodType() method because it not As() symbol. + } + } + + public class C + { + public virtual void Method() { } + } + + public class OtherClass + { + public void GenericMethodNotAs() { } + } + }" + MoqLibrary.Code; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoMoqLibrary() + { + var source = @" + namespace ConsoleApplication1 + { + using OtherNamespace; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.As(); + } + } + + public interface I + { + } + } + + namespace OtherNamespace + { + public class Mock + { + public void As() { } + } + }"; + + await Verify.VerifyAnalyzerAsync(source); + } + } +} \ No newline at end of file diff --git a/tests/Moq.Analyzers.Tests/MoqLibrary.cs b/tests/Moq.Analyzers.Tests/MoqLibrary.cs index a6652c1..556330a 100644 --- a/tests/Moq.Analyzers.Tests/MoqLibrary.cs +++ b/tests/Moq.Analyzers.Tests/MoqLibrary.cs @@ -19,6 +19,8 @@ public Mock(MockBehavior _ = MockBehavior.Loose, params object[] args) { } public Mock(params object[] args) { } + public Mock As() { return null; } + public ISetup Setup(Action act) { return null; } public ISetup Setup(Func func) { return default; }