Skip to content
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
Comment thread
rjmurillo marked this conversation as 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
Comment thread
rjmurillo marked this conversation as 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.
Comment thread
rjmurillo marked this conversation as 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
```
Comment thread
rjmurillo marked this conversation as 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`
```
Comment thread
rjmurillo marked this conversation as 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
Comment thread
rjmurillo marked this conversation as resolved.

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
Moq1400 | Moq | Warning | SetExplicitMockBehaviorAnalyzer, [Documentation](https://github.com/rjmurillo/moq.analyzers/blob/main/docs/rules/Moq1400.md)
Comment thread
MattKotsenas marked this conversation as resolved.
Comment thread
MattKotsenas marked this conversation as resolved.
Comment thread
MattKotsenas marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as 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);
Comment thread
rjmurillo marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as 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)
Comment thread
rjmurillo marked this conversation as 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;
Comment thread
MattKotsenas marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as resolved.

namespace Moq.Analyzers;
Comment thread
MattKotsenas marked this conversation as 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)
Comment thread
MattKotsenas marked this conversation as resolved.
{
// Ensure Moq is referenced in the compilation
ImmutableArray<INamedTypeSymbol> mockTypes = context.Compilation.GetMoqMock();
if (mockTypes.IsEmpty)
{
return;
}
Comment thread
MattKotsenas marked this conversation as 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;
}
Comment thread
MattKotsenas marked this conversation as 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
Comment thread
MattKotsenas marked this conversation as 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
Copy Markdown
Contributor

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)
Comment thread
MattKotsenas marked this conversation as resolved.
{
if (context.Operation is not IObjectCreationOperation creationOperation)
{
return;
}

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

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

foreach (IArgumentOperation argument in creationOperation.Arguments)
Comment thread
MattKotsenas marked this conversation as 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
Copy Markdown
Contributor

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
Copy Markdown
Contributor

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)
Comment thread
MattKotsenas marked this conversation as 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
Copy Markdown
Contributor

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")]) },
Comment thread
rjmurillo marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as 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);
}
Comment thread
rjmurillo marked this conversation as 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);
}
Comment thread
rjmurillo marked this conversation as resolved.
}