Skip to content

Commit 4cf69db

Browse files
authored
Remove false positive for Moq1200 when using parameterized lambda (#301)
When using Moq's expression features for type construction, the Moq1200 analyzer was firing unexpectedly indicating a matching constructor on the type was not found. The `ConstructorArgumentsShouldMatchAnalyzer` was extracting the parameters from the Mock type and comparing them to the parameters of the constructed type as is, including the lambda and the `MockBehavior`. Example: ```csharp _ = new Mock<Calculator>(() => new Calculator(), MockBehavior.Loose); ``` > See #234 for more details This is incorrect for several reasons: 1. The parenthesized lambda is not itself a parameter for the target type, but rather the body 2. The `MockBehavior` would not likely be a parameter on the target type Correct analysis would be to drop the `MockBehavior` argument as with other configurations of the `Mock<T>` type, and use the body of the lambda. However, using the body of the lambda is not necessary. The purpose of this analyzer is to detect errors that would not be caught at compile time that would result in a runtime error. In this case, using a constructor not available on the type would result in a compiler error. As such, the constructor detection method has been updated to return a tertiary result: `true` when there is a matching constructor found, `false` when there is not, and `null` when the constructor is a lambda (i.e., the constructor should be ignored). Changes: - Add unit test for issue #234 - Add support to ignore parameterized lambda arguments when performing constructor detection - Add support `MockBehavior` definitions to be in ordinal position 0 or 1 Fixes #234
1 parent 01a43d2 commit 4cf69db

File tree

2 files changed

+78
-8
lines changed

2 files changed

+78
-8
lines changed

src/Analyzers/ConstructorArgumentsShouldMatchAnalyzer.cs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ private static bool IsExpressionMockBehavior(SyntaxNodeAnalysisContext context,
9999
return targetSymbol.IsInstanceOf(knownSymbols.MockBehavior);
100100
}
101101

102-
private static bool IsFirstArgumentMockBehavior(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols, ArgumentListSyntax? argumentList)
102+
private static bool IsArgumentMockBehavior(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols, ArgumentListSyntax? argumentList, uint argumentOrdinal)
103103
{
104-
ExpressionSyntax? expression = argumentList?.Arguments[0].Expression;
104+
ExpressionSyntax? expression = argumentList?.Arguments.Count > argumentOrdinal ? argumentList.Arguments[(int)argumentOrdinal].Expression : null;
105105

106106
return IsExpressionMockBehavior(context, knownSymbols, expression);
107107
}
@@ -267,10 +267,13 @@ private static void AnalyzeNewObject(SyntaxNodeAnalysisContext context, MoqKnown
267267
/// <param name="constructors">The constructors.</param>
268268
/// <param name="arguments">The arguments.</param>
269269
/// <param name="context">The context.</param>
270-
/// <returns><c>true</c> if a suitable constructor was found; otherwise <c>false</c>. </returns>
270+
/// <returns>
271+
/// <see langword="true" /> if a suitable constructor was found; otherwise <see langword="false" />.
272+
/// If the construction method is a parenthesized lambda expression, <see langword="null" /> is returned.
273+
/// </returns>
271274
/// <remarks>Handles <see langword="params" /> and optional parameters.</remarks>
272275
[SuppressMessage("Design", "MA0051:Method is too long", Justification = "This should be refactored; suppressing for now to enable TreatWarningsAsErrors in CI.")]
273-
private static bool AnyConstructorsFound(
276+
private static bool? AnyConstructorsFound(
274277
IMethodSymbol[] constructors,
275278
ArgumentSyntax[] arguments,
276279
SyntaxNodeAnalysisContext context)
@@ -348,6 +351,24 @@ private static bool AnyConstructorsFound(
348351
}
349352
}
350353

354+
// Special case for Lambda expression syntax
355+
// In Moq you can specify a Lambda expression that creates an instance
356+
// of the specified type
357+
// See https://github.com/devlooped/moq/blob/18dc7410ad4f993ce0edd809c5dfcaa3199f13ff/src/Moq/Mock%601.cs#L200
358+
//
359+
// The parenthesized lambda takes arguments as the first child node
360+
// which may be empty or have args defined as part of a closure.
361+
// Either way, we don't care about that, we only care that the
362+
// constructor is valid.
363+
//
364+
// Since this does not use reflection through Castle, an invalid
365+
// lambda here would cause the compiler to break, so no need to
366+
// do additional checks.
367+
if (arguments.Length == 1 && arguments[0].Expression.IsKind(SyntaxKind.ParenthesizedLambdaExpression))
368+
{
369+
return null;
370+
}
371+
351372
return false;
352373
}
353374

@@ -386,10 +407,18 @@ private static void VerifyMockAttempt(
386407
ArgumentSyntax[] arguments = argumentList?.Arguments.ToArray() ?? [];
387408
#pragma warning restore ECS0900 // Consider using an alternative implementation to avoid boxing and unboxing
388409

389-
if (hasMockBehavior && arguments.Length > 0 && IsFirstArgumentMockBehavior(context, knownSymbols, argumentList))
410+
if (hasMockBehavior && arguments.Length > 0)
390411
{
391-
// They passed a mock behavior as the first argument; ignore as Moq swallows it
392-
arguments = arguments.RemoveAt(0);
412+
if (arguments.Length >= 1 && IsArgumentMockBehavior(context, knownSymbols, argumentList, 0))
413+
{
414+
// They passed a mock behavior as the first argument; ignore as Moq swallows it
415+
arguments = arguments.RemoveAt(0);
416+
}
417+
else if (arguments.Length >= 2 && IsArgumentMockBehavior(context, knownSymbols, argumentList, 1))
418+
{
419+
// They passed a mock behavior as the second argument; ignore as Moq swallows it
420+
arguments = arguments.RemoveAt(1);
421+
}
393422
}
394423

395424
switch (mockedClass.TypeKind)
@@ -433,7 +462,9 @@ private static void VerifyClassMockAttempt(
433462
}
434463

435464
// We have constructors, now we need to check if the arguments match any of them
436-
if (!AnyConstructorsFound(constructors, arguments, context))
465+
// If the value is null it means we want to ignore and not create a diagnostic
466+
bool? matchingCtorFound = AnyConstructorsFound(constructors, arguments, context);
467+
if (matchingCtorFound.HasValue && !matchingCtorFound.Value)
437468
{
438469
Diagnostic diagnostic = constructorIsEmpty.Location.CreateDiagnostic(ClassMustHaveMatchingConstructor, argumentList);
439470
context.ReportDiagnostic(diagnostic);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier<Moq.Analyzers.ConstructorArgumentsShouldMatchAnalyzer>;
2+
3+
namespace Moq.Analyzers.Test;
4+
5+
public partial class ConstructorArgumentsShouldMatchAnalyzerTests
6+
{
7+
public static IEnumerable<object[]> ExpressionTestData()
8+
{
9+
return new object[][]
10+
{
11+
["""_ = new Mock<Calculator>(() => new Calculator(), MockBehavior.Loose);"""],
12+
["""_ = new Mock<Calculator>(() => new Calculator(), MockBehavior.Strict);"""],
13+
["""_ = new Mock<Calculator>(() => new Calculator(), MockBehavior.Default);"""],
14+
["""_ = new Mock<Calculator>(() => new Calculator());"""],
15+
}.WithNamespaces().WithMoqReferenceAssemblyGroups();
16+
}
17+
18+
[Theory]
19+
[MemberData(nameof(ExpressionTestData))]
20+
public async Task ShouldPassIfExpressionWithDefaultCtorIsUsedWithMockBehavior(string referenceAssemblyGroup, string @namespace, string mock)
21+
{
22+
await Verifier.VerifyAnalyzerAsync(
23+
$@"
24+
{@namespace}
25+
public class Calculator
26+
{{
27+
public int Add(int a, int b) => a + b;
28+
}}
29+
internal class UnitTest
30+
{{
31+
private void Test()
32+
{{
33+
{mock}
34+
}}
35+
}}
36+
",
37+
referenceAssemblyGroup);
38+
}
39+
}

0 commit comments

Comments
 (0)