From 741f2100da030c07a1476175ffce336aff0e0514 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 3 Oct 2025 16:27:47 +0200 Subject: [PATCH 01/14] Add the PosInfoMoq2017 to check Raise() and RaiseAsync() methods parameters (fixes #51). --- PosInformatique.Moq.Analyzers.sln | 1 + README.md | 1 + docs/Compilation/PosInfoMoq2017.md | 91 +++++ src/Moq.Analyzers/AnalyzerReleases.Shipped.md | 9 +- ...rametersMustMatchEventSignatureAnalyzer.cs | 120 +++++++ src/Moq.Analyzers/Moq.Analyzers.csproj | 16 + src/Moq.Analyzers/MoqExpressionAnalyzer.cs | 52 +++ src/Moq.Analyzers/MoqSymbols.cs | 56 ++++ src/Moq.Analyzers/RaiseMethodCall.cs | 34 ++ ...tersMustMatchEventSignatureAnalyzerTest.cs | 313 ++++++++++++++++++ 10 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 docs/Compilation/PosInfoMoq2017.md create mode 100644 src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs create mode 100644 src/Moq.Analyzers/RaiseMethodCall.cs create mode 100644 tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index 9aed5d6..b0932c3 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -59,6 +59,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation" docs\Compilation\PosInfoMoq2014.md = docs\Compilation\PosInfoMoq2014.md docs\Compilation\PosInfoMoq2015.md = docs\Compilation\PosInfoMoq2015.md docs\Compilation\PosInfoMoq2016.md = docs\Compilation\PosInfoMoq2016.md + docs\Compilation\PosInfoMoq2017.md = docs\Compilation\PosInfoMoq2017.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 f93ff02..3523381 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ All the rules of this category should not be disabled (or changed their severity | [PosInfoMoq2014: The `Callback()` delegate expression must not return a value.](docs/Compilation/PosInfoMoq2014.md) | The `Callback()` delegate expression must not return a value. | | [PosInfoMoq2015: The `Protected().Setup()` method must match the return type of the mocked method](docs/Compilation/PosInfoMoq2015.md) | The method setup with `Protected().Setup()` must match the return type of the mocked method. | | [PosInfoMoq2016: `Mock` constructor with factory lambda expression can be used only with classes.](docs/Compilation/PosInfoMoq2016.md) | The factory lambda expression used in `Mock` instantiation must used only for the classes. | +| [PosInfoMoq2017: `Mock.Raise()`/`RaiseAsync()` must use parameters matching the event signature.](docs/Compilation/PosInfoMoq2017.md) | The parameters passed to `Raise()` or `RaiseAsync()` must exactly match the parameters of the corresponding event delegate. | diff --git a/docs/Compilation/PosInfoMoq2017.md b/docs/Compilation/PosInfoMoq2017.md new file mode 100644 index 0000000..20b47f7 --- /dev/null +++ b/docs/Compilation/PosInfoMoq2017.md @@ -0,0 +1,91 @@ +# PosInfoMoq2017: `Mock.Raise()`/`RaiseAsync()` must use parameters matching the event signature + +| Property | Value | +|-----------------------|----------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2017 | +| **Title** | `Mock.Raise()`/`RaiseAsync()` must use parameters matching the event signature | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +The parameters passed to `Raise()` or `RaiseAsync()` must exactly match the parameters of the corresponding event delegate. + +## Rule description + +When mocking events with **Moq**, calling `Mock.Raise()` or `Mock.RaiseAsync()` requires that the provided arguments match the event signature. + +- With the **`Raise(event, EventArgs)`** overload: + - Used for `EventHandler` or `EventHandler`. + - Moq automatically supplies the `sender` (the mocked object). + - You only pass the `EventArgs` (or derived class) instance. + +- With the **`Raise(event, params object[])`** overload: + - You provide all the arguments of the event delegate yourself. + - This includes the `sender` and all additional event arguments. + +### Example + +```csharp +public class Service +{ + public event EventHandler Changed; + public event EventHandler DataChanged; + + public void DoSomething() + { + this.Changed?.Invoke(this, EventArgs.Empty); + this.DataChanged?.Invoke(this, new DataEventArgs(42)); + } +} + +public class DataEventArgs : EventArgs +{ + public int Value { get; } + public DataEventArgs(int value) => Value = value; +} +``` + +#### Correct usage (matching parameters) + +```csharp +var serviceMock = new Mock(); + +// Raise with EventHandler (sender is mocked object, EventArgs required) +serviceMock.Raise(s => s.Changed += null, EventArgs.Empty); + +// Raise with EventHandler (sender is mocked object, T required) +serviceMock.Raise(s => s.DataChanged += null, new DataEventArgs(42)); + +// Raise with params object[] (sender + event args explicitly) +serviceMock.Raise(s => s.DataChanged += null, "CustomSender", new DataEventArgs(42)); +``` + +#### Incorrect usage (parameters not matching) + +```csharp +var serviceMock = new Mock(); + +// Missing EventArgs +serviceMock.Raise(s => s.Changed += null); // ❌ + +// Wrong argument type +serviceMock.Raise(s => s.DataChanged += null, EventArgs.Empty); // ❌ +``` + +## How to fix violations + +To fix a violation, ensure that the arguments passed to `Raise()` or `RaiseAsync()` match **exactly** the event delegate signature: + +- `EventHandler`: `(object sender, EventArgs e)` +- `EventHandler`: `(object sender, T e)` + +Depending on the overload: +- With `Raise(event, EventArgs)` → provide only `EventArgs` or `T`. +- With `Raise(event, params object[])` → provide both `sender` and `EventArgs` (or `T`). + +## When to suppress warnings + +Do not suppress errors from this rule. If skipped, Moq will throw a runtime exception such as `TargetParameterCountException` +(with the following message *"Parameter count mismatch"*) or an `ArgumentException` to explain that a value can not be converted +to an other type. diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index b41611f..20ba7a2 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -1,4 +1,11 @@ -## Release 1.13.0 +## Release 2.0.0 + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +PosInfoMoq2017 | Compilation | Error | RaiseParametersMustMatchEventSignatureAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html) + +## Release 1.13.0 ### New Rules Rule ID | Category | Severity | Notes diff --git a/src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs b/src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs new file mode 100644 index 0000000..f19da40 --- /dev/null +++ b/src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs @@ -0,0 +1,120 @@ +//----------------------------------------------------------------------- +// +// 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 RaiseParametersMustMatchEventSignatureAnalyzer : DiagnosticAnalyzer + { + internal static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + "PosInfoMoq2017", + "The Raise() parameters must match the signature of the mocked event", + "The Raise() parameters must match the signature of the mocked event. {0}.", + "Compilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The Raise() parameters must match the signature of the mocked event.", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html"); + + 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 moqSymbols = MoqSymbols.FromCompilation(context.Compilation); + + if (moqSymbols is null) + { + return; + } + + // Extract the Raise() method call info (event + parameters) + var invocationExpression = (InvocationExpressionSyntax)context.Node; + + var moqExpressionAnalyzer = new MoqExpressionAnalyzer(moqSymbols, context.SemanticModel); + + var raiseMethod = moqExpressionAnalyzer.ExtractRaiseMethodCall(invocationExpression, context.CancellationToken); + + if (raiseMethod is null) + { + return; + } + + var eventParameters = raiseMethod.EventParameters.ToList(); + + // Check the overload of the Raise() method called. + if (moqSymbols.IsEventArgs(raiseMethod.Method.Parameters[1].Type)) + { + // Case calling the overload : Raise(x => x.Event, EventArgs) + // We don't check in this case the "object sender" parameter + // of the event, because Raise(x => x.Event, EventArgs) + // don't propose to pass this parameter. + eventParameters.RemoveAt(0); + } + + // Check the parameters count match + if (raiseMethod.MethodParameters.Count != eventParameters.Count) + { + var raiseMethodSyntax = GetRaiseMethodSyntax(invocationExpression); + + if (raiseMethodSyntax is null) + { + return; + } + + context.ReportDiagnostic(Rule, raiseMethodSyntax.GetLocation(), $"The event '{raiseMethod.Event.Name}' expects {raiseMethod.EventParameters.Count} argument(s) but {raiseMethod.MethodParameters.Count} were provided."); + return; + } + + // Check the arguments of the Raise() method match the parameters of the event. + for (var i = 0; i < raiseMethod.MethodParameters.Count; i++) + { + var methodParameter = raiseMethod.MethodParameters[i]; + var eventParameter = eventParameters[i]; + + if (methodParameter is null) + { + // Check the event parameter is nullable. + if (!eventParameter.Type.IsReferenceType) + { + context.ReportDiagnostic(Rule, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value 'null' was provided."); + } + + continue; + } + + if (!methodParameter.IsOrInheritFrom(eventParameter.Type)) + { + context.ReportDiagnostic(Rule, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value of type '{methodParameter.Name}' was provided."); + continue; + } + } + } + + private static SyntaxNode? GetRaiseMethodSyntax(InvocationExpressionSyntax expressionSyntax) + { + if (expressionSyntax.Expression is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) + { + return null; + } + + return memberAccessExpressionSyntax.Name; + } + } +} diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index 99b7406..63fb1cc 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -17,6 +17,22 @@ https://github.com/PosInformatique/PosInformatique.Moq.Analyzers README.md + 2.0.0 + - Add new rules: + - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. + + 1.13.0 + - Add new rules: + - PosInfoMoq1007: The Verify() method must specify the Times argument. + - PosInfoMoq1008: The Mock.Verify() and Mock.VerifyAll() methods must specify at least one mock. + - PosInfoMoq1009: Avoid using Verifiable() method. + - Improvements/Fixes + - When using It.IsAny<T>() or It.Is<T>() with inherited class, the PosInfoMoq1006 was raised. + + 1.12.0 + - Add new rules: + - PosInfoMoq1006: The It.IsAny<T>() or It.Is<T>() arguments must match the parameters of the mocked method. + 1.11.0 - Add new rules: - PosInfoMoq1005: Defines the generic argument of the SetupSet() method with the type of the mocked property. diff --git a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs index 7183f3f..7f50353 100644 --- a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs +++ b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs @@ -488,6 +488,58 @@ public bool IsStrictBehavior(IdentifierNameSyntax localVariableExpression, Cance return methodSymbol.TypeArguments[0]; } + public RaiseMethodCall? ExtractRaiseMethodCall(InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) + { + // Check if the method analyzed is a Raise() method. + var methodSymbol = this.semanticModel.GetSymbolInfo(invocationExpression, cancellationToken); + + if (!this.moqSymbols.IsRaiseMethod(methodSymbol.Symbol) && !this.moqSymbols.IsRaiseAsyncMethod(methodSymbol.Symbol)) + { + return null; + } + + // Gets the event + // 1 - Check the first argument is a lambda expression (Raise(x => ...)) + if (invocationExpression.ArgumentList.Arguments[0].Expression is not LambdaExpressionSyntax lambdaExpressionSyntax) + { + return null; + } + + // 2 - If the body of the lambda expression is an assignment to an event (Raise(x => x.Event += null)) + if (lambdaExpressionSyntax.Body is not AssignmentExpressionSyntax assignmentExpressionSyntax) + { + return null; + } + + // 3 - Check the left of assignment is a member access. + if (assignmentExpressionSyntax.Left is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) + { + return null; + } + + // 4 - Gets the event symbol + var eventSymbol = this.semanticModel.GetSymbolInfo(memberAccessExpressionSyntax.Name, cancellationToken); + + if (eventSymbol.Symbol is null) + { + return null; + } + + // 5 - Gets the parameters of the Raise() method after the "x => x.Event += null". + var arguments = invocationExpression.ArgumentList.Arguments.Skip(1).ToArray(); + + var parameterSymbols = new List(arguments.Length); + + foreach (var argument in arguments) + { + var parameterSymbol = this.semanticModel.GetTypeInfo(argument.Expression, cancellationToken); + + parameterSymbols.Add(parameterSymbol.Type); + } + + return new RaiseMethodCall((IMethodSymbol)methodSymbol.Symbol, parameterSymbols, arguments, (IEventSymbol)eventSymbol.Symbol); + } + 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 d306d8d..b9cf458 100644 --- a/src/Moq.Analyzers/MoqSymbols.cs +++ b/src/Moq.Analyzers/MoqSymbols.cs @@ -16,6 +16,10 @@ internal sealed class MoqSymbols private readonly INamedTypeSymbol mockGenericClass; + private readonly Lazy> raiseMethods; + + private readonly Lazy> raiseAsyncMethods; + private readonly Lazy> setupMethods; private readonly Lazy> setupProtectedMethods; @@ -52,6 +56,8 @@ internal sealed class MoqSymbols private readonly Lazy timesClass; + private readonly Lazy eventArgsClass; + private readonly Lazy funcClass; private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) @@ -66,6 +72,8 @@ private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) this.itIsAnyMethod = new Lazy(() => compilation.GetTypeByMetadataName("Moq.It")!.GetMembers("IsAny").Single()); this.verifiesInterface = new Lazy(() => compilation.GetTypeByMetadataName("Moq.Language.IVerifies")!); + this.raiseMethods = new Lazy>(() => mockGenericClass.GetMembers("Raise").OfType().ToArray()); + this.raiseAsyncMethods = new Lazy>(() => mockGenericClass.GetMembers("RaiseAsync").OfType().ToArray()); this.setupMethods = new Lazy>(() => mockGenericClass.GetMembers("Setup").Concat(setupConditionResultInterface.Value.GetMembers("Setup")).OfType().ToArray()); this.mockBehaviorStrictField = new Lazy(() => this.mockBehaviorEnum.Value.GetMembers("Strict").First()); this.setupProtectedMethods = new Lazy>(() => compilation.GetTypeByMetadataName("Moq.Protected.IProtectedMock`1")!.GetMembers("Setup").OfType().ToArray()); @@ -85,6 +93,8 @@ private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) this.setupSetMethods = new Lazy>(() => mockGenericClass.GetMembers("SetupSet").OfType().ToArray()); this.timesClass = new Lazy(() => compilation.GetTypeByMetadataName("Moq.Times")!); + + this.eventArgsClass = new Lazy(() => compilation.GetTypeByMetadataName("System.EventArgs")!); this.funcClass = new Lazy(() => compilation.GetTypeByMetadataName("System.Func`1")!); } @@ -149,6 +159,9 @@ public bool IsAnyType(ITypeSymbol symbol) return null; } + public bool IsEventArgs(ISymbol? symbol) + => AreEqual(symbol, this.eventArgsClass); + public bool IsItIsAny(ISymbol? symbol) { if (symbol is null) @@ -194,6 +207,12 @@ public bool IsMock(ISymbol? symbol) return true; } + public bool IsRaiseMethod([NotNullWhen(true)] ISymbol? symbol) + => AreEqual(symbol, this.raiseMethods); + + public bool IsRaiseAsyncMethod([NotNullWhen(true)] ISymbol? symbol) + => AreEqual(symbol, this.raiseAsyncMethods); + public bool IsSetupMethod(ISymbol? symbol) { if (symbol is null) @@ -541,5 +560,42 @@ public bool IsAsMethod(IMethodSymbol method) return true; } + + private static bool AreEqual([NotNullWhen(true)] ISymbol? symbol1, Lazy symbol2) + where TSymbol : ISymbol + { + if (symbol1 is null) + { + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(symbol1.OriginalDefinition, symbol2.Value)) + { + return false; + } + + return true; + } + + private static bool AreEqual([NotNullWhen(true)] ISymbol? symbol1, Lazy> symbols2) + where TSymbol : ISymbol + { + if (symbol1 is null) + { + return false; + } + + var originalDefinition = symbol1.OriginalDefinition; + + foreach (var symbol in symbols2.Value) + { + if (SymbolEqualityComparer.Default.Equals(originalDefinition, symbol)) + { + return true; + } + } + + return false; + } } } diff --git a/src/Moq.Analyzers/RaiseMethodCall.cs b/src/Moq.Analyzers/RaiseMethodCall.cs new file mode 100644 index 0000000..24140b5 --- /dev/null +++ b/src/Moq.Analyzers/RaiseMethodCall.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers +{ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + internal sealed class RaiseMethodCall + { + public RaiseMethodCall(IMethodSymbol method, IReadOnlyList methodParameters, IReadOnlyList methodArguments, IEventSymbol @event) + { + this.Method = method; + this.Event = @event; + this.MethodArguments = methodArguments; + this.MethodParameters = methodParameters; + + this.EventParameters = ((INamedTypeSymbol)@event.Type).DelegateInvokeMethod!.Parameters; + } + + public IMethodSymbol Method { get; } + + public IReadOnlyList MethodArguments { get; } + + public IReadOnlyList MethodParameters { get; } + + public IEventSymbol Event { get; } + + public IReadOnlyList EventParameters { get; } + } +} diff --git a/tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs new file mode 100644 index 0000000..947b2a4 --- /dev/null +++ b/tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs @@ -0,0 +1,313 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers.Tests +{ + using Microsoft.CodeAnalysis.Testing; + using Verifier = MoqCSharpAnalyzerVerifier; + + public class RaiseParametersMustMatchEventSignatureAnalyzerTest + { + [Theory] + [InlineData("Raise", "void")] + [InlineData("RaiseAsync", "Task")] + public async Task Raise_WithParams_NoDiagnosticReported(string method, string eventReturnType) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1." + method + @"(m => m.TheEvent += null, ""OK"", 1, null); + mock1." + method + @"(m => m.TheEvent += null, ""OK"", 1, 10); + mock1." + method + @"(m => m.TheEvent += null, ""OK"", 1, new object()); + + mock1." + method + @"(m => m.TheEvent -= null, ""OK"", 1, null); + mock1." + method + @"(m => m.TheEvent -= null, ""OK"", 1, 10); + mock1." + method + @"(m => m.TheEvent -= null, ""OK"", 1, new object()); + } + } + + public interface I + { + event CustomEventHandler TheEvent; + } + + public delegate " + eventReturnType + @" CustomEventHandler(string a, int b, object c); + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task Raise_WithEventArgs_NoDiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1.Raise(m => m.TheEvent += null, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent += null, null, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent += null, 10, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent += null, new object(), new CustomEventArgs()); + + mock1.Raise(m => m.TheEvent -= null, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent -= null, null, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent -= null, 10, new CustomEventArgs()); + mock1.Raise(m => m.TheEvent -= null, new object(), new CustomEventArgs()); + } + } + + public interface I + { + event EventHandler TheEvent; + } + + public class CustomEventArgs : EventArgs + { + } + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Theory] + [InlineData("Raise", "void")] + [InlineData("RaiseAsync", "Task")] + public async Task Raise_WithParams_MissingArgument_DiagnosticReported(string method, string eventReturnType) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1.{|#0:" + method + @"|#0}(m => m.TheEvent += null); + mock1.{|#1:" + method + @"|#1}(m => m.TheEvent += null, 1); + mock1.{|#2:" + method + @"|#2}(m => m.TheEvent += null, 1, 2, ""3"", 4, 5, 6); + } + } + + public interface I + { + event CustomEventHandler TheEvent; + } + + public delegate " + eventReturnType + @" CustomEventHandler(string a, int b, object c); + }"; + + await Verifier.VerifyAnalyzerAsync( + source, + [ + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(0) + .WithArguments("The event 'TheEvent' expects 3 argument(s) but 0 were provided."), + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(1) + .WithArguments("The event 'TheEvent' expects 3 argument(s) but 1 were provided."), + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(2) + .WithArguments("The event 'TheEvent' expects 3 argument(s) but 6 were provided."), + ]); + } + + [Theory] + [InlineData("Raise", "void")] + [InlineData("RaiseAsync", "Task")] + public async Task Raise_WithParams_WrongType_DiagnosticReported(string method, string eventReturnType) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1." + method + @"(m => m.TheEvent += null, {|#0:1|#0}, 2, ""OK"", {|#1:""Wrong""|#1}, null, {|#2:null|#2}); + } + } + + public interface I + { + event CustomEventHandler TheEvent; + } + + public delegate " + eventReturnType + @" CustomEventHandler(string a, int b, object c, int d, string e, int f); + }"; + + await Verifier.VerifyAnalyzerAsync( + source, + [ + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(0) + .WithArguments("The parameter 'a' of the event 'TheEvent' expects a value of type 'String' but a value of type 'Int32' was provided."), + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(1) + .WithArguments("The parameter 'd' of the event 'TheEvent' expects a value of type 'Int32' but a value of type 'String' was provided."), + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(2) + .WithArguments("The parameter 'f' of the event 'TheEvent' expects a value of type 'Int32' but a value 'null' was provided."), + ]); + } + + [Fact] + public async Task Raise_WithEventArgs_WrongType_DiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1.Raise(m => m.TheEvent += null, {|#0:new EventArgs()|#0}); + } + } + + public interface I + { + event EventHandler TheEvent; + } + + public class CustomEventArgs : EventArgs + { + } + }"; + + await Verifier.VerifyAnalyzerAsync( + source, + [ + new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + .WithLocation(0) + .WithArguments("The parameter 'e' of the event 'TheEvent' expects a value of type 'CustomEventArgs' but a value of type 'EventArgs' was provided."), + ]); + } + + [Theory] + [InlineData("Raise", "void", "\"OK\", 1, null")] + [InlineData("Raise", "void", "\"OK\", 1, 10")] + [InlineData("Raise", "void", "\"OK\", 1, new object()")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, null")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, 10")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, new object()")] + public async Task Raise_WrongLambdaExpression_NoDiagnosticReported(string method, string eventReturnType, string parameters) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var act = (I i) => i.TheEvent += null; + var tmp = 0; + + var mock1 = new Mock(MockBehavior.Strict); + + mock1." + method + @"(null); + mock1." + method + @"(null, " + parameters + @"); + mock1." + method + @"(m => m.ToString(), "" + parameters + @""); + mock1." + method + @"(act, "" + parameters + @""); + mock1." + method + @"(m => new object(), "" + parameters + @""); + mock1." + method + @"(m => tmp = 10, "" + parameters + @""); + } + } + + public interface I + { + event CustomEventHandler TheEvent; + + int Property { get; set; } + } + + public delegate " + eventReturnType + @" CustomEventHandler(string a, int b, object c); + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoMoqLibrary() + { + var source = @" + namespace ConsoleApplication1 + { + using System; + using OtherNamespace; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + mock1.Raise(m => m.EventWithSenderAndString += null, new EventArgs()); + mock1.Raise(m => m.EventWithSenderAndString += null, 1, 2, 3, 4); + } + } + + public interface I + { + event EventHandler EventWithSenderAndString; + } + } + + namespace OtherNamespace + { + using System; + + public class Mock + { + public Mock(MockBehavior _) { } + + public void Raise(Action act, EventArgs e) { } + public void Raise(Action act, params object[] args) { } + } + + public enum MockBehavior { Strict, Loose } + }"; + + await Verifier.VerifyAnalyzerWithNoMoqLibraryAsync(source); + } + } +} \ No newline at end of file From af5cf608e69bf8b0eed34fa2c52dc94ec38eab32 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 3 Oct 2025 16:30:50 +0200 Subject: [PATCH 02/14] Add the PosInfoMoq2018 to check the first arguments of the Raise() method (fixes #51). --- PosInformatique.Moq.Analyzers.sln | 1 + README.md | 3 +- docs/Compilation/PosInfoMoq2018.md | 83 +++++++++++++++++++ src/Moq.Analyzers/AnalyzerReleases.Shipped.md | 3 +- ...ureAnalyzer.cs => RaiseMethodsAnalyzer.cs} | 34 ++++++-- src/Moq.Analyzers/Moq.Analyzers.csproj | 1 + src/Moq.Analyzers/MoqExpressionAnalyzer.cs | 19 +++-- ...zerTest.cs => RaiseMethodsAnalyzerTest.cs} | 35 ++++---- 8 files changed, 146 insertions(+), 33 deletions(-) create mode 100644 docs/Compilation/PosInfoMoq2018.md rename src/Moq.Analyzers/Analyzers/{RaiseParametersMustMatchEventSignatureAnalyzer.cs => RaiseMethodsAnalyzer.cs} (64%) rename tests/Moq.Analyzers.Tests/Analyzers/{RaiseParametersMustMatchEventSignatureAnalyzerTest.cs => RaiseMethodsAnalyzerTest.cs} (86%) diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index b0932c3..c09b02a 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -60,6 +60,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation" docs\Compilation\PosInfoMoq2015.md = docs\Compilation\PosInfoMoq2015.md docs\Compilation\PosInfoMoq2016.md = docs\Compilation\PosInfoMoq2016.md docs\Compilation\PosInfoMoq2017.md = docs\Compilation\PosInfoMoq2017.md + docs\Compilation\PosInfoMoq2018.md = docs\Compilation\PosInfoMoq2018.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 3523381..74432a0 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,5 @@ All the rules of this category should not be disabled (or changed their severity | [PosInfoMoq2015: The `Protected().Setup()` method must match the return type of the mocked method](docs/Compilation/PosInfoMoq2015.md) | The method setup with `Protected().Setup()` must match the return type of the mocked method. | | [PosInfoMoq2016: `Mock` constructor with factory lambda expression can be used only with classes.](docs/Compilation/PosInfoMoq2016.md) | The factory lambda expression used in `Mock` instantiation must used only for the classes. | | [PosInfoMoq2017: `Mock.Raise()`/`RaiseAsync()` must use parameters matching the event signature.](docs/Compilation/PosInfoMoq2017.md) | The parameters passed to `Raise()` or `RaiseAsync()` must exactly match the parameters of the corresponding event delegate. | - - +| [PosInfoMoq2018: The first parameter of `Raise()`/`RaiseAsync()` must be an event.](docs/Compilation/PosInfoMoq2018.md) | The first parameter passed to `Raise()` or `RaiseAsync()` must reference an **event** member of the mocked type. | diff --git a/docs/Compilation/PosInfoMoq2018.md b/docs/Compilation/PosInfoMoq2018.md new file mode 100644 index 0000000..9b903b7 --- /dev/null +++ b/docs/Compilation/PosInfoMoq2018.md @@ -0,0 +1,83 @@ +# PosInfoMoq2018: The first parameter of `Raise()`/`RaiseAsync()` must be an event + +| Property | Value | +|-----------------------|----------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2018 | +| **Title** | The first parameter of `Raise()`/`RaiseAsync()` must be an event | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +The first parameter passed to `Raise()` or `RaiseAsync()` must reference an **event** member of the mocked type. +Using any other kind of member (property, method, field, etc.) is invalid. + +## Rule description + +When using Moq, `Mock.Raise()` and `Mock.RaiseAsync()` are designed to trigger **events** defined in the mocked type. +If the provided lambda expression instead references something that is not an event (like a property assignment), the code is invalid. + +### Example + +```csharp +public class Service +{ + public event EventHandler Changed; + public event EventHandler DataChanged; + public int Property { get; set; } +} + +public class DataEventArgs : EventArgs +{ + public int Value { get; } + public DataEventArgs(int value) => Value = value; +} +``` + +#### Correct usage (event targeted) + +```csharp +var serviceMock = new Mock(); + +// Raise() with EventHandler +serviceMock.Raise(s => s.Changed += null, EventArgs.Empty); + +// Raise() with EventHandler +serviceMock.Raise(s => s.DataChanged += null, new DataEventArgs(42)); + +// RaiseAsync() with EventHandler +await serviceMock.RaiseAsync(s => s.DataChanged += null, new DataEventArgs(42)); +``` + +#### Incorrect usage (invalid target) + +```csharp +var serviceMock = new Mock(); + +// Refers to a property, not an event +serviceMock.Raise(s => s.Property = 10, EventArgs.Empty); // ❌ + +// Same problem with RaiseAsync +await serviceMock.RaiseAsync(s => s.Property = 10, EventArgs.Empty); // ❌ +``` + +## How to fix violations + +Ensure that the first parameter of `Raise()` and `RaiseAsync()` always references an **event** of the mocked type, never a property or field. + +Correct: + +```csharp +serviceMock.Raise(s => s.Changed += null, EventArgs.Empty); +``` + +Incorrect: + +```csharp +serviceMock.Raise(s => s.Property = 10, EventArgs.Empty); +``` + +## When to suppress warnings + +Do not suppress this warning. If bypassed, Moq itself will throw an `ArgumentException` exception at runtime with a message such as +`Unsupported expression: m => (m.Property = 10)`. \ No newline at end of file diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index 20ba7a2..79cd873 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -3,7 +3,8 @@ ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- -PosInfoMoq2017 | Compilation | Error | RaiseParametersMustMatchEventSignatureAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html) +PosInfoMoq2017 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html) +PosInfoMoq2018 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html) ## Release 1.13.0 diff --git a/src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs similarity index 64% rename from src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs rename to src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs index f19da40..45d2a25 100644 --- a/src/Moq.Analyzers/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzer.cs +++ b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- @@ -13,9 +13,9 @@ namespace PosInformatique.Moq.Analyzers using Microsoft.CodeAnalysis.Diagnostics; [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class RaiseParametersMustMatchEventSignatureAnalyzer : DiagnosticAnalyzer + public class RaiseMethodsAnalyzer : DiagnosticAnalyzer { - internal static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + internal static readonly DiagnosticDescriptor ParametersMustMatchSignature = new DiagnosticDescriptor( "PosInfoMoq2017", "The Raise() parameters must match the signature of the mocked event", "The Raise() parameters must match the signature of the mocked event. {0}.", @@ -25,7 +25,19 @@ public class RaiseParametersMustMatchEventSignatureAnalyzer : DiagnosticAnalyzer description: "The Raise() parameters must match the signature of the mocked event.", helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html"); - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + internal static readonly DiagnosticDescriptor FirstParameterMustBeEvent = new DiagnosticDescriptor( + "PosInfoMoq2018", + "The first parameter of Raise()/RaiseAsync() must be an event", + "The first parameter of Raise()/RaiseAsync() must be an event", + "Compilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The first parameter of Raise()/RaiseAsync() must be an event.", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html"); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + ParametersMustMatchSignature, + FirstParameterMustBeEvent); public override void Initialize(AnalysisContext context) { @@ -49,10 +61,16 @@ private static void Analyze(SyntaxNodeAnalysisContext context) var moqExpressionAnalyzer = new MoqExpressionAnalyzer(moqSymbols, context.SemanticModel); - var raiseMethod = moqExpressionAnalyzer.ExtractRaiseMethodCall(invocationExpression, context.CancellationToken); + var raiseMethod = moqExpressionAnalyzer.ExtractRaiseMethodCall(invocationExpression, out var invalidEventExpression, context.CancellationToken); if (raiseMethod is null) { + if (invalidEventExpression is not null) + { + // Raise the rule that the first argument of Raise()/RaiseAsync() method must be an event. + context.ReportDiagnostic(FirstParameterMustBeEvent, invalidEventExpression.GetLocation()); + } + return; } @@ -78,7 +96,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) return; } - context.ReportDiagnostic(Rule, raiseMethodSyntax.GetLocation(), $"The event '{raiseMethod.Event.Name}' expects {raiseMethod.EventParameters.Count} argument(s) but {raiseMethod.MethodParameters.Count} were provided."); + context.ReportDiagnostic(ParametersMustMatchSignature, raiseMethodSyntax.GetLocation(), $"The event '{raiseMethod.Event.Name}' expects {raiseMethod.EventParameters.Count} argument(s) but {raiseMethod.MethodParameters.Count} were provided."); return; } @@ -93,7 +111,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) // Check the event parameter is nullable. if (!eventParameter.Type.IsReferenceType) { - context.ReportDiagnostic(Rule, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value 'null' was provided."); + context.ReportDiagnostic(ParametersMustMatchSignature, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value 'null' was provided."); } continue; @@ -101,7 +119,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) if (!methodParameter.IsOrInheritFrom(eventParameter.Type)) { - context.ReportDiagnostic(Rule, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value of type '{methodParameter.Name}' was provided."); + context.ReportDiagnostic(ParametersMustMatchSignature, raiseMethod.MethodArguments[i].GetLocation(), $"The parameter '{eventParameter.Name}' of the event '{raiseMethod.Event.Name}' expects a value of type '{eventParameter.Type.Name}' but a value of type '{methodParameter.Name}' was provided."); continue; } } diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index 63fb1cc..dcf438e 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -20,6 +20,7 @@ 2.0.0 - Add new rules: - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. + - PosInfoMoq2018: The first parameter of `Raise()`/`RaiseAsync()` must be an event. 1.13.0 - Add new rules: diff --git a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs index 7f50353..0d6ca12 100644 --- a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs +++ b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs @@ -488,40 +488,47 @@ public bool IsStrictBehavior(IdentifierNameSyntax localVariableExpression, Cance return methodSymbol.TypeArguments[0]; } - public RaiseMethodCall? ExtractRaiseMethodCall(InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) + public RaiseMethodCall? ExtractRaiseMethodCall(InvocationExpressionSyntax invocationExpression, out ExpressionSyntax? invalidEventExpression, CancellationToken cancellationToken) { // Check if the method analyzed is a Raise() method. var methodSymbol = this.semanticModel.GetSymbolInfo(invocationExpression, cancellationToken); if (!this.moqSymbols.IsRaiseMethod(methodSymbol.Symbol) && !this.moqSymbols.IsRaiseAsyncMethod(methodSymbol.Symbol)) { + invalidEventExpression = null; return null; } // Gets the event // 1 - Check the first argument is a lambda expression (Raise(x => ...)) - if (invocationExpression.ArgumentList.Arguments[0].Expression is not LambdaExpressionSyntax lambdaExpressionSyntax) + var eventExpression = invocationExpression.ArgumentList.Arguments[0].Expression; + + if (eventExpression is not LambdaExpressionSyntax lambdaExpressionSyntax) { + invalidEventExpression = eventExpression; return null; } // 2 - If the body of the lambda expression is an assignment to an event (Raise(x => x.Event += null)) if (lambdaExpressionSyntax.Body is not AssignmentExpressionSyntax assignmentExpressionSyntax) { + invalidEventExpression = eventExpression; return null; } // 3 - Check the left of assignment is a member access. if (assignmentExpressionSyntax.Left is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) { + invalidEventExpression = eventExpression; return null; } // 4 - Gets the event symbol - var eventSymbol = this.semanticModel.GetSymbolInfo(memberAccessExpressionSyntax.Name, cancellationToken); + var memberSymbol = this.semanticModel.GetSymbolInfo(memberAccessExpressionSyntax.Name, cancellationToken); - if (eventSymbol.Symbol is null) + if (memberSymbol.Symbol is not IEventSymbol eventSymbol) { + invalidEventExpression = eventExpression; return null; } @@ -537,7 +544,9 @@ public bool IsStrictBehavior(IdentifierNameSyntax localVariableExpression, Cance parameterSymbols.Add(parameterSymbol.Type); } - return new RaiseMethodCall((IMethodSymbol)methodSymbol.Symbol, parameterSymbols, arguments, (IEventSymbol)eventSymbol.Symbol); + invalidEventExpression = null; + + return new RaiseMethodCall((IMethodSymbol)methodSymbol.Symbol, parameterSymbols, arguments, eventSymbol); } private static ObjectCreationExpressionSyntax? FindMockCreation(BlockSyntax block, string variableName) diff --git a/tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs similarity index 86% rename from tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs rename to tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs index 947b2a4..29e757a 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/RaiseParametersMustMatchEventSignatureAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- @@ -7,9 +7,9 @@ namespace PosInformatique.Moq.Analyzers.Tests { using Microsoft.CodeAnalysis.Testing; - using Verifier = MoqCSharpAnalyzerVerifier; + using Verifier = MoqCSharpAnalyzerVerifier; - public class RaiseParametersMustMatchEventSignatureAnalyzerTest + public class RaiseMethodsAnalyzerTest { [Theory] [InlineData("Raise", "void")] @@ -125,13 +125,13 @@ public interface I await Verifier.VerifyAnalyzerAsync( source, [ - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(0) .WithArguments("The event 'TheEvent' expects 3 argument(s) but 0 were provided."), - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(1) .WithArguments("The event 'TheEvent' expects 3 argument(s) but 1 were provided."), - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(2) .WithArguments("The event 'TheEvent' expects 3 argument(s) but 6 were provided."), ]); @@ -170,13 +170,13 @@ public interface I await Verifier.VerifyAnalyzerAsync( source, [ - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(0) .WithArguments("The parameter 'a' of the event 'TheEvent' expects a value of type 'String' but a value of type 'Int32' was provided."), - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(1) .WithArguments("The parameter 'd' of the event 'TheEvent' expects a value of type 'Int32' but a value of type 'String' was provided."), - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(2) .WithArguments("The parameter 'f' of the event 'TheEvent' expects a value of type 'Int32' but a value 'null' was provided."), ]); @@ -214,7 +214,7 @@ public class CustomEventArgs : EventArgs await Verifier.VerifyAnalyzerAsync( source, [ - new DiagnosticResult(RaiseParametersMustMatchEventSignatureAnalyzer.Rule) + new DiagnosticResult(RaiseMethodsAnalyzer.ParametersMustMatchSignature) .WithLocation(0) .WithArguments("The parameter 'e' of the event 'TheEvent' expects a value of type 'CustomEventArgs' but a value of type 'EventArgs' was provided."), ]); @@ -227,7 +227,7 @@ await Verifier.VerifyAnalyzerAsync( [InlineData("RaiseAsync", "Task", "\"OK\", 1, null")] [InlineData("RaiseAsync", "Task", "\"OK\", 1, 10")] [InlineData("RaiseAsync", "Task", "\"OK\", 1, new object()")] - public async Task Raise_WrongLambdaExpression_NoDiagnosticReported(string method, string eventReturnType, string parameters) + public async Task Raise_WrongLambdaExpression_DiagnosticReported(string method, string eventReturnType, string parameters) { var source = @" namespace ConsoleApplication1 @@ -245,12 +245,13 @@ public void TestMethod() var mock1 = new Mock(MockBehavior.Strict); - mock1." + method + @"(null); - mock1." + method + @"(null, " + parameters + @"); - mock1." + method + @"(m => m.ToString(), "" + parameters + @""); - mock1." + method + @"(act, "" + parameters + @""); - mock1." + method + @"(m => new object(), "" + parameters + @""); - mock1." + method + @"(m => tmp = 10, "" + parameters + @""); + mock1." + method + @"({|PosInfoMoq2018:null|}); + mock1." + method + @"({|PosInfoMoq2018:null|}, " + parameters + @"); + mock1." + method + @"({|PosInfoMoq2018:m => m.ToString()|}, "" + parameters + @""); + mock1." + method + @"({|PosInfoMoq2018:act|}, "" + parameters + @""); + mock1." + method + @"({|PosInfoMoq2018:m => new object()|}, "" + parameters + @""); + mock1." + method + @"({|PosInfoMoq2018:m => tmp = 10|}, "" + parameters + @""); + mock1." + method + @"({|PosInfoMoq2018:m => m.Property = 10|}, "" + parameters + @""); } } From f4e4dea51f0a42f95c948ccc7d6feb4808ebf45c Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 3 Oct 2025 16:43:40 +0200 Subject: [PATCH 03/14] Factorization some code in MoqSymbols. --- src/Moq.Analyzers/MoqSymbols.cs | 244 +++----------------------------- 1 file changed, 17 insertions(+), 227 deletions(-) diff --git a/src/Moq.Analyzers/MoqSymbols.cs b/src/Moq.Analyzers/MoqSymbols.cs index b9cf458..994188b 100644 --- a/src/Moq.Analyzers/MoqSymbols.cs +++ b/src/Moq.Analyzers/MoqSymbols.cs @@ -132,14 +132,7 @@ public bool ContainsTimesParameters(IMethodSymbol method) } public bool IsAnyType(ITypeSymbol symbol) - { - if (!SymbolEqualityComparer.Default.Equals(symbol, this.isAnyTypeClass.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.isAnyTypeClass); public ITypeSymbol? GetItIsType(ISymbol? symbol) { @@ -163,19 +156,7 @@ public bool IsEventArgs(ISymbol? symbol) => AreEqual(symbol, this.eventArgsClass); public bool IsItIsAny(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, this.itIsAnyMethod.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.itIsAnyMethod); public ITypeSymbol? GetItIsAnyType(ISymbol? symbol) { @@ -214,164 +195,31 @@ public bool IsRaiseAsyncMethod([NotNullWhen(true)] ISymbol? symbol) => AreEqual(symbol, this.raiseAsyncMethods); public bool IsSetupMethod(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - var originalDefinition = symbol.OriginalDefinition; - - foreach (var setupMethod in this.setupMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(originalDefinition, setupMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.setupMethods); public bool IsSetupProtectedMethod(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - var originalDefinition = symbol.OriginalDefinition; - - foreach (var setupProtectedMethod in this.setupProtectedMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(originalDefinition, setupProtectedMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.setupProtectedMethods); public bool IsSetupSetMethod(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - var originalDefinition = symbol.OriginalDefinition; - - foreach (var setupSetMethod in this.setupSetMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(originalDefinition, setupSetMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.setupSetMethods); public bool IsSetupSetMethodWithoutGenericArgument(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, this.setupSetMethodWithoutGenericArgument.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.setupSetMethodWithoutGenericArgument); public bool IsVerifiableMethod([NotNullWhen(true)] ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - var originalDefinition = symbol.OriginalDefinition; - - foreach (var verifiableMethod in this.verifiableMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(originalDefinition, verifiableMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.verifiableMethods); public bool IsVerifyMethod([NotNullWhen(true)] ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - var originalDefinition = symbol.OriginalDefinition; - - foreach (var verifyMethod in this.verifyMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(originalDefinition, verifyMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.verifyMethods); public bool IsVerifyStaticMethod([NotNullWhen(true)] ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol, this.staticVerifyMethod.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.staticVerifyMethod); public bool IsVerifyAllMethod(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, this.verifyAllMethod.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.verifyAllMethod); public bool IsVerifyAllStaticMethod([NotNullWhen(true)] ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol, this.staticVerifyAllMethod.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.staticVerifyAllMethod); public bool IsCallback(ISymbol? symbol) { @@ -444,34 +292,10 @@ public bool IsThrowsAsyncMethod(ISymbol symbol) } public bool IsMockBehaviorEnum(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol, this.mockBehaviorEnum.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.mockBehaviorEnum); public bool IsMockBehaviorStrictField(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(symbol, this.mockBehaviorStrictField.Value)) - { - return false; - } - - return true; - } + => AreEqual(symbol, this.mockBehaviorStrictField); public bool IsOverridable(ISymbol method) { @@ -519,47 +343,13 @@ public bool IsMockable(ITypeSymbol type) } public bool IsMockOfMethod(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } - - foreach (var mockOfMethod in this.mockOfMethods.Value) - { - if (SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, mockOfMethod)) - { - return true; - } - } - - return false; - } + => AreEqual(symbol, this.mockOfMethods); public bool IsMockConstructorWithFactory(ISymbol? symbol) - { - if (symbol is null) - { - return false; - } + => AreEqual(symbol, this.mockConstructorWithFactory); - if (!SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, this.mockConstructorWithFactory.Value)) - { - return false; - } - - return true; - } - - public bool IsAsMethod(IMethodSymbol method) - { - if (!SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, this.asMethod.Value)) - { - return false; - } - - return true; - } + public bool IsAsMethod(IMethodSymbol symbol) + => AreEqual(symbol, this.asMethod); private static bool AreEqual([NotNullWhen(true)] ISymbol? symbol1, Lazy symbol2) where TSymbol : ISymbol From d7ec2d5ab299f515d8c57b353341365ea44983d7 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 3 Oct 2025 17:06:22 +0200 Subject: [PATCH 04/14] Remove the usage of WithSpan() in the unit tests. --- ...legateMustMatchMockedMethodAnalyzerTest.cs | 4 ++- ...arametersShouldNotBeIgnoredAnalyzerTest.cs | 12 +++++--- ...BeUsedWithItIsAnyParametersAnalyzerTest.cs | 30 ++++++++++++------- ...tMatchMockedMethodArgumentsAnalyzerTest.cs | 6 ++-- ...legateMustMatchMockedMethodAnalyzerTest.cs | 10 ++++--- ...turnValueWithStrictBehaviorAnalyzerTest.cs | 8 ++--- ...erifyMustHaveTimesParameterAnalyzerTest.cs | 12 +++++--- ...thodsRequiresMockParametersAnalyzerTest.cs | 4 +-- 8 files changed, 54 insertions(+), 32 deletions(-) diff --git a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateMustMatchMockedMethodAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateMustMatchMockedMethodAnalyzerTest.cs index 7316795..b649dd2 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateMustMatchMockedMethodAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateMustMatchMockedMethodAnalyzerTest.cs @@ -182,7 +182,9 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(CallBackDelegateMustMatchMockedMethodAnalyzer.CallbackMustNotReturnValue) - .WithSpan(59, 57, 59, 70).WithSpan(62, 41, 62, 54).WithSpan(65, 37, 65, 49), + .WithLocation(0) + .WithLocation(1) + .WithLocation(2), ]); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateParametersShouldNotBeIgnoredAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateParametersShouldNotBeIgnoredAnalyzerTest.cs index de6f880..b3de61d 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateParametersShouldNotBeIgnoredAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateParametersShouldNotBeIgnoredAnalyzerTest.cs @@ -46,13 +46,17 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(CallBackDelegateParametersShouldNotBeIgnoredAnalyzer.Rule) - .WithSpan(13, 44, 13, 52).WithArguments("a"), + .WithLocation(0) + .WithArguments("a"), new DiagnosticResult(CallBackDelegateParametersShouldNotBeIgnoredAnalyzer.Rule) - .WithSpan(13, 54, 13, 59).WithArguments("b"), + .WithLocation(1) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateParametersShouldNotBeIgnoredAnalyzer.Rule) - .WithSpan(15, 54, 15, 59).WithArguments("b"), + .WithLocation(2) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateParametersShouldNotBeIgnoredAnalyzer.Rule) - .WithSpan(17, 44, 17, 52).WithArguments("a"), + .WithLocation(3) + .WithArguments("a"), ]); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzerTest.cs index 73806a0..8cb90a9 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzerTest.cs @@ -44,15 +44,20 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(12, 59, 12, 77).WithArguments("a"), + .WithLocation(0) + .WithArguments("a"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(12, 79, 12, 94).WithArguments("b"), + .WithLocation(1) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(13, 70, 13, 85).WithArguments("b"), + .WithLocation(2) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(14, 59, 14, 77).WithArguments("a"), + .WithLocation(3) + .WithArguments("a"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(15, 59, 15, 77).WithArguments("a"), + .WithLocation(4) + .WithArguments("a"), ]); } @@ -91,15 +96,20 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(14, 80, 14, 98).WithArguments("a"), + .WithLocation(0) + .WithArguments("a"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(14, 100, 14, 115).WithArguments("b"), + .WithLocation(1) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(15, 91, 15, 106).WithArguments("b"), + .WithLocation(2) + .WithArguments("b"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(16, 80, 16, 98).WithArguments("a"), + .WithLocation(3) + .WithArguments("a"), new DiagnosticResult(CallBackDelegateShouldBeUsedWithItIsAnyParametersAnalyzer.Rule) - .WithSpan(17, 80, 17, 98).WithArguments("a"), + .WithLocation(4) + .WithArguments("a"), ]); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/ItArgumentsMustMatchMockedMethodArgumentsAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/ItArgumentsMustMatchMockedMethodArgumentsAnalyzerTest.cs index 84cc9b6..411bd40 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/ItArgumentsMustMatchMockedMethodArgumentsAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/ItArgumentsMustMatchMockedMethodArgumentsAnalyzerTest.cs @@ -98,11 +98,11 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(ItArgumentsMustMatchMockedMethodArgumentsAnalyzer.Rule) - .WithSpan(13, 59, 13, 74), + .WithLocation(0), new DiagnosticResult(ItArgumentsMustMatchMockedMethodArgumentsAnalyzer.Rule) - .WithSpan(14, 59, 14, 83), + .WithLocation(1), new DiagnosticResult(ItArgumentsMustMatchMockedMethodArgumentsAnalyzer.Rule) - .WithSpan(15, 59, 15, 104), + .WithLocation(2), ]); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs index 91607c2..242f738 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/ReturnsMethodDelegateMustMatchMockedMethodAnalyzerTest.cs @@ -95,7 +95,7 @@ public void TestMethod() var mock1 = new Mock(); mock1.Setup(i => i.TestMethod()) .Callback(() => { }) - .Returns(() => { return ""Foobar""; }); + .Returns({|#0:() => { return ""Foobar""; }|#0}); } } @@ -108,7 +108,8 @@ public interface I await Verifier.VerifyAnalyzerAsync( source, new DiagnosticResult(ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.ReturnValueMustMatchRule) - .WithSpan(13, 42, 13, 68).WithArguments("Int32")); + .WithLocation(0) + .WithArguments("Int32")); } [Fact] @@ -126,7 +127,7 @@ public void TestMethod() var mock1 = new Mock(); mock1.Setup(i => i.TestProperty) .Callback(() => { }) - .Returns(() => { return ""Foobar""; }); + .Returns({|#0:() => { return ""Foobar""; }|#0}); } } @@ -139,7 +140,8 @@ public interface I await Verifier.VerifyAnalyzerAsync( source, new DiagnosticResult(ReturnsMethodDelegateMustMatchMockedMethodAnalyzer.ReturnValueMustMatchRule) - .WithSpan(13, 42, 13, 68).WithArguments("Int32")); + .WithLocation(0) + .WithArguments("Int32")); } [Theory] diff --git a/tests/Moq.Analyzers.Tests/Analyzers/SetupMethodMustReturnValueWithStrictBehaviorAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/SetupMethodMustReturnValueWithStrictBehaviorAnalyzerTest.cs index e6fdc66..3c7ef17 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/SetupMethodMustReturnValueWithStrictBehaviorAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/SetupMethodMustReturnValueWithStrictBehaviorAnalyzerTest.cs @@ -101,10 +101,10 @@ public void TestMethod() var mock1 = new Mock(MockBehavior.Strict); mock1.Setup(i => i.TestMethod()) .Callback(() => { }) - .Returns(""Foobar""); + .Returns({|#0:""Foobar""|#0}); mock1.Setup(i => i.TestProperty) .Callback(() => { }) - .Returns(""Foobar""); + .Returns({|#1:""Foobar""|#1}); } } @@ -118,8 +118,8 @@ public interface I await Verifier.VerifyAnalyzerAsync( source, - DiagnosticResult.CompilerError("CS1503").WithSpan(13, 42, 13, 50).WithArguments("1", "string", "int"), - DiagnosticResult.CompilerError("CS1503").WithSpan(16, 42, 16, 50).WithArguments("1", "string", "int")); + DiagnosticResult.CompilerError("CS1503").WithLocation(0).WithArguments("1", "string", "int"), + DiagnosticResult.CompilerError("CS1503").WithLocation(1).WithArguments("1", "string", "int")); } [Fact] diff --git a/tests/Moq.Analyzers.Tests/Analyzers/VerifyMustHaveTimesParameterAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/VerifyMustHaveTimesParameterAnalyzerTest.cs index 545ff6f..c924f96 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/VerifyMustHaveTimesParameterAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/VerifyMustHaveTimesParameterAnalyzerTest.cs @@ -123,9 +123,11 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(VerifyMustHaveTimesParameterAnalyzer.Rule) - .WithSpan(13, 34, 13, 40).WithArguments("Verify"), + .WithLocation(0) + .WithArguments("Verify"), new DiagnosticResult(VerifyMustHaveTimesParameterAnalyzer.Rule) - .WithSpan(14, 34, 14, 40).WithArguments("Verify"), + .WithLocation(1) + .WithArguments("Verify"), ]); } @@ -159,9 +161,11 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(VerifyMustHaveTimesParameterAnalyzer.Rule) - .WithSpan(13, 61, 13, 71).WithArguments("Verifiable"), + .WithLocation(0) + .WithArguments("Verifiable"), new DiagnosticResult(VerifyMustHaveTimesParameterAnalyzer.Rule) - .WithSpan(14, 61, 14, 71).WithArguments("Verifiable"), + .WithLocation(1) + .WithArguments("Verifiable"), ]); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/VerifyStaticMethodsRequiresMockParametersAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/VerifyStaticMethodsRequiresMockParametersAnalyzerTest.cs index cbb99da..db15b17 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/VerifyStaticMethodsRequiresMockParametersAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/VerifyStaticMethodsRequiresMockParametersAnalyzerTest.cs @@ -71,10 +71,10 @@ await Verifier.VerifyAnalyzerAsync( source, [ new DiagnosticResult(VerifyStaticMethodsRequiresMockParametersAnalyzer.Rule) - .WithSpan(11, 34, 11, 42) + .WithLocation(0) .WithArguments("Verify"), new DiagnosticResult(VerifyStaticMethodsRequiresMockParametersAnalyzer.Rule) - .WithSpan(12, 34, 12, 45) + .WithLocation(1) .WithArguments("VerifyAll"), ]); } From f69e090c36d085a583e562a05c653887ef2cddef Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Fri, 3 Oct 2025 17:55:26 +0200 Subject: [PATCH 05/14] Add the PosInfoMoq1010 rule to check there is "+= null" when set the event to raise (fixes #52). --- PosInformatique.Moq.Analyzers.sln | 1 + README.md | 3 +- docs/Design/PosInfoMoq1010.md | 77 +++++++++++++++++++ src/Moq.Analyzers/AnalyzerReleases.Shipped.md | 1 + .../Analyzers/RaiseMethodsAnalyzer.cs | 19 ++++- src/Moq.Analyzers/Moq.Analyzers.csproj | 3 +- src/Moq.Analyzers/MoqExpressionAnalyzer.cs | 18 ++++- .../Analyzers/RaiseMethodsAnalyzerTest.cs | 41 ++++++++++ 8 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 docs/Design/PosInfoMoq1010.md diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index c09b02a..4249d3b 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -38,6 +38,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Design", "Design", "{815BE8 docs\Design\PosInfoMoq1007.md = docs\Design\PosInfoMoq1007.md docs\Design\PosInfoMoq1008.md = docs\Design\PosInfoMoq1008.md docs\Design\PosInfoMoq1009.md = docs\Design\PosInfoMoq1009.md + docs\Design\PosInfoMoq1010.md = docs\Design\PosInfoMoq1010.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation", "{D9C84D36-7F9C-4EFB-BE6F-9F7A05FE957D}" diff --git a/README.md b/README.md index 74432a0..45b8d9e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ Design rules used to make your unit tests more strongly strict. | [PosInfoMoq1006: The `It.IsAny()` or `It.Is()` arguments must match the parameters of the mocked method.](docs/Design/PosInfoMoq1006.md) | When setting up a method using `It.IsAny()` or `It.Is()` as arguments, the type `T` must exactly match the parameters of the configured method. | | [PosInfoMoq1007: The `Verify()` method must specify the `Times` argument.](docs/Design/PosInfoMoq1007.md) | When calling the `Verify()` method, if the `Times` argument is not specified, Moq will assume `Times.AtLeastOnce()` by default. | | [PosInfoMoq1008: The `Mock.Verify()` and `Mock.VerifyAll()` methods must specify at least one mock.](docs/Design/PosInfoMoq1008.md) | When calling the static methods `Mock.Verify()` or `Mock.VerifyAll()` without providing any `Mock` instances, no verification is performed. | -| [PosInfoMoq1009: Avoid using `Verifiable()` method](docs/Design/PosInfoMoq1008.md) | A `Verify()` of an `Mock` instance has not been called in the *Assert* phase of an unit test for `Verifiable()` setups. | +| [PosInfoMoq1009: Avoid using `Verifiable()` method](docs/Design/PosInfoMoq1009.md) | A `Verify()` of an `Mock` instance has not been called in the *Assert* phase of an unit test for `Verifiable()` setups. | +| [PosInfoMoq1010: Use `+= null` syntax when raising events with `Raise()`/`RaiseAsync()`](docs/Design/PosInfoMoq1010.md) | When using `Mock.Raise()` or `Mock.RaiseAsync()`, the lambda expression used to identify the event should consistently use the `+= null` syntax. | ### Compilation diff --git a/docs/Design/PosInfoMoq1010.md b/docs/Design/PosInfoMoq1010.md new file mode 100644 index 0000000..d0cbcd4 --- /dev/null +++ b/docs/Design/PosInfoMoq1010.md @@ -0,0 +1,77 @@ +# PosInfoMoq1010: Use `+= null` syntax when raising events with `Raise()`/`RaiseAsync()` + +| Property | Value | +|-----------------------|----------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq1010 | +| **Title** | Use `+= null` syntax when raising events with `Raise()`/`RaiseAsync()` | +| **Category** | Design | +| **Default severity** | Warning | + +## Cause + +When using `Mock.Raise()` or `Mock.RaiseAsync()`, the lambda expression used to identify the event should consistently use the `+= null` syntax. + +## Rule description + +While Moq is flexible and can identify the event even with `+= SomeMethod`, using `+= null` is the conventional and clearest way to indicate that you are merely referencing the event for Moq's internal use, not actually subscribing a handler. This improves code readability and intent. + +### Example + +```csharp +public class Service +{ + public event EventHandler Changed; + + public void DoSomething() + { + this.Changed?.Invoke(this, EventArgs.Empty); + } +} +``` + +#### Correct usage (`+= null`) + +```csharp +var serviceMock = new Mock(); + +// Clear intent: referencing the event for Moq +serviceMock.Raise(s => s.Changed += null, EventArgs.Empty); +``` + +#### Incorrect usage (`+= SomeMethod`) + +```csharp +public class MyTests +{ + private void MyEventHandler(object sender, EventArgs e) { /* ... */ } + + [Fact] + public void Test() + { + var serviceMock = new Mock(); + + // While functional, less clear intent + serviceMock.Raise(s => s.Changed += MyEventHandler, EventArgs.Empty); // ⚠️ + } +} +``` + +## How to fix violations + +Change the lambda expression to use `+= null` when identifying the event for `Raise()` or `RaiseAsync()`. + +Correct: + +```csharp +serviceMock.Raise(s => s.Changed += null, EventArgs.Empty); +``` + +Incorrect: + +```csharp +serviceMock.Raise(s => s.Changed += MyEventHandler, EventArgs.Empty); +``` + +## When to suppress warnings + +This is a design guideline for code readability. While suppressing it won't cause runtime errors, it's generally recommended to follow for consistent and clear code. diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index 79cd873..ba090c8 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -3,6 +3,7 @@ ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- +PosInfoMoq1010 | Design | Warning | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Design/PosInfoMoq1010.html) PosInfoMoq2017 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html) PosInfoMoq2018 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html) diff --git a/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs index 45d2a25..ac04d27 100644 --- a/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs +++ b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs @@ -35,9 +35,20 @@ public class RaiseMethodsAnalyzer : DiagnosticAnalyzer description: "The first parameter of Raise()/RaiseAsync() must be an event.", helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html"); + internal static readonly DiagnosticDescriptor EventExpressionMustBeEventAddWithNull = new DiagnosticDescriptor( + "PosInfoMoq1010", + "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", + "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Use '+= null' syntax when raising events with Raise()/RaiseAsync().", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Design/PosInfoMoq1010.html"); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( ParametersMustMatchSignature, - FirstParameterMustBeEvent); + FirstParameterMustBeEvent, + EventExpressionMustBeEventAddWithNull); public override void Initialize(AnalysisContext context) { @@ -74,6 +85,12 @@ private static void Analyze(SyntaxNodeAnalysisContext context) return; } + if (invalidEventExpression is not null) + { + // Raise a warning to indicate that the expression after the "+=" is not "null". + context.ReportDiagnostic(EventExpressionMustBeEventAddWithNull, invalidEventExpression.GetLocation()); + } + var eventParameters = raiseMethod.EventParameters.ToList(); // Check the overload of the Raise() method called. diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index dcf438e..38f1957 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -19,8 +19,9 @@ 2.0.0 - Add new rules: + - PosInfoMoq1010: Use '+= null' syntax when raising events with Raise()/RaiseAsync() - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. - - PosInfoMoq2018: The first parameter of `Raise()`/`RaiseAsync()` must be an event. + - PosInfoMoq2018: The first parameter of Raise()/RaiseAsync() must be an event. 1.13.0 - Add new rules: diff --git a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs index 0d6ca12..3ce9631 100644 --- a/src/Moq.Analyzers/MoqExpressionAnalyzer.cs +++ b/src/Moq.Analyzers/MoqExpressionAnalyzer.cs @@ -7,6 +7,7 @@ namespace PosInformatique.Moq.Analyzers { using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; internal class MoqExpressionAnalyzer @@ -544,7 +545,22 @@ public bool IsStrictBehavior(IdentifierNameSyntax localVariableExpression, Cance parameterSymbols.Add(parameterSymbol.Type); } - invalidEventExpression = null; + // Check the right assignement expression is "null". + if (assignmentExpressionSyntax.Right is not LiteralExpressionSyntax literalExpressionSyntax) + { + invalidEventExpression = assignmentExpressionSyntax.Right; + } + else + { + if (literalExpressionSyntax.Kind() != SyntaxKind.NullLiteralExpression) + { + invalidEventExpression = assignmentExpressionSyntax.Right; + } + else + { + invalidEventExpression = null; + } + } return new RaiseMethodCall((IMethodSymbol)methodSymbol.Symbol, parameterSymbols, arguments, eventSymbol); } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs index 29e757a..b62ae4e 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs @@ -268,6 +268,47 @@ public interface I await Verifier.VerifyAnalyzerAsync(source); } + [Theory] + [InlineData("Raise", "void")] + [InlineData("RaiseAsync", "Task")] + public async Task Raise_WithNotNullDelegate_NoDiagnosticReported(string method, string eventReturnType) + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:MethodMatchEventSignature|}, ""OK"", 1, null); + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:MethodMatchEventSignature|}, ""OK"", 1, 10); + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:MethodMatchEventSignature|}, ""OK"", 1, new object()); + + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:(a, b, c) => throw new NotSupportedException()|}, ""OK"", 1, null); + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:(a, b, c) => throw new NotSupportedException()|}, ""OK"", 1, 10); + mock1." + method + @"(m => m.TheEvent += {|PosInfoMoq1010:(a, b, c) => throw new NotSupportedException()|}, ""OK"", 1, new object()); + } + + public static " + eventReturnType + @" MethodMatchEventSignature(string a, int b, object c) { throw new NotSupportedException(); } + } + + public interface I + { + event CustomEventHandler TheEvent; + } + + public delegate " + eventReturnType + @" CustomEventHandler(string a, int b, object c); + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + [Fact] public async Task NoMoqLibrary() { From cf2046c69da04ebd80a7a598d26a99935277e260 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 04:24:53 +0200 Subject: [PATCH 06/14] Check that the RaiseAsync() method is used with asynchronous events (fixes #51). --- PosInformatique.Moq.Analyzers.sln | 1 + README.md | 1 + docs/Compilation/PosInfoMoq2019.md | 85 +++++++++++++++++++ src/Moq.Analyzers/AnalyzerReleases.Shipped.md | 1 + .../Analyzers/RaiseMethodsAnalyzer.cs | 55 ++++++++---- src/Moq.Analyzers/Moq.Analyzers.csproj | 1 + src/Moq.Analyzers/MoqSymbols.cs | 12 +++ src/Moq.Analyzers/RaiseMethodCall.cs | 8 +- .../Analyzers/RaiseMethodsAnalyzerTest.cs | 38 +++++++++ 9 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 docs/Compilation/PosInfoMoq2019.md diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index 4249d3b..8604a46 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -62,6 +62,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compilation", "Compilation" docs\Compilation\PosInfoMoq2016.md = docs\Compilation\PosInfoMoq2016.md docs\Compilation\PosInfoMoq2017.md = docs\Compilation\PosInfoMoq2017.md docs\Compilation\PosInfoMoq2018.md = docs\Compilation\PosInfoMoq2018.md + docs\Compilation\PosInfoMoq2019.md = docs\Compilation\PosInfoMoq2019.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 45b8d9e..c383aeb 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,5 @@ All the rules of this category should not be disabled (or changed their severity | [PosInfoMoq2016: `Mock` constructor with factory lambda expression can be used only with classes.](docs/Compilation/PosInfoMoq2016.md) | The factory lambda expression used in `Mock` instantiation must used only for the classes. | | [PosInfoMoq2017: `Mock.Raise()`/`RaiseAsync()` must use parameters matching the event signature.](docs/Compilation/PosInfoMoq2017.md) | The parameters passed to `Raise()` or `RaiseAsync()` must exactly match the parameters of the corresponding event delegate. | | [PosInfoMoq2018: The first parameter of `Raise()`/`RaiseAsync()` must be an event.](docs/Compilation/PosInfoMoq2018.md) | The first parameter passed to `Raise()` or `RaiseAsync()` must reference an **event** member of the mocked type. | +| [PosInfoMoq2019: `RaiseAsync()` must be used only for events with async handlers (returning `Task`).](docs/Compilation/PosInfoMoq2019.md) | `Mock.RaiseAsync()` must only be used with events whose delegate type returns `Task` (i.e., async events). | diff --git a/docs/Compilation/PosInfoMoq2019.md b/docs/Compilation/PosInfoMoq2019.md new file mode 100644 index 0000000..7d61bc5 --- /dev/null +++ b/docs/Compilation/PosInfoMoq2019.md @@ -0,0 +1,85 @@ +# PosInfoMoq2019: `RaiseAsync()` must be used only for events with async handlers (returning `Task`) + +| Property | Value | +|-----------------------|----------------------------------------------------------------------| +| **Rule ID** | PosInfoMoq2019 | +| **Title** | `RaiseAsync()` must be used only for events with async handlers (returning `Task`) | +| **Category** | Compilation | +| **Default severity** | Error | + +## Cause + +`Mock.RaiseAsync()` must only be used with events whose delegate type returns `Task` (i.e., async events). +Using `RaiseAsync()` on events whose handlers return `void` (or any non-`Task` type) is invalid. + +## Rule description + +Moq provides `RaiseAsync()` to trigger asynchronous events. This requires that the event delegate returns `Task` (or `Task`). +If the event delegate returns `void` (like `EventHandler` or `EventHandler`), `RaiseAsync()` is not appropriate and will cause runtime issues. + +### Delegate examples + +```csharp +// Async event delegate +public delegate Task AsyncEventHandler(object sender, EventArgs e); + +// Classic sync delegates (return void) +public delegate void VoidEventHandler(object sender, EventArgs e); +public class DataEventArgs : EventArgs { public int Value { get; } public DataEventArgs(int v) => Value = v; } +``` + +### Mocked type with both kinds of events + +```csharp +public class Service +{ + public event AsyncEventHandler AsyncChanged; // returns Task + public event EventHandler SyncChanged; // returns void + public event EventHandler SyncDataChanged; // returns void +} +``` + +### Correct usage (async delegate returning Task) + +```csharp +var serviceMock = new Mock(); + +// Use RaiseAsync with async event (Task-returning delegate) +await serviceMock.RaiseAsync(s => s.AsyncChanged += null, EventArgs.Empty); +``` + +### Incorrect usage (void-returning delegates) + +```csharp +var serviceMock = new Mock(); + +// EventHandler returns void — using RaiseAsync is invalid +await serviceMock.RaiseAsync(s => s.SyncChanged += null, EventArgs.Empty); // ❌ + +// EventHandler returns void — using RaiseAsync is invalid +await serviceMock.RaiseAsync(s => s.SyncDataChanged += null, new DataEventArgs(42)); // ❌ +``` + +## How to fix violations + +- If the event delegate returns `void`, use `Raise()` instead of `RaiseAsync()`. +- If you need async semantics, change the event to use a `Task`-returning delegate, for example `AsyncEventHandler`. + +### Fix examples + +```csharp +// Using Raise() for void-returning events +serviceMock.Raise(s => s.SyncChanged += null, EventArgs.Empty); +serviceMock.Raise(s => s.SyncDataChanged += null, new DataEventArgs(42)); + +// Using RaiseAsync() for Task-returning events +await serviceMock.RaiseAsync(s => s.AsyncChanged += null, EventArgs.Empty); +``` + +## When to suppress warnings + +Do not suppress this rule. +If disabled, Moq may throw a runtime exception (`NullReferenceException`) due to a known bug when `RaiseAsync()` is used on non-async events, +instead of providing a clear error message. + +Reference: https://github.com/devlooped/moq/issues/1568 diff --git a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md index ba090c8..fcb8544 100644 --- a/src/Moq.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Moq.Analyzers/AnalyzerReleases.Shipped.md @@ -6,6 +6,7 @@ Rule ID | Category | Severity | Notes PosInfoMoq1010 | Design | Warning | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Design/PosInfoMoq1010.html) PosInfoMoq2017 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2017.html) PosInfoMoq2018 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html) +PosInfoMoq2019 | Compilation | Error | RaiseMethodsAnalyzer, [Documentation](https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2019.html) ## Release 1.13.0 diff --git a/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs index ac04d27..f98f727 100644 --- a/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs +++ b/src/Moq.Analyzers/Analyzers/RaiseMethodsAnalyzer.cs @@ -15,6 +15,16 @@ namespace PosInformatique.Moq.Analyzers [DiagnosticAnalyzer(LanguageNames.CSharp)] public class RaiseMethodsAnalyzer : DiagnosticAnalyzer { + internal static readonly DiagnosticDescriptor EventExpressionMustBeEventAddWithNull = new DiagnosticDescriptor( + "PosInfoMoq1010", + "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", + "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Use '+= null' syntax when raising events with Raise()/RaiseAsync().", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Design/PosInfoMoq1010.html"); + internal static readonly DiagnosticDescriptor ParametersMustMatchSignature = new DiagnosticDescriptor( "PosInfoMoq2017", "The Raise() parameters must match the signature of the mocked event", @@ -35,20 +45,21 @@ public class RaiseMethodsAnalyzer : DiagnosticAnalyzer description: "The first parameter of Raise()/RaiseAsync() must be an event.", helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2018.html"); - internal static readonly DiagnosticDescriptor EventExpressionMustBeEventAddWithNull = new DiagnosticDescriptor( - "PosInfoMoq1010", - "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", - "Use '+= null' syntax when raising events with Raise()/RaiseAsync()", - "Design", - DiagnosticSeverity.Warning, + internal static readonly DiagnosticDescriptor RaiseAsyncMustBeUsedOnlyForAsyncEvents = new DiagnosticDescriptor( + "PosInfoMoq2019", + "RaiseAsync() must be used only for events with async handlers (returning Task)", + "RaiseAsync() must be used only for events with async handlers (returning Task)", + "Compilation", + DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Use '+= null' syntax when raising events with Raise()/RaiseAsync().", - helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Design/PosInfoMoq1010.html"); + description: "RaiseAsync() must be used only for events with async handlers (returning Task).", + helpLinkUri: "https://posinformatique.github.io/PosInformatique.Moq.Analyzers/docs/Compilation/PosInfoMoq2019.html"); public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + EventExpressionMustBeEventAddWithNull, ParametersMustMatchSignature, FirstParameterMustBeEvent, - EventExpressionMustBeEventAddWithNull); + RaiseAsyncMustBeUsedOnlyForAsyncEvents); public override void Initialize(AnalysisContext context) { @@ -85,12 +96,31 @@ private static void Analyze(SyntaxNodeAnalysisContext context) return; } + var raiseMethodSyntax = GetRaiseMethodSyntax(invocationExpression); + + if (raiseMethodSyntax is null) + { + return; + } + if (invalidEventExpression is not null) { // Raise a warning to indicate that the expression after the "+=" is not "null". context.ReportDiagnostic(EventExpressionMustBeEventAddWithNull, invalidEventExpression.GetLocation()); } + // -- Check if RaiseAsync() is called for asynchronous event -- + if (moqSymbols.IsRaiseAsyncMethod(raiseMethod.Method)) + { + var eventReturnType = raiseMethod.EventReturnType; + + if (!moqSymbols.IsTask(eventReturnType) && !moqSymbols.IsTaskGeneric(eventReturnType)) + { + context.ReportDiagnostic(RaiseAsyncMustBeUsedOnlyForAsyncEvents, raiseMethodSyntax.GetLocation()); + } + } + + // -- Check the signature of the event called -- var eventParameters = raiseMethod.EventParameters.ToList(); // Check the overload of the Raise() method called. @@ -106,13 +136,6 @@ private static void Analyze(SyntaxNodeAnalysisContext context) // Check the parameters count match if (raiseMethod.MethodParameters.Count != eventParameters.Count) { - var raiseMethodSyntax = GetRaiseMethodSyntax(invocationExpression); - - if (raiseMethodSyntax is null) - { - return; - } - context.ReportDiagnostic(ParametersMustMatchSignature, raiseMethodSyntax.GetLocation(), $"The event '{raiseMethod.Event.Name}' expects {raiseMethod.EventParameters.Count} argument(s) but {raiseMethod.MethodParameters.Count} were provided."); return; } diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index 38f1957..0c1a7f8 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -22,6 +22,7 @@ - PosInfoMoq1010: Use '+= null' syntax when raising events with Raise()/RaiseAsync() - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. - PosInfoMoq2018: The first parameter of Raise()/RaiseAsync() must be an event. + - PosInfoMoq2019: RaiseAsync() must be used only for events with async handlers (returning Task). 1.13.0 - Add new rules: diff --git a/src/Moq.Analyzers/MoqSymbols.cs b/src/Moq.Analyzers/MoqSymbols.cs index 994188b..7b894d7 100644 --- a/src/Moq.Analyzers/MoqSymbols.cs +++ b/src/Moq.Analyzers/MoqSymbols.cs @@ -60,6 +60,10 @@ internal sealed class MoqSymbols private readonly Lazy funcClass; + private readonly Lazy taskClass; + + private readonly Lazy taskGenericClass; + private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) { this.mockGenericClass = mockGenericClass; @@ -96,6 +100,8 @@ private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) this.eventArgsClass = new Lazy(() => compilation.GetTypeByMetadataName("System.EventArgs")!); this.funcClass = new Lazy(() => compilation.GetTypeByMetadataName("System.Func`1")!); + this.taskClass = new Lazy(() => compilation.GetTypeByMetadataName("System.Threading.Tasks.Task")!); + this.taskGenericClass = new Lazy(() => compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1")!); } public static MoqSymbols? FromCompilation(Compilation compilation) @@ -206,6 +212,12 @@ public bool IsSetupSetMethod(ISymbol? symbol) public bool IsSetupSetMethodWithoutGenericArgument(ISymbol? symbol) => AreEqual(symbol, this.setupSetMethodWithoutGenericArgument); + public bool IsTask(ISymbol? symbol) + => AreEqual(symbol, this.taskClass); + + public bool IsTaskGeneric(ISymbol? symbol) + => AreEqual(symbol, this.taskGenericClass); + public bool IsVerifiableMethod([NotNullWhen(true)] ISymbol? symbol) => AreEqual(symbol, this.verifiableMethods); diff --git a/src/Moq.Analyzers/RaiseMethodCall.cs b/src/Moq.Analyzers/RaiseMethodCall.cs index 24140b5..2b3bb89 100644 --- a/src/Moq.Analyzers/RaiseMethodCall.cs +++ b/src/Moq.Analyzers/RaiseMethodCall.cs @@ -11,6 +11,8 @@ namespace PosInformatique.Moq.Analyzers internal sealed class RaiseMethodCall { + private readonly IMethodSymbol eventMethod; + public RaiseMethodCall(IMethodSymbol method, IReadOnlyList methodParameters, IReadOnlyList methodArguments, IEventSymbol @event) { this.Method = method; @@ -18,7 +20,7 @@ public RaiseMethodCall(IMethodSymbol method, IReadOnlyList methodP this.MethodArguments = methodArguments; this.MethodParameters = methodParameters; - this.EventParameters = ((INamedTypeSymbol)@event.Type).DelegateInvokeMethod!.Parameters; + this.eventMethod = ((INamedTypeSymbol)@event.Type).DelegateInvokeMethod!; } public IMethodSymbol Method { get; } @@ -29,6 +31,8 @@ public RaiseMethodCall(IMethodSymbol method, IReadOnlyList methodP public IEventSymbol Event { get; } - public IReadOnlyList EventParameters { get; } + public IReadOnlyList EventParameters => this.eventMethod.Parameters; + + public ITypeSymbol EventReturnType => this.eventMethod.ReturnType; } } diff --git a/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs index b62ae4e..655fc1a 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/RaiseMethodsAnalyzerTest.cs @@ -14,6 +14,7 @@ public class RaiseMethodsAnalyzerTest [Theory] [InlineData("Raise", "void")] [InlineData("RaiseAsync", "Task")] + [InlineData("RaiseAsync", "Task")] public async Task Raise_WithParams_NoDiagnosticReported(string method, string eventReturnType) { var source = @" @@ -93,6 +94,7 @@ public class CustomEventArgs : EventArgs [Theory] [InlineData("Raise", "void")] [InlineData("RaiseAsync", "Task")] + [InlineData("RaiseAsync", "Task")] public async Task Raise_WithParams_MissingArgument_DiagnosticReported(string method, string eventReturnType) { var source = @" @@ -140,6 +142,7 @@ await Verifier.VerifyAnalyzerAsync( [Theory] [InlineData("Raise", "void")] [InlineData("RaiseAsync", "Task")] + [InlineData("RaiseAsync", "Task")] public async Task Raise_WithParams_WrongType_DiagnosticReported(string method, string eventReturnType) { var source = @" @@ -227,6 +230,9 @@ await Verifier.VerifyAnalyzerAsync( [InlineData("RaiseAsync", "Task", "\"OK\", 1, null")] [InlineData("RaiseAsync", "Task", "\"OK\", 1, 10")] [InlineData("RaiseAsync", "Task", "\"OK\", 1, new object()")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, null")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, 10")] + [InlineData("RaiseAsync", "Task", "\"OK\", 1, new object()")] public async Task Raise_WrongLambdaExpression_DiagnosticReported(string method, string eventReturnType, string parameters) { var source = @" @@ -271,6 +277,7 @@ public interface I [Theory] [InlineData("Raise", "void")] [InlineData("RaiseAsync", "Task")] + [InlineData("RaiseAsync", "Task")] public async Task Raise_WithNotNullDelegate_NoDiagnosticReported(string method, string eventReturnType) { var source = @" @@ -309,6 +316,37 @@ public interface I await Verifier.VerifyAnalyzerAsync(source); } + [Fact] + public async Task RaiseAsync_WithNoAsynchronousEvent_NoDiagnosticReported() + { + var source = @" + namespace ConsoleApplication1 + { + using Moq; + using System; + using System.Threading.Tasks; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(MockBehavior.Strict); + + mock1.{|PosInfoMoq2019:RaiseAsync|}(m => m.TheEvent += null, ""OK"", 1, new object()); + } + } + + public interface I + { + event CustomEventHandler TheEvent; + } + + public delegate void CustomEventHandler(string a, int b, object c); + }"; + + await Verifier.VerifyAnalyzerAsync(source); + } + [Fact] public async Task NoMoqLibrary() { From c2fee43cd5f2a9957c387fcb72bd6fe75d8e36cb Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 04:26:26 +0200 Subject: [PATCH 07/14] Change the version to 2.0.0 --- .github/workflows/github-actions-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index c05370a..1c0a449 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -7,7 +7,7 @@ on: type: string description: The version of the library required: true - default: 1.13.0 + default: 2.0.0 VersionSuffix: type: string description: The version suffix of the library (for example rc.1) From dd804aa1e4a3e16f86d7a345676844fe8919ba8d Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 05:24:03 +0200 Subject: [PATCH 08/14] Upgrade test libraries for Roselyn. --- tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj b/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj index c202d68..7f6e3bc 100644 --- a/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj +++ b/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj @@ -10,8 +10,8 @@ all - - + + From b83aed2a64b7a457c377fcd4e93eda9d7871c529 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 09:04:11 +0200 Subject: [PATCH 09/14] Add fixer for the PosInfoMoq1000 rule (fixes #53). --- docs/Design/PosInfoMoq1000-Fixer.png | Bin 0 -> 42013 bytes docs/Design/PosInfoMoq1000.md | 4 + .../VerifyAllShouldBeCalledAnalyzer.cs | 4 +- .../CodeFixes/AddVerifyAllCodeFixProvider.cs | 195 +++++++++++++++++ ...tGenericArgumentSetupSetCodeFixProvider.cs | 2 +- src/Moq.Analyzers/Moq.Analyzers.csproj | 6 +- src/Moq.Analyzers/SyntaxNodeHelper.cs | 27 +++ .../AddVerifyAllCodeFixProviderTest.cs | 203 ++++++++++++++++++ 8 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 docs/Design/PosInfoMoq1000-Fixer.png create mode 100644 src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs create mode 100644 src/Moq.Analyzers/SyntaxNodeHelper.cs create mode 100644 tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs diff --git a/docs/Design/PosInfoMoq1000-Fixer.png b/docs/Design/PosInfoMoq1000-Fixer.png new file mode 100644 index 0000000000000000000000000000000000000000..519051f7587f7ce6d5db248744ce6e651677deb7 GIT binary patch literal 42013 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 literal 0 HcmV?d00001 diff --git a/docs/Design/PosInfoMoq1000.md b/docs/Design/PosInfoMoq1000.md index d82f28d..383279e 100644 --- a/docs/Design/PosInfoMoq1000.md +++ b/docs/Design/PosInfoMoq1000.md @@ -40,6 +40,10 @@ public void SendMail_ShouldCallSmtpService() To fix a violation of this rule, call the `VerifyAll()` in the *Assert* phase on the `Mock` instances created during the *Arrange* phase. +### Visual Studio fixer +A Visual Studio fixer exists to add the call to the `VerifyAll()` method at the end of the unit test in the current document, project or solution. +![Visual Studio rule fixer](PosInfoMoq1000-Fixer.png) + ## When to suppress warnings Do not suppress a warning from this rule. Normally all setup methods must be call. diff --git a/src/Moq.Analyzers/Analyzers/VerifyAllShouldBeCalledAnalyzer.cs b/src/Moq.Analyzers/Analyzers/VerifyAllShouldBeCalledAnalyzer.cs index 72c945f..d2ad9eb 100644 --- a/src/Moq.Analyzers/Analyzers/VerifyAllShouldBeCalledAnalyzer.cs +++ b/src/Moq.Analyzers/Analyzers/VerifyAllShouldBeCalledAnalyzer.cs @@ -15,7 +15,7 @@ namespace PosInformatique.Moq.Analyzers [DiagnosticAnalyzer(LanguageNames.CSharp)] public class VerifyAllShouldBeCalledAnalyzer : DiagnosticAnalyzer { - private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + internal static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( "PosInfoMoq1000", "VerifyAll() method should be called when instantiate a Mock instances", "The VerifyAll() method should be called at the end of the unit test", @@ -54,7 +54,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) } // Retrieve the variable name - var variableName = objectCreation.Ancestors().OfType().FirstOrDefault(); + var variableName = SyntaxNodeHelper.GetVariableNameSyntax(objectCreation); if (variableName is null) { diff --git a/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs new file mode 100644 index 0000000..c392b3e --- /dev/null +++ b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs @@ -0,0 +1,195 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers +{ + using System.Collections.Immutable; + using System.Composition; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeActions; + using Microsoft.CodeAnalysis.CodeFixes; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddVerifyAllCodeFixProvider))] + [Shared] + public class AddVerifyAllCodeFixProvider : CodeFixProvider + { + public sealed override ImmutableArray FixableDiagnosticIds + { + get { return ImmutableArray.Create(VerifyAllShouldBeCalledAnalyzer.Rule.Id); } + } + + public sealed override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + // Gets the location where is the issue in the code. + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + // Gets the syntax node where is located the issue in the code. + var node = root.FindNode(diagnosticSpan, getInnermostNodeForTie: true); + + var variableName = SyntaxNodeHelper.GetVariableNameSyntax(node); + + if (variableName is null) + { + return; + } + + var parentMethod = node.Ancestors().OfType().FirstOrDefault(); + + if (parentMethod is null) + { + // Parent method not found, skip it. + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Add VerifyAll() call at the end of the unit test method.", + createChangedDocument: cancellationToken => AddVerifyAllAsync(context.Document, parentMethod, variableName.Identifier.ValueText, cancellationToken), + equivalenceKey: "Add VerifyAll() call at the end of the unit test method."), + diagnostic); + + return; + } + + private static async Task AddVerifyAllAsync(Document document, MethodDeclarationSyntax oldUnitTestMethod, string variableName, CancellationToken cancellationToken) + { + if (oldUnitTestMethod.Body is null) + { + return document; + } + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + + if (semanticModel is null) + { + return document; + } + + var moqSymbols = MoqSymbols.FromCompilation(semanticModel.Compilation); + + if (moqSymbols is null) + { + return document; + } + + // Find the location where we can insert the mock. + var index = oldUnitTestMethod.Body.Statements.Count; + + foreach (var statement in oldUnitTestMethod.Body.Statements.Reverse()) + { + if (statement is not ExpressionStatementSyntax expressionStatement) + { + continue; + } + + if (!IsVerifyAll(semanticModel, moqSymbols, expressionStatement, out var otherVariableName, cancellationToken)) + { + break; + } + + if (variableName.CompareTo(otherVariableName) >= 0) + { + // The variable name to add is in alphabetic order after the "variable.VerifyAll()", + // so will insert the statement here. + break; + } + + // We continue to iterate + index--; + } + + var verifyAllCallStatement = SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(variableName), + SyntaxFactory.IdentifierName("VerifyAll")))); + + var oldBodyStatements = oldUnitTestMethod.Body.Statements; + + var newStatements = InsertStatement(oldBodyStatements, index, verifyAllCallStatement); + + var newUnitTestMethod = oldUnitTestMethod.WithBody(oldUnitTestMethod.Body.WithStatements(newStatements)); + + var oldRoot = await document.GetSyntaxRootAsync(cancellationToken); + + if (oldRoot is null) + { + return document; + } + + var newRoot = oldRoot.ReplaceNode(oldUnitTestMethod, newUnitTestMethod); + + return document.WithSyntaxRoot(newRoot); + } + + private static SyntaxList InsertStatement(SyntaxList list, int index, StatementSyntax statement) + { + var newStatements = new List(list); + + if (index < newStatements.Count) + { + var leadingTrivia = newStatements[index].GetLeadingTrivia(); + + // Remove the leading trivia of the current node. + newStatements[index] = newStatements[index].ReplaceNode(newStatements[index], newStatements[index].WithoutLeadingTrivia()); + + statement = statement.WithLeadingTrivia(leadingTrivia); + } + + newStatements.Insert(index, statement); + + return SyntaxFactory.List(newStatements); + } + + private static bool IsVerifyAll(SemanticModel semanticModel, MoqSymbols moqSymbols, ExpressionStatementSyntax statement, out string? variableName, CancellationToken cancellationToken) + { + var expressionSymbol = semanticModel.GetSymbolInfo(statement.Expression, cancellationToken); + + if (!moqSymbols.IsVerifyAllMethod(expressionSymbol.Symbol)) + { + variableName = null; + return false; + } + + if (statement.Expression is not InvocationExpressionSyntax invocationExpressionSyntax) + { + variableName = null; + return false; + } + + if (invocationExpressionSyntax.Expression is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) + { + variableName = null; + return false; + } + + if (memberAccessExpressionSyntax.Expression is not IdentifierNameSyntax identifierNameSyntax) + { + variableName = null; + return false; + } + + variableName = identifierNameSyntax.Identifier.ValueText; + return true; + } + } +} diff --git a/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs index d911756..65dd82d 100644 --- a/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs +++ b/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs @@ -61,7 +61,7 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) private static async Task AddMockBehiavorStrictArgumentAsync(Document document, IdentifierNameSyntax oldIdentifierNameSyntax, CancellationToken cancellationToken) { - var semanticModel = await document.GetSemanticModelAsync(); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); if (semanticModel is null) { diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index 0c1a7f8..037072d 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -23,6 +23,8 @@ - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. - PosInfoMoq2018: The first parameter of Raise()/RaiseAsync() must be an event. - PosInfoMoq2019: RaiseAsync() must be used only for events with async handlers (returning Task). + - Add new fixers: + - PosInfoMoq1000: Add a call of missing VerifyAll() at the end of the unit tests. 1.13.0 - Add new rules: @@ -50,8 +52,8 @@ 1.10.0 - Add new rules: - - PosInfoMoq2012: The delegate in the argument of the Returns() method must return a value with same type of the mocked method. - - PosInfoMoq2013: The delegate in the argument of the Returns()/ReturnsAsync() method must have the same parameter types of the mocked method/property. + - PosInfoMoq2012: The delegate in the argument of the Returns() method must return a value with same type of the mocked method. + - PosInfoMoq2013: The delegate in the argument of the Returns()/ReturnsAsync() method must have the same parameter types of the mocked method/property. 1.9.3 - Fix the PosInfoMoq2006 when Setup() a method/property in inherited class. diff --git a/src/Moq.Analyzers/SyntaxNodeHelper.cs b/src/Moq.Analyzers/SyntaxNodeHelper.cs new file mode 100644 index 0000000..77001f2 --- /dev/null +++ b/src/Moq.Analyzers/SyntaxNodeHelper.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers +{ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + internal static class SyntaxNodeHelper + { + public static VariableDeclaratorSyntax? GetVariableNameSyntax(SyntaxNode node) + { + var variableName = node.Ancestors().OfType().FirstOrDefault(); + + if (variableName is null) + { + // No variable set on the left. + return null; + } + + return variableName; + } + } +} diff --git a/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs b/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs new file mode 100644 index 0000000..4dd6614 --- /dev/null +++ b/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs @@ -0,0 +1,203 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Moq.Analyzers.Tests +{ + using Verifier = MoqCSharpCodeFixVerifier; + + public class AddVerifyAllCodeFixProviderTest + { + [Fact] + public async Task AddVerifyAllBeforeOtherMocks_Fix() + { + var source = @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = {|PosInfoMoq1000:new Mock()|}; + mock1.Setup(i => i.Method()); + + var mock2 = new Mock(); + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock2.VerifyAll(); + mock3.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + var expectedFixedSource = + @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.Method()); + + var mock2 = new Mock(); + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock1.VerifyAll(); + mock2.VerifyAll(); + mock3.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + await Verifier.VerifyCodeFixAsync(source, expectedFixedSource); + } + + [Fact] + public async Task AddVerifyAllAfterOtherMocks_Fix() + { + var source = @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mockzzz = {|PosInfoMoq1000:new Mock()|}; + mockzzz.Setup(i => i.Method()); + + var mock2 = new Mock(); + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock2.VerifyAll(); + mock3.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + var expectedFixedSource = + @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mockzzz = new Mock(); + mockzzz.Setup(i => i.Method()); + + var mock2 = new Mock(); + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock2.VerifyAll(); + mock3.VerifyAll(); + mockzzz.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + await Verifier.VerifyCodeFixAsync(source, expectedFixedSource); + } + + [Fact] + public async Task AddVerifyAllSingleMock_WithComment_Fix() + { + var source = @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = {|PosInfoMoq1000:new Mock()|}; + mock1.Setup(i => i.Method()); + } + } + + public interface I + { + void Method(); + } +}"; + + var expectedFixedSource = + @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.Method()); + mock1.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + await Verifier.VerifyCodeFixAsync(source, expectedFixedSource); + } + } +} From ae1916e3f94da005a3b5441a880d82b4b832a46e Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 09:17:04 +0200 Subject: [PATCH 10/14] Upgrade NuGet package. --- .../Moq.Analyzers.Sandbox.csproj | 13 +++++++------ .../Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/Moq.Analyzers.Sandbox/Moq.Analyzers.Sandbox.csproj b/tests/Moq.Analyzers.Sandbox/Moq.Analyzers.Sandbox.csproj index e530db7..57aa733 100644 --- a/tests/Moq.Analyzers.Sandbox/Moq.Analyzers.Sandbox.csproj +++ b/tests/Moq.Analyzers.Sandbox/Moq.Analyzers.Sandbox.csproj @@ -6,17 +6,18 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj b/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj index 7f6e3bc..84997b4 100644 --- a/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj +++ b/tests/Moq.Analyzers.Tests/Moq.Analyzers.Tests.csproj @@ -5,16 +5,16 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all From f12d4fe7730f2c700afaec9642ba90578396cc47 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 09:26:50 +0200 Subject: [PATCH 11/14] Add ConfigureAwait(false) for asynchronous method call. --- PosInformatique.Moq.Analyzers.sln | 5 +++++ src/.editorconfig | 6 ++++++ src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs | 4 ++-- .../CodeFixes/SetBehaviorToStrictCodeFixProvider.cs | 4 ++-- .../CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs | 4 ++-- src/Moq.Analyzers/Moq.Analyzers.csproj | 4 ++++ 6 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/.editorconfig diff --git a/PosInformatique.Moq.Analyzers.sln b/PosInformatique.Moq.Analyzers.sln index 8604a46..68f7953 100644 --- a/PosInformatique.Moq.Analyzers.sln +++ b/PosInformatique.Moq.Analyzers.sln @@ -75,6 +75,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\github-actions-release.yml = .github\workflows\github-actions-release.yml EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + src\.editorconfig = src\.editorconfig + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..71f3161 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,6 @@ + +[*.{cs,vb}] +#### Naming styles #### + +#### Visual Studio Threading Analyzer #### +dotnet_diagnostic.VSTHRD111.severity = warning \ No newline at end of file diff --git a/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs index c392b3e..28e8805 100644 --- a/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs +++ b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs @@ -76,7 +76,7 @@ private static async Task AddVerifyAllAsync(Document document, MethodD return document; } - var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); if (semanticModel is null) { @@ -129,7 +129,7 @@ private static async Task AddVerifyAllAsync(Document document, MethodD var newUnitTestMethod = oldUnitTestMethod.WithBody(oldUnitTestMethod.Body.WithStatements(newStatements)); - var oldRoot = await document.GetSyntaxRootAsync(cancellationToken); + var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); if (oldRoot is null) { diff --git a/src/Moq.Analyzers/CodeFixes/SetBehaviorToStrictCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/SetBehaviorToStrictCodeFixProvider.cs index 0e4c900..c85f4dc 100644 --- a/src/Moq.Analyzers/CodeFixes/SetBehaviorToStrictCodeFixProvider.cs +++ b/src/Moq.Analyzers/CodeFixes/SetBehaviorToStrictCodeFixProvider.cs @@ -111,7 +111,7 @@ private static async Task AddMockBehiavorStrictArgumentAsync(Document var newMockCreationExpression = oldMockCreationExpression.WithArgumentList( SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))); - var oldRoot = await document.GetSyntaxRootAsync(cancellationToken); + var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); if (oldRoot is null) { @@ -148,7 +148,7 @@ private static async Task AddMockBehiavorStrictArgumentAsync(Document var newInvocationExpression = oldInvocationExpression.WithArgumentList( SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))); - var oldRoot = await document.GetSyntaxRootAsync(cancellationToken); + var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); if (oldRoot is null) { diff --git a/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs index 65dd82d..eb1c068 100644 --- a/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs +++ b/src/Moq.Analyzers/CodeFixes/SetGenericArgumentSetupSetCodeFixProvider.cs @@ -61,7 +61,7 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) private static async Task AddMockBehiavorStrictArgumentAsync(Document document, IdentifierNameSyntax oldIdentifierNameSyntax, CancellationToken cancellationToken) { - var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); if (semanticModel is null) { @@ -104,7 +104,7 @@ private static async Task AddMockBehiavorStrictArgumentAsync(Document SyntaxFactory.TypeArgumentList( SyntaxFactory.SingletonSeparatedList(propertyType))); - var oldRoot = await document.GetSyntaxRootAsync(cancellationToken); + var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); if (oldRoot is null) { diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index 037072d..f56052c 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -128,6 +128,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 8a179e5f85f8adc8435e21acfd0b29efdad688a8 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 09:35:27 +0200 Subject: [PATCH 12/14] Check the cancellation token propagation. --- src/.editorconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/.editorconfig b/src/.editorconfig index 71f3161..ff4340d 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -2,5 +2,8 @@ [*.{cs,vb}] #### Naming styles #### +#### Code Analysis #### +dotnet_diagnostic.CA2016.severity = warning + #### Visual Studio Threading Analyzer #### dotnet_diagnostic.VSTHRD111.severity = warning \ No newline at end of file From fb6461fbcdf04539d69714265e14d60b7a8cf002 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 6 Oct 2025 17:32:22 +0200 Subject: [PATCH 13/14] Check the method setup in SetupSequence() method (fixes: #33). --- README.md | 2 +- docs/Compilation/PosInfoMoq2001.md | 12 ++--- ...BeUsedOnlyForOverridableMembersAnalyzer.cs | 6 +-- src/Moq.Analyzers/Moq.Analyzers.csproj | 1 + src/Moq.Analyzers/MoqSymbols.cs | 6 +++ ...edOnlyForOverridableMembersAnalyzerTest.cs | 48 +++++++++++++++++++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c383aeb..caeb6d7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ All the rules of this category should not be disabled (or changed their severity | Rule | Description | | - | - | | [PosInfoMoq2000: The `Returns()` or `ReturnsAsync()` methods must be call for Strict mocks](docs/Compilation/PosInfoMoq2000.md) | When a `Mock` has been defined with the `Strict` behavior, the `Returns()` or `ReturnsAsync()` method must be called when setup a method to mock which returns a value. | -| [PosInfoMoq2001: The `Setup()`/`SetupSet()` method must be used only on overridable members](docs/Compilation/PosInfoMoq2001.md)) | The `Setup()` method must be applied only for overridable members. | +| [PosInfoMoq2001: The `Setup()`/`SetupSequence()`/`SetupSet()` method must be used only on overridable members](docs/Compilation/PosInfoMoq2001.md)) | The `Setup()`/`SetupSequence()`/`SetupSet()` method must be applied only for overridable members. | | [PosInfoMoq2002: `Mock` class can be used only to mock non-sealed class](docs/Compilation/PosInfoMoq2002.md) | The `Mock` class can mock only interfaces or non-`sealed` classes. | | [PosInfoMoq2003: The `Callback()` delegate expression must match the signature of the mocked method](docs/Compilation/PosInfoMoq2003.md) | The delegate in the argument of the `Callback()` method must match the signature of the mocked method. | | [PosInfoMoq2004: Constructor arguments cannot be passed for interface mocks](docs/Compilation/PosInfoMoq2004.md) | No arguments can be passed to a mocked interface. | diff --git a/docs/Compilation/PosInfoMoq2001.md b/docs/Compilation/PosInfoMoq2001.md index 9f98a05..448e202 100644 --- a/docs/Compilation/PosInfoMoq2001.md +++ b/docs/Compilation/PosInfoMoq2001.md @@ -1,15 +1,15 @@ -# PosInfoMoq2001: The `Setup()`/`SetupSet()` method must be used only on overridable members +# PosInfoMoq2001: The `Setup()`/`SetupSequence()`/`SetupSet()` method must be used only on overridable members | Property | Value | |-------------------------------------|---------------------------------------------------------------| | **Rule ID** | PosInfoMoq2001 | -| **Title** | The `Setup()`/`SetupSet()` method must be used only on overridable members | +| **Title** | The `Setup()`/`SetupSequence()`/`SetupSet()` method must be used only on overridable members | | **Category** | Compilation | | **Default severity** | Error | ## Cause -The `Setup()` or `SetupSet()` methods must be applied only for overridable members. +The `Setup()`, `SetupSequence()` or `SetupSet()` methods must be applied only for overridable members. An overridable member is a **method** or **property** which is in: - An `interface`. - A non-`sealed` `class`. In this case, the member must be: @@ -18,10 +18,10 @@ An overridable member is a **method** or **property** which is in: ## Rule description -The `Setup()` method must be applied only for overridable members. +The `Setup()`, `SetupSequence()` and `SetupSet()` methods must be applied only for overridable members. For example: -- The following methods and properties can be mock and used in the `Setup()` method: +- The following methods and properties can be mock and used in the `Setup()` and `SetupSequence()` method: - `IService.MethodCanBeMocked()` - `IService.PropertyCanBeMocked` - `Service.VirtualMethodCanBeMocked` @@ -58,7 +58,7 @@ static methods which can not be overriden. ## How to fix violations -To fix a violation of this rule, be sure to mock a member in the `Setup()` or `SetupSet()` method which can be overriden. +To fix a violation of this rule, be sure to mock a member in the `Setup()`, `SetupSequence()` or `SetupSet()` method which can be overriden. ## When to suppress warnings diff --git a/src/Moq.Analyzers/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzer.cs b/src/Moq.Analyzers/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzer.cs index 2d6927c..fc3c6ee 100644 --- a/src/Moq.Analyzers/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzer.cs +++ b/src/Moq.Analyzers/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzer.cs @@ -17,8 +17,8 @@ public class SetupMustBeUsedOnlyForOverridableMembersAnalyzer : DiagnosticAnalyz { private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( "PosInfoMoq2001", - "The Setup()/SetupSet() method must be used only on overridable members", - "The Setup()/SetupSet() method must be used only on overridable members", + "The Setup()/SetupSequence()/SetupSet() method must be used only on overridable members", + "The Setup()/SetupSequence()/SetupSet() method must be used only on overridable members", "Compilation", DiagnosticSeverity.Error, isEnabledByDefault: true, @@ -51,7 +51,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) // Check is Setup() method. var methodSymbol = context.SemanticModel.GetSymbolInfo(invocationExpression, context.CancellationToken); - if (!moqSymbols.IsSetupMethod(methodSymbol.Symbol) && !moqSymbols.IsSetupSetMethod(methodSymbol.Symbol)) + if (!moqSymbols.IsSetupMethod(methodSymbol.Symbol) && !moqSymbols.IsSetupSetMethod(methodSymbol.Symbol) && !moqSymbols.IsSetupSequenceMethod(methodSymbol.Symbol)) { return; } diff --git a/src/Moq.Analyzers/Moq.Analyzers.csproj b/src/Moq.Analyzers/Moq.Analyzers.csproj index f56052c..82d371c 100644 --- a/src/Moq.Analyzers/Moq.Analyzers.csproj +++ b/src/Moq.Analyzers/Moq.Analyzers.csproj @@ -20,6 +20,7 @@ 2.0.0 - Add new rules: - PosInfoMoq1010: Use '+= null' syntax when raising events with Raise()/RaiseAsync() + - PosInfoMoq2001: Add the support of the SetupSequence() methods. - PosInfoMoq2017: Mock<T>.Raise()/RaiseAsync() must use parameters matching the event signature. - PosInfoMoq2018: The first parameter of Raise()/RaiseAsync() must be an event. - PosInfoMoq2019: RaiseAsync() must be used only for events with async handlers (returning Task). diff --git a/src/Moq.Analyzers/MoqSymbols.cs b/src/Moq.Analyzers/MoqSymbols.cs index 7b894d7..43afa38 100644 --- a/src/Moq.Analyzers/MoqSymbols.cs +++ b/src/Moq.Analyzers/MoqSymbols.cs @@ -52,6 +52,8 @@ internal sealed class MoqSymbols private readonly Lazy setupSetMethodWithoutGenericArgument; + private readonly Lazy> setupSequenceMethods; + private readonly Lazy> setupSetMethods; private readonly Lazy timesClass; @@ -93,6 +95,7 @@ private MoqSymbols(INamedTypeSymbol mockGenericClass, Compilation compilation) this.mockConstructorWithFactory = new Lazy(() => mockGenericClass.Constructors.Single(c => c.Parameters.Length == 2 && c.Parameters[0].Type.Name == "Expression")); + this.setupSequenceMethods = new Lazy>(() => mockGenericClass.GetMembers("SetupSequence").OfType().ToArray()); this.setupSetMethodWithoutGenericArgument = new Lazy(() => mockGenericClass.GetMembers("SetupSet").OfType().Single(c => c.TypeArguments.Length == 1)); this.setupSetMethods = new Lazy>(() => mockGenericClass.GetMembers("SetupSet").OfType().ToArray()); @@ -206,6 +209,9 @@ public bool IsSetupMethod(ISymbol? symbol) public bool IsSetupProtectedMethod(ISymbol? symbol) => AreEqual(symbol, this.setupProtectedMethods); + public bool IsSetupSequenceMethod(ISymbol? symbol) + => AreEqual(symbol, this.setupSequenceMethods); + public bool IsSetupSetMethod(ISymbol? symbol) => AreEqual(symbol, this.setupSetMethods); diff --git a/tests/Moq.Analyzers.Tests/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzerTest.cs b/tests/Moq.Analyzers.Tests/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzerTest.cs index 5216328..413aacc 100644 --- a/tests/Moq.Analyzers.Tests/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzerTest.cs +++ b/tests/Moq.Analyzers.Tests/Analyzers/SetupMustBeUsedOnlyForOverridableMembersAnalyzerTest.cs @@ -26,82 +26,118 @@ public void TestMethod() var mock1 = new Mock(); mock1.Setup(i => i.TestMethod()); mock1.Setup(i => i.TestProperty); + mock1.SetupSequence(i => i.TestMethod()); + mock1.SetupSequence(i => i.TestProperty); mock1.SetupSet(i => i.TestProperty = ""Foobard""); mock1.SetupSet(i => i.TestProperty = ""Foobard""); mock1.Setup(i => i.InnerObject.VirtualMethod()); mock1.Setup(i => i.InnerObject.VirtualProperty); + mock1.SetupSequence(i => i.InnerObject.VirtualMethod()); + mock1.SetupSequence(i => i.InnerObject.VirtualProperty); mock1.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock1.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock1.Setup(i => i.InnerObject.AbstractMethod()); mock1.Setup(i => i.InnerObject.AbstractProperty); + mock1.SetupSequence(i => i.InnerObject.AbstractMethod()); + mock1.SetupSequence(i => i.InnerObject.AbstractProperty); mock1.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock1.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); var mock2 = new Mock(); mock2.Setup(i => i.VirtualMethod()); mock2.Setup(i => i.VirtualProperty); + mock2.SetupSequence(i => i.VirtualMethod()); + mock2.SetupSequence(i => i.VirtualProperty); mock2.SetupSet(i => i.VirtualProperty = ""Foobard""); mock2.SetupSet(i => i.VirtualProperty = ""Foobard""); mock2.Setup(i => i.InnerObject.VirtualMethod()); mock2.Setup(i => i.InnerObject.VirtualProperty); + mock2.SetupSequence(i => i.InnerObject.VirtualMethod()); + mock2.SetupSequence(i => i.InnerObject.VirtualProperty); mock2.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock2.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock2.Setup(i => i.InnerObject.AbstractMethod()); mock2.Setup(i => i.InnerObject.AbstractProperty); + mock2.SetupSequence(i => i.InnerObject.AbstractMethod()); + mock2.SetupSequence(i => i.InnerObject.AbstractProperty); mock2.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock2.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); var mock3 = new Mock(); mock3.Setup(i => i.VirtualMethod()); mock3.Setup(i => i.VirtualProperty); + mock3.SetupSequence(i => i.VirtualMethod()); + mock3.SetupSequence(i => i.VirtualProperty); mock3.SetupSet(i => i.VirtualProperty = ""Foobard""); mock3.SetupSet(i => i.VirtualProperty = ""Foobard""); mock3.Setup(i => i.AbstractMethod()); mock3.Setup(i => i.AbstractProperty); + mock3.SetupSequence(i => i.AbstractMethod()); + mock3.SetupSequence(i => i.AbstractProperty); mock3.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock3.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock3.Setup(i => i.InnerObject.VirtualMethod()); mock3.Setup(i => i.InnerObject.VirtualProperty); + mock3.SetupSequence(i => i.InnerObject.VirtualMethod()); + mock3.SetupSequence(i => i.InnerObject.VirtualProperty); mock3.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock3.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock3.Setup(i => i.InnerObject.AbstractMethod()); mock3.Setup(i => i.InnerObject.AbstractProperty); + mock3.SetupSequence(i => i.InnerObject.AbstractMethod()); + mock3.SetupSequence(i => i.InnerObject.AbstractProperty); mock3.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock3.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); var mock4 = new Mock(); mock4.Setup(i => i.VirtualMethod()); mock4.Setup(i => i.VirtualProperty); + mock4.SetupSequence(i => i.VirtualMethod()); + mock4.SetupSequence(i => i.VirtualProperty); mock4.SetupSet(i => i.VirtualProperty = ""Foobard""); mock4.SetupSet(i => i.VirtualProperty = ""Foobard""); mock4.Setup(i => i.AbstractMethod()); mock4.Setup(i => i.AbstractProperty); + mock4.SetupSequence(i => i.AbstractMethod()); + mock4.SetupSequence(i => i.AbstractProperty); mock4.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock4.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock4.Setup(i => i.InnerObject.VirtualMethod()); mock4.Setup(i => i.InnerObject.VirtualProperty); + mock4.SetupSequence(i => i.InnerObject.VirtualMethod()); + mock4.SetupSequence(i => i.InnerObject.VirtualProperty); mock4.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock4.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock4.Setup(i => i.InnerObject.AbstractMethod()); mock4.Setup(i => i.InnerObject.AbstractProperty); + mock4.SetupSequence(i => i.InnerObject.AbstractMethod()); + mock4.SetupSequence(i => i.InnerObject.AbstractProperty); mock4.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock4.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); var mock5 = new Mock(); mock5.Setup(i => i.VirtualMethod()); mock5.Setup(i => i.VirtualProperty); + mock5.SetupSequence(i => i.VirtualMethod()); + mock5.SetupSequence(i => i.VirtualProperty); mock5.SetupSet(i => i.VirtualProperty = ""Foobard""); mock5.SetupSet(i => i.VirtualProperty = ""Foobard""); mock5.Setup(i => i.AbstractMethod()); mock5.Setup(i => i.AbstractProperty); + mock5.SetupSequence(i => i.AbstractMethod()); + mock5.SetupSequence(i => i.AbstractProperty); mock5.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock5.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock5.Setup(i => i.InnerObject.VirtualMethod()); mock5.Setup(i => i.InnerObject.VirtualProperty); + mock5.SetupSequence(i => i.InnerObject.VirtualMethod()); + mock5.SetupSequence(i => i.InnerObject.VirtualProperty); mock5.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock5.SetupSet(i => i.InnerObject.VirtualProperty = ""Foobard""); mock5.Setup(i => i.InnerObject.AbstractMethod()); mock5.Setup(i => i.InnerObject.AbstractProperty); + mock5.SetupSequence(i => i.InnerObject.AbstractMethod()); + mock5.SetupSequence(i => i.InnerObject.AbstractProperty); mock5.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); mock5.SetupSet(i => i.InnerObject.AbstractProperty = ""Foobard""); } @@ -191,32 +227,44 @@ public void TestMethod() var mock1 = new Mock(); mock1.Setup(i => i.[|Method|]()); mock1.Setup(i => i.[|Property|]); + mock1.SetupSequence(i => i.[|Method|]()); + mock1.SetupSequence(i => i.[|Property|]); mock1.SetupSet(i => i.[|Property|] = ""1234""); mock1.SetupSet(i => i.[|Property|] = ""1234""); mock1.Setup(i => i.[|InnerObject|].VirtualMethod()); mock1.Setup(i => i.[|InnerObject|].VirtualProperty); + mock1.SetupSequence(i => i.[|InnerObject|].VirtualMethod()); + mock1.SetupSequence(i => i.[|InnerObject|].VirtualProperty); mock1.SetupSet(i => i.[|InnerObject|].VirtualProperty = ""1234""); mock1.SetupSet(i => i.[|InnerObject|].VirtualProperty = ""1234""); mock1.Setup(i => i.AbstractInnerObject.[|Method|]()); mock1.Setup(i => i.AbstractInnerObject.[|Property|]); + mock1.SetupSequence(i => i.AbstractInnerObject.[|Method|]()); + mock1.SetupSequence(i => i.AbstractInnerObject.[|Property|]); mock1.SetupSet(i => i.AbstractInnerObject.[|Property|] = ""1234""); mock1.SetupSet(i => i.AbstractInnerObject.[|Property|] = ""1234""); mock1.Setup(i => i.[|SealedInnerObject|].VirtualMethod()); mock1.Setup(i => i.[|SealedInnerObject|].VirtualProperty); + mock1.SetupSequence(i => i.[|SealedInnerObject|].VirtualMethod()); + mock1.SetupSequence(i => i.[|SealedInnerObject|].VirtualProperty); mock1.SetupSet(i => i.[|SealedInnerObject|].VirtualProperty = ""1234""); mock1.SetupSet(i => i.[|SealedInnerObject|].VirtualProperty = ""1234""); var mock2 = new Mock(); mock2.Setup(i => StandardClass.[|StaticMethod|]()); + mock2.SetupSequence(i => StandardClass.[|StaticMethod|]()); var mock3 = new Mock(); mock3.Setup(i => i.[|Method|]()); mock3.Setup(i => i.[|Property|]); + mock3.SetupSequence(i => i.[|Method|]()); + mock3.SetupSequence(i => i.[|Property|]); mock3.SetupSet(i => i.[|Property|] = ""1234""); mock3.SetupSet(i => i.[|Property|] = ""1234""); var mock4 = new Mock(); mock4.Setup(i => i.[|ExtensionMethod|]()); + mock4.SetupSequence(i => i.[|ExtensionMethod|]()); } } From a104b003712bbcd9b7a23cc02acd2e24d9bb47f4 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 7 Oct 2025 11:42:21 +0200 Subject: [PATCH 14/14] Improve the fixer PosInfoMoq1000 to add a new line for the first VerifyAll() if need. --- .../CodeFixes/AddVerifyAllCodeFixProvider.cs | 30 ++-- .../AddVerifyAllCodeFixProviderTest.cs | 128 +++++++++++++++++- 2 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs index 28e8805..b52f22d 100644 --- a/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs +++ b/src/Moq.Analyzers/CodeFixes/AddVerifyAllCodeFixProvider.cs @@ -92,6 +92,7 @@ private static async Task AddVerifyAllAsync(Document document, MethodD // Find the location where we can insert the mock. var index = oldUnitTestMethod.Body.Statements.Count; + bool insertNewLine = true; foreach (var statement in oldUnitTestMethod.Body.Statements.Reverse()) { @@ -105,6 +106,8 @@ private static async Task AddVerifyAllAsync(Document document, MethodD break; } + insertNewLine = false; + if (variableName.CompareTo(otherVariableName) >= 0) { // The variable name to add is in alphabetic order after the "variable.VerifyAll()", @@ -123,11 +126,7 @@ private static async Task AddVerifyAllAsync(Document document, MethodD SyntaxFactory.IdentifierName(variableName), SyntaxFactory.IdentifierName("VerifyAll")))); - var oldBodyStatements = oldUnitTestMethod.Body.Statements; - - var newStatements = InsertStatement(oldBodyStatements, index, verifyAllCallStatement); - - var newUnitTestMethod = oldUnitTestMethod.WithBody(oldUnitTestMethod.Body.WithStatements(newStatements)); + var newUnitTestMethodBody = InsertStatement(oldUnitTestMethod.Body, index, verifyAllCallStatement, insertNewLine); var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); @@ -136,28 +135,39 @@ private static async Task AddVerifyAllAsync(Document document, MethodD return document; } - var newRoot = oldRoot.ReplaceNode(oldUnitTestMethod, newUnitTestMethod); + var newRoot = oldRoot.ReplaceNode(oldUnitTestMethod, oldUnitTestMethod.WithBody(newUnitTestMethodBody)); return document.WithSyntaxRoot(newRoot); } - private static SyntaxList InsertStatement(SyntaxList list, int index, StatementSyntax statement) + private static BlockSyntax InsertStatement(BlockSyntax unitTestMethodBody, int index, StatementSyntax statement, bool insertNewLine) { - var newStatements = new List(list); + var newStatements = new List(unitTestMethodBody.Statements); if (index < newStatements.Count) { var leadingTrivia = newStatements[index].GetLeadingTrivia(); - // Remove the leading trivia of the current node. + // Remove the leading trivia of the current node and transfert it to the new statement to insert. newStatements[index] = newStatements[index].ReplaceNode(newStatements[index], newStatements[index].WithoutLeadingTrivia()); statement = statement.WithLeadingTrivia(leadingTrivia); } + if (insertNewLine) + { + // Special case, we insert a new line at the end of the unit test (we also retrieve the comment at the end if need). + // Because we want that the VerifyAll() blocks are detached with a new line. + // It is useful if developers want to call the fixer for all the solution. + statement = statement.WithLeadingTrivia( + unitTestMethodBody.CloseBraceToken.LeadingTrivia.Add(SyntaxFactory.CarriageReturnLineFeed)); + + unitTestMethodBody = unitTestMethodBody.WithCloseBraceToken(unitTestMethodBody.CloseBraceToken.WithLeadingTrivia()); + } + newStatements.Insert(index, statement); - return SyntaxFactory.List(newStatements); + return unitTestMethodBody.WithStatements(SyntaxFactory.List(newStatements)); } private static bool IsVerifyAll(SemanticModel semanticModel, MoqSymbols moqSymbols, ExpressionStatementSyntax statement, out string? variableName, CancellationToken cancellationToken) diff --git a/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs b/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs index 4dd6614..6d61c5b 100644 --- a/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs +++ b/tests/Moq.Analyzers.Tests/CodeFixes/AddVerifyAllCodeFixProviderTest.cs @@ -71,6 +71,76 @@ public void TestMethod() } } + public interface I + { + void Method(); + } +}"; + + await Verifier.VerifyCodeFixAsync(source, expectedFixedSource); + } + + [Fact] + public async Task AddVerifyAllInTheMiddleOfOtherMocks_Fix() + { + var source = @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.Method()); + + var mock2 = {|PosInfoMoq1000:new Mock()|}; + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock1.VerifyAll(); + mock3.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + var expectedFixedSource = + @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.Method()); + + var mock2 = new Mock(); + mock2.Setup(i => i.Method()); + + var mock3 = new Mock(); + mock3.Setup(i => i.Method()); + + // No changes + mock1.VerifyAll(); + mock2.VerifyAll(); + mock3.VerifyAll(); + } + } + public interface I { void Method(); @@ -151,7 +221,58 @@ public interface I } [Fact] - public async Task AddVerifyAllSingleMock_WithComment_Fix() + public async Task AddVerifyAllSingleMock() + { + var source = @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = {|PosInfoMoq1000:new Mock()|}; + mock1.Setup(i => i.Method()); + } + } + + public interface I + { + void Method(); + } +}"; + + var expectedFixedSource = + @" +namespace ConsoleApplication1 +{ + using Moq; + using System; + + public class TestClass + { + public void TestMethod() + { + var mock1 = new Mock(); + mock1.Setup(i => i.Method()); + + mock1.VerifyAll(); + } + } + + public interface I + { + void Method(); + } +}"; + + await Verifier.VerifyCodeFixAsync(source, expectedFixedSource); + } + + [Fact] + public async Task AddVerifyAllSingleMock_WithCommentAtTheEnd() { var source = @" namespace ConsoleApplication1 @@ -165,6 +286,8 @@ public void TestMethod() { var mock1 = {|PosInfoMoq1000:new Mock()|}; mock1.Setup(i => i.Method()); + + // End of unit tests } } @@ -187,6 +310,9 @@ public void TestMethod() { var mock1 = new Mock(); mock1.Setup(i => i.Method()); + + // End of unit tests + mock1.VerifyAll(); } }