Skip to content
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

Add rule to explicitly pick MockBehavior #226

Merged
merged 8 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/rules/Moq1400.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Moq1400: Explicitly choose a mocking behavior instead of relying on the default (Loose) behavior
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

| Item | Value |
| --- | --- |
| Enabled | True |
| Severity | Warning |
| CodeFix | False |
---

Mocks use the `MockBehavior.Loose` by default. Some people find this default behavior undesirable, as it can lead to
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
unexpected behavior if the mock is improperly set up. To fix, specify either `MockBehavior.Loose` or
`MockBehavior.Strict` to signify acknowledgement of the mock's behavior.
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

## Examples of patterns that are flagged by this analyzer

```csharp
interface ISample
{
int Calculate() => 0;
}

var mock = new Mock<ISample>(); // Moq1400: Moq: Explicitly choose a mock behavior
var mock2 = Mock.Of<ISample>(); // Moq1400: Moq: Explicitly choose a mock behavior
```

```csharp
interface ISample
{
int Calculate() => 0;
}

var mock = new Mock<ISample>(MockBehavior.Default); // Moq1400: Moq: Explicitly choose a mock behavior
var mock2 = Mock.Of<ISample>(MockBehavior.Default); // Moq1400: Moq: Explicitly choose a mock behavior
var repo = new MockRepository(MockBehavior.Default); // Moq1400: Moq: Explicitly choose a mock behavior
```
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

## Solution

```csharp
interface ISample
{
int Calculate() => 0;
}

var mock = new Mock<ISample>(MockBehavior.Strict); // Or `MockBehavior.Loose`
var mock2 = new Mock.Of<ISample>(MockBehavior.Strict); // Or `MockBehavior.Loose`
var repo = new MockRepository(MockBehavior.Strict); // Or `MockBehavior.Loose`
```
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions src/Moq.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
Moq1400 | Moq | Warning | SetExplicitMockBehaviorAnalyzer, [Documentation](https://github.com/rjmurillo/moq.analyzers/blob/main/docs/rules/Moq1400.md)
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 0 additions & 2 deletions src/Moq.Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,10 @@ private static void Analyze(OperationAnalysisContext context, ImmutableArray<IMe
}

IMethodSymbol targetMethod = invocationOperation.TargetMethod;
#pragma warning disable ECS0900 // Minimize boxing and unboxing
if (!targetMethod.IsInstanceOf(wellKnownAsMethods))
{
return;
}
#pragma warning restore ECS0900 // Minimize boxing and unboxing

ImmutableArray<ITypeSymbol> typeArguments = targetMethod.TypeArguments;
if (typeArguments.Length != 1)
Expand Down
1 change: 1 addition & 0 deletions src/Moq.Analyzers/Common/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ internal static class DiagnosticIds
internal const string SetupOnlyUsedForOverridableMembers = "Moq1200";
internal const string AsyncUsesReturnsAsyncInsteadOfResult = "Moq1201";
internal const string AsShouldOnlyBeUsedForInterfacesRuleId = "Moq1300";
internal const string SetExplicitMockBehavior = "Moq1400";
}
30 changes: 22 additions & 8 deletions src/Moq.Analyzers/Common/ISymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,33 @@ internal static class ISymbolExtensions
/// <see langword="true"/> if <paramref name="symbol"/> is an instance of <paramref name="other"/>, either as a direct match,
/// or as a specialaization; otherwise, <see langword="false"/>.
/// </returns>
/// <remarks>
/// As an example, <c>Type.Method&lt;int&gt;()</c> is an instance of <c>Type.Method&lt;T&gt;()</c>.
/// </remarks>
/// <example>
/// <c>MyType.MyMethod&lt;int&gt;()</c> is an instance of <c>MyType.MyMethod&lt;T&gt;()</c>.
/// </example>
/// <example>
/// <c>MyType&lt;int&gt;()</c> is an instance of <c>MyType&lt;T&gt;()</c>.
/// </example>
public static bool IsInstanceOf<TSymbol>(this ISymbol symbol, TSymbol other, SymbolEqualityComparer? symbolEqualityComparer = null)
where TSymbol : class, ISymbol
{
symbolEqualityComparer ??= SymbolEqualityComparer.Default;

return symbol switch
if (symbol is IMethodSymbol methodSymbol)
{
IMethodSymbol methodSymbol => symbolEqualityComparer.Equals(methodSymbol.OriginalDefinition, other),
_ => symbolEqualityComparer.Equals(symbol, other),
};
return symbolEqualityComparer.Equals(methodSymbol.OriginalDefinition, other);
}

if (symbol is INamedTypeSymbol namedTypeSymbol)
{
if (namedTypeSymbol.IsGenericType)
{
namedTypeSymbol = namedTypeSymbol.ConstructedFrom;
}

return symbolEqualityComparer.Equals(namedTypeSymbol, other);
}

return symbolEqualityComparer.Equals(symbol, other);
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
}

/// <inheritdoc cref="IsInstanceOf{TSymbol}(ISymbol, TSymbol, SymbolEqualityComparer?)"/>
Expand All @@ -36,7 +50,7 @@ public static bool IsInstanceOf<TSymbol>(this ISymbol symbol, TSymbol other, Sym
/// The symbols to compare to. Returns <see langword="true"/> if <paramref name="symbol"/> matches any of others.
/// </param>
/// <param name="symbolEqualityComparer">The <see cref="SymbolEqualityComparer"/> to use for equality.</param>
public static bool IsInstanceOf<TSymbol>(this ISymbol symbol, IEnumerable<TSymbol> others, SymbolEqualityComparer? symbolEqualityComparer = null)
public static bool IsInstanceOf<TSymbol>(this ISymbol symbol, ImmutableArray<TSymbol> others, SymbolEqualityComparer? symbolEqualityComparer = null)
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
where TSymbol : class, ISymbol
{
symbolEqualityComparer ??= SymbolEqualityComparer.Default;
Expand Down
136 changes: 136 additions & 0 deletions src/Moq.Analyzers/SetExplicitMockBehaviorAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using Microsoft.CodeAnalysis.Operations;
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

namespace Moq.Analyzers;
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Mock should explicitly specify a behavior and not rely on the default.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SetExplicitMockBehaviorAnalyzer : DiagnosticAnalyzer
{
private static readonly LocalizableString Title = "Moq: Explicitly choose a mock behavior";
private static readonly LocalizableString Message = "Explicitly choose a mocking behavior instead of relying on the default (Loose) behavior";

private static readonly DiagnosticDescriptor Rule = new(
DiagnosticIds.SetExplicitMockBehavior,
Title,
Message,
DiagnosticCategory.Moq,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.SetExplicitMockBehavior}.md");

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);

/// <inheritdoc />
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(RegisterCompilationStartAction);
}

private static void RegisterCompilationStartAction(CompilationStartAnalysisContext context)
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
{
// Ensure Moq is referenced in the compilation
ImmutableArray<INamedTypeSymbol> mockTypes = context.Compilation.GetMoqMock();
if (mockTypes.IsEmpty)
{
return;
}
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved

// Look for the MockBehavior type and provide it to Analyze to avoid looking it up multiple times.
INamedTypeSymbol? mockBehaviorSymbol = context.Compilation.GetTypeByMetadataName(WellKnownTypeNames.MoqBehavior);
if (mockBehaviorSymbol is null)
{
return;
}
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved

// Look for the Mock.Of() method and provide it to Analyze to avoid looking it up multiple times.
#pragma warning disable ECS0900 // Minimize boxing and unboxing
ImmutableArray<IMethodSymbol> ofMethods = mockTypes
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
.SelectMany(mockType => mockType.GetMembers(WellKnownTypeNames.Of))
.OfType<IMethodSymbol>()
.Where(method => method.IsGenericMethod)
.ToImmutableArray();
#pragma warning restore ECS0900 // Minimize boxing and unboxing
Comment on lines +52 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Clarify Suppression of 'ECS0900' Warning

The suppression of the ECS0900 warning for minimizing boxing and unboxing is noted. Consider providing a comment explaining why this suppression is necessary to aid future maintainability.

Apply this diff to add an explanatory comment:

 #pragma warning disable ECS0900 // Minimize boxing and unboxing
+// Suppressing this warning because the LINQ query does not significantly impact performance in this context.
 ImmutableArray<IMethodSymbol> ofMethods = mockTypes
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#pragma warning disable ECS0900 // Minimize boxing and unboxing
ImmutableArray<IMethodSymbol> ofMethods = mockTypes
.SelectMany(mockType => mockType.GetMembers(WellKnownTypeNames.Of))
.OfType<IMethodSymbol>()
.Where(method => method.IsGenericMethod)
.ToImmutableArray();
#pragma warning restore ECS0900 // Minimize boxing and unboxing
#pragma warning disable ECS0900 // Minimize boxing and unboxing
// Suppressing this warning because the LINQ query does not significantly impact performance in this context.
ImmutableArray<IMethodSymbol> ofMethods = mockTypes
.SelectMany(mockType => mockType.GetMembers(WellKnownTypeNames.Of))
.OfType<IMethodSymbol>()
.Where(method => method.IsGenericMethod)
.ToImmutableArray();
#pragma warning restore ECS0900 // Minimize boxing and unboxing


context.RegisterOperationAction(
context => AnalyzeNewObject(context, mockTypes, mockBehaviorSymbol),
OperationKind.ObjectCreation);

if (!ofMethods.IsEmpty)
{
context.RegisterOperationAction(
context => AnalyzeInvocation(context, ofMethods, mockBehaviorSymbol),
OperationKind.Invocation);
}
}

private static void AnalyzeNewObject(OperationAnalysisContext context, ImmutableArray<INamedTypeSymbol> mockTypes, INamedTypeSymbol mockBehaviorSymbol)
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
{
if (context.Operation is not IObjectCreationOperation creationOperation)
{
return;
}

if (creationOperation.Type is not INamedTypeSymbol namedType)
{
return;
}

if (!namedType.IsInstanceOf(mockTypes))
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
{
return;
}

foreach (IArgumentOperation argument in creationOperation.Arguments)
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
{
if (argument.Value is IFieldReferenceOperation fieldReferenceOperation)
{
ISymbol field = fieldReferenceOperation.Member;
if (field.ContainingType.IsInstanceOf(mockBehaviorSymbol) && IsExplicitBehavior(field.Name))
{
return;
}
}
Comment on lines +89 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Handle Explicit Behaviors Specified via Variables or Expressions

The current implementation checks for mock behaviors specified directly as field references. Consider extending the analysis to handle scenarios where the mock behavior is provided via variables, method calls, or expressions to avoid false positives.

For example, if a mock behavior is assigned to a variable and then passed as an argument, the analyzer may incorrectly report a diagnostic. Enhancing the logic to perform a more comprehensive analysis of the argument values can improve accuracy.

}

context.ReportDiagnostic(creationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Provide More Specific Diagnostic Location

When reporting the diagnostic, consider pinpointing the location of the default mock behavior argument rather than the entire object creation syntax. This will help developers identify the exact issue more efficiently.

Apply this diff to report the diagnostic at the specific argument location:

-context.ReportDiagnostic(creationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
+if (creationOperation.Arguments.Length > 0)
+{
+    context.ReportDiagnostic(creationOperation.Arguments[0].Syntax.GetLocation().CreateDiagnostic(Rule));
+}
+else
+{
+    context.ReportDiagnostic(creationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
context.ReportDiagnostic(creationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
if (creationOperation.Arguments.Length > 0)
{
context.ReportDiagnostic(creationOperation.Arguments[0].Syntax.GetLocation().CreateDiagnostic(Rule));
}
else
{
context.ReportDiagnostic(creationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
}

}

private static void AnalyzeInvocation(OperationAnalysisContext context, ImmutableArray<IMethodSymbol> wellKnownOfMethods, INamedTypeSymbol mockBehaviorSymbol)
{
if (context.Operation is not IInvocationOperation invocationOperation)
{
return;
}

IMethodSymbol targetMethod = invocationOperation.TargetMethod;
if (!targetMethod.IsInstanceOf(wellKnownOfMethods))
{
return;
}

foreach (IArgumentOperation argument in invocationOperation.Arguments)
MattKotsenas marked this conversation as resolved.
Show resolved Hide resolved
{
if (argument.Value is IFieldReferenceOperation fieldReferenceOperation)
{
ISymbol field = fieldReferenceOperation.Member;
if (field.ContainingType.IsInstanceOf(mockBehaviorSymbol) && IsExplicitBehavior(field.Name))
{
return;
}
}
Comment on lines +117 to +126
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Unified Argument Analysis across Methods

The argument analysis logic in AnalyzeInvocation mirrors that in AnalyzeNewObject. Consider refactoring this repetitive code into a shared method to enhance maintainability and reduce duplication.

Extract the argument analysis into a helper method:

private static bool HasExplicitBehavior(ImmutableArray<IArgumentOperation> arguments, INamedTypeSymbol mockBehaviorSymbol)
{
    foreach (IArgumentOperation argument in arguments)
    {
        if (argument.Value is IFieldReferenceOperation fieldReferenceOperation)
        {
            ISymbol field = fieldReferenceOperation.Member;
            if (field.ContainingType.IsInstanceOf(mockBehaviorSymbol) && IsExplicitBehavior(field.Name))
            {
                return true;
            }
        }
    }
    return false;
}

Then update the methods:

// In AnalyzeNewObject
-if (/* argument analysis logic */)
+if (HasExplicitBehavior(creationOperation.Arguments, mockBehaviorSymbol))

// In AnalyzeInvocation
-if (/* argument analysis logic */)
+if (HasExplicitBehavior(invocationOperation.Arguments, mockBehaviorSymbol))

}

context.ReportDiagnostic(invocationOperation.Syntax.GetLocation().CreateDiagnostic(Rule));
}

private static bool IsExplicitBehavior(string symbolName)
{
return string.Equals(symbolName, "Loose", StringComparison.Ordinal) || string.Equals(symbolName, "Strict", StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal static class ReferenceAssemblyCatalog
// implementation of `.As<T>()` (see https://github.com/devlooped/moq/commit/b552aeddd82090ee0f4743a1ab70a16f3e6d2d11).
{ nameof(Net80WithOldMoq), ReferenceAssemblies.Net.Net80.AddPackages([new PackageIdentity("Moq", "4.8.2")]) },

// This must be 4.12.0 or later in order to have the new `Mock.Of<T>(MockBehavior)` method (see https://github.com/devlooped/moq/commit/1561c006c87a0894c5257a1e541da44e40e33dd3).
// 4.18.4 is currently the most downloaded version of Moq.
{ nameof(Net80WithNewMoq), ReferenceAssemblies.Net.Net80.AddPackages([new PackageIdentity("Moq", "4.18.4")]) },
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier<Moq.Analyzers.SetExplicitMockBehaviorAnalyzer>;

namespace Moq.Analyzers.Test;

public class SetExplicitMockBehaviorAnalyzerTests
{
public static IEnumerable<object[]> TestData()
{
IEnumerable<object[]> mockConstructors = new object[][]
{
["""{|Moq1400:new Mock<ISample>()|};"""],
["""{|Moq1400:new Mock<ISample>(MockBehavior.Default)|};"""],
["""new Mock<ISample>(MockBehavior.Loose);"""],
["""new Mock<ISample>(MockBehavior.Strict);"""],
}.WithNamespaces().WithMoqReferenceAssemblyGroups();

IEnumerable<object[]> fluentBuilders = new object[][]
{
["""{|Moq1400:Mock.Of<ISample>()|};"""],
["""{|Moq1400:Mock.Of<ISample>(MockBehavior.Default)|};"""],
["""Mock.Of<ISample>(MockBehavior.Loose);"""],
["""Mock.Of<ISample>(MockBehavior.Strict);"""],
}.WithNamespaces().WithNewMoqReferenceAssemblyGroups();

IEnumerable<object[]> mockRepositories = new object[][]
{
["""{|Moq1400:new MockRepository(MockBehavior.Default)|};"""],
["""new MockRepository(MockBehavior.Loose);"""],
["""new MockRepository(MockBehavior.Strict);"""],
}.WithNamespaces().WithNewMoqReferenceAssemblyGroups();

return mockConstructors.Union(fluentBuilders).Union(mockRepositories);
}
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved

[Theory]
[MemberData(nameof(TestData))]
public async Task ShouldAnalyzeMocksWithoutExplictMockBehavior(string referenceAssemblyGroup, string @namespace, string mock)
{
await Verifier.VerifyAnalyzerAsync(
$$"""
{{@namespace}}

public interface ISample
{
int Calculate(int a, int b);
}

internal class UnitTest
{
private void Test()
{
{{mock}}
}
}
""",
referenceAssemblyGroup);
}
rjmurillo marked this conversation as resolved.
Show resolved Hide resolved
}