diff --git a/analyzers/src/SonarAnalyzer.CSharp/Rules/TestMethodShouldContainAssertion.cs b/analyzers/src/SonarAnalyzer.CSharp/Rules/TestMethodShouldContainAssertion.cs index f24611b22b7..19dbafc8a1f 100644 --- a/analyzers/src/SonarAnalyzer.CSharp/Rules/TestMethodShouldContainAssertion.cs +++ b/analyzers/src/SonarAnalyzer.CSharp/Rules/TestMethodShouldContainAssertion.cs @@ -18,140 +18,138 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -namespace SonarAnalyzer.Rules.CSharp +namespace SonarAnalyzer.Rules.CSharp; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TestMethodShouldContainAssertion : SonarDiagnosticAnalyzer { - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public sealed class TestMethodShouldContainAssertion : SonarDiagnosticAnalyzer - { - internal const string DiagnosticId = "S2699"; - private const string MessageFormat = "Add at least one assertion to this test case."; - private const string CustomAssertionAttributeName = "AssertionMethodAttribute"; - private const int MaxInvocationDepth = 2; // Consider BFS instead of DFS if this gets increased + internal const string DiagnosticId = "S2699"; + private const string MessageFormat = "Add at least one assertion to this test case."; + private const string CustomAssertionAttributeName = "AssertionMethodAttribute"; + private const int MaxInvocationDepth = 2; // Consider BFS instead of DFS if this gets increased - private static readonly Dictionary KnownAssertions = new Dictionary - { - {"DidNotReceive", new[] {KnownType.NSubstitute_SubstituteExtensions}}, - {"DidNotReceiveWithAnyArgs", new[] {KnownType.NSubstitute_SubstituteExtensions}}, - {"Received", new[] {KnownType.NSubstitute_SubstituteExtensions, KnownType.NSubstitute_ReceivedExtensions_ReceivedExtensions}}, - {"ReceivedWithAnyArgs", new[] {KnownType.NSubstitute_SubstituteExtensions, KnownType.NSubstitute_ReceivedExtensions_ReceivedExtensions}}, - {"InOrder", new[] {KnownType.NSubstitute_Received}} - }; - - /// The assertions in the Shouldly library are supported by (they all contain "Should") - private static readonly ImmutableArray KnownAssertionTypes = ImmutableArray.Create( - KnownType.Microsoft_VisualStudio_TestTools_UnitTesting_Assert, - KnownType.NFluent_Check, - KnownType.NUnit_Framework_Assert, - KnownType.Xunit_Assert); - - private static readonly ImmutableArray KnownAsertionExceptionTypes = ImmutableArray.Create( - KnownType.Microsoft_VisualStudio_TestTools_UnitTesting_AssertFailedException, - KnownType.NFluent_FluentCheckException, - KnownType.NFluent_Kernel_FluentCheckException, - KnownType.NUnit_Framework_AssertionException, - KnownType.Xunit_Sdk_AssertException, - KnownType.Xunit_Sdk_XunitException); - - private static readonly DiagnosticDescriptor Rule = DescriptorFactory.Create(DiagnosticId, MessageFormat); - - public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); - - protected override void Initialize(SonarAnalysisContext context) => - context.RegisterNodeAction( - c => - { - var methodDeclaration = MethodDeclarationFactory.Create(c.Node); - if (!methodDeclaration.Identifier.IsMissing - && methodDeclaration.HasImplementation - && c.SemanticModel.GetDeclaredSymbol(c.Node) is IMethodSymbol methodSymbol - && IsTestMethod(methodSymbol, methodDeclaration.IsLocal) - && !methodSymbol.HasExpectedExceptionAttribute() - && !methodSymbol.HasAssertionInAttribute() - && !IsTestIgnored(methodSymbol) - && !ContainsAssertion(c.Node, c.SemanticModel, new HashSet(), 0)) - { - c.ReportIssue(Rule, methodDeclaration.Identifier); - } - }, - SyntaxKind.MethodDeclaration, - SyntaxKindEx.LocalFunctionStatement); - - // only xUnit allows local functions to be test methods. - private static bool IsTestMethod(IMethodSymbol symbol, bool isLocalFunction) => - isLocalFunction ? IsXunitTestMethod(symbol) : symbol.IsTestMethod(); - - private static bool IsXunitTestMethod(IMethodSymbol methodSymbol) => - methodSymbol.AnyAttributeDerivesFromAny(UnitTestHelper.KnownTestMethodAttributesOfxUnit); - - private static bool ContainsAssertion(SyntaxNode methodDeclaration, SemanticModel previousSemanticModel, ISet visitedSymbols, int level) - { - var currentSemanticModel = methodDeclaration.EnsureCorrectSemanticModelOrDefault(previousSemanticModel); - if (currentSemanticModel == null) + private static readonly Dictionary KnownAssertions = new() + { + {"DidNotReceive", [KnownType.NSubstitute_SubstituteExtensions] }, + {"DidNotReceiveWithAnyArgs", [KnownType.NSubstitute_SubstituteExtensions] }, + {"Received", [KnownType.NSubstitute_SubstituteExtensions, KnownType.NSubstitute_ReceivedExtensions_ReceivedExtensions] }, + {"ReceivedWithAnyArgs", [KnownType.NSubstitute_SubstituteExtensions, KnownType.NSubstitute_ReceivedExtensions_ReceivedExtensions] }, + {"InOrder", [KnownType.NSubstitute_Received] } + }; + + /// The assertions in the Shouldly and Moq libraries are supported by + /// - All assertions in Shouldly contain "Should" in their name. + /// - All assertions in Moq contain "Verify" in their name. + private static readonly ImmutableArray KnownAssertionTypes = ImmutableArray.Create( + KnownType.Microsoft_VisualStudio_TestTools_UnitTesting_Assert, + KnownType.NFluent_Check, + KnownType.NUnit_Framework_Assert, + KnownType.Xunit_Assert); + + private static readonly ImmutableArray KnownAssertionExceptionTypes = ImmutableArray.Create( + KnownType.Microsoft_VisualStudio_TestTools_UnitTesting_AssertFailedException, + KnownType.NFluent_FluentCheckException, + KnownType.NFluent_Kernel_FluentCheckException, + KnownType.NUnit_Framework_AssertionException, + KnownType.Xunit_Sdk_AssertException, + KnownType.Xunit_Sdk_XunitException); + + private static readonly DiagnosticDescriptor Rule = DescriptorFactory.Create(DiagnosticId, MessageFormat); + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + + protected override void Initialize(SonarAnalysisContext context) => + context.RegisterNodeAction( + c => { - return false; - } + var methodDeclaration = MethodDeclarationFactory.Create(c.Node); + if (!methodDeclaration.Identifier.IsMissing + && methodDeclaration.HasImplementation + && c.SemanticModel.GetDeclaredSymbol(c.Node) is IMethodSymbol methodSymbol + && IsTestMethod(methodSymbol, methodDeclaration.IsLocal) + && !methodSymbol.HasExpectedExceptionAttribute() + && !methodSymbol.HasAssertionInAttribute() + && !IsTestIgnored(methodSymbol) + && !ContainsAssertion(c.Node, c.SemanticModel, new HashSet(), 0)) + { + c.ReportIssue(Rule, methodDeclaration.Identifier); + } + }, + SyntaxKind.MethodDeclaration, + SyntaxKindEx.LocalFunctionStatement); - var descendantNodes = methodDeclaration.DescendantNodes(); - var invocations = descendantNodes.OfType().ToArray(); - if (invocations.Any(x => IsAssertion(x)) - || descendantNodes.OfType().Any(x => x.Expression != null && currentSemanticModel.GetTypeInfo(x.Expression).Type.DerivesFromAny(KnownAsertionExceptionTypes))) - { - return true; - } + // only xUnit allows local functions to be test methods. + private static bool IsTestMethod(IMethodSymbol symbol, bool isLocalFunction) => + isLocalFunction ? IsXunitTestMethod(symbol) : symbol.IsTestMethod(); - var invokedSymbols = invocations.Select(expression => currentSemanticModel.GetSymbolInfo(expression).Symbol).OfType(); - if (invokedSymbols.Any(symbol => IsKnownAssertion(symbol) || IsCustomAssertion(symbol))) - { - return true; - } + private static bool IsXunitTestMethod(IMethodSymbol methodSymbol) => + methodSymbol.AnyAttributeDerivesFromAny(UnitTestHelper.KnownTestMethodAttributesOfxUnit); - if (level == MaxInvocationDepth) - { - return false; - } + private static bool ContainsAssertion(SyntaxNode methodDeclaration, SemanticModel model, ISet visitedSymbols, int level) + { + var currentModel = methodDeclaration.EnsureCorrectSemanticModelOrDefault(model); + if (currentModel is null) + { + return false; + } - foreach (var symbol in invokedSymbols.Where(x => !visitedSymbols.Contains(x))) - { - visitedSymbols.Add(symbol); - foreach (var invokedDeclaration in symbol.DeclaringSyntaxReferences.Select(x => x.GetSyntax()).OfType()) - { - if (ContainsAssertion(invokedDeclaration, currentSemanticModel, visitedSymbols, level + 1)) - { - return true; - } - } - } + var descendantNodes = methodDeclaration.DescendantNodes(); + var invocations = descendantNodes.OfType().ToArray(); + if (Array.Exists(invocations, IsAssertion) + || descendantNodes.OfType().Any(x => x.Expression is not null && currentModel.GetTypeInfo(x.Expression).Type.DerivesFromAny(KnownAssertionExceptionTypes))) + { + return true; + } + + var invokedSymbols = invocations.Select(x => currentModel.GetSymbolInfo(x).Symbol).OfType(); + if (invokedSymbols.Any(x => IsKnownAssertion(x) || IsCustomAssertion(x))) + { + return true; + } + if (level == MaxInvocationDepth) + { return false; } - private static bool IsTestIgnored(IMethodSymbol method) + foreach (var symbol in invokedSymbols.Where(x => !visitedSymbols.Contains(x))) { - if (method.IsMsTestOrNUnitTestIgnored()) + visitedSymbols.Add(symbol); + if (symbol.DeclaringSyntaxReferences.Select(x => x.GetSyntax()).OfType().Any(x => ContainsAssertion(x, currentModel, visitedSymbols, level + 1))) { return true; } + } - // Checking whether an Xunit test is ignore or not needs to be done at the syntax level i.e. language-specific - var factAttributeSyntax = method.FindXUnitTestAttribute() - ?.ApplicationSyntaxReference.GetSyntax() as AttributeSyntax; + return false; + } - return factAttributeSyntax?.ArgumentList != null - && factAttributeSyntax.ArgumentList.Arguments.Any(x => x.NameEquals.Name.Identifier.ValueText == "Skip"); + private static bool IsTestIgnored(IMethodSymbol method) + { + if (method.IsMsTestOrNUnitTestIgnored()) + { + return true; } - private static bool IsAssertion(InvocationExpressionSyntax invocation) => - invocation.Expression - .ToString() - .SplitCamelCaseToWords() - .Intersect(UnitTestHelper.KnownAssertionMethodParts) - .Any(); + // Checking whether an Xunit test is ignore or not needs to be done at the syntax level i.e. language-specific + var factAttributeSyntax = method.FindXUnitTestAttribute() + ?.ApplicationSyntaxReference.GetSyntax() as AttributeSyntax; - private static bool IsKnownAssertion(ISymbol methodSymbol) => - (KnownAssertions.GetValueOrDefault(methodSymbol.Name) is { } types && types.Any(x => methodSymbol.ContainingType.ConstructedFrom.Is(x))) - || methodSymbol.ContainingType.DerivesFromAny(KnownAssertionTypes); - - private static bool IsCustomAssertion(ISymbol methodSymbol) => - methodSymbol.GetAttributesWithInherited().Any(x => x.AttributeClass.Name == CustomAssertionAttributeName); + return factAttributeSyntax?.ArgumentList is not null + && factAttributeSyntax.ArgumentList.Arguments.Any(x => x.NameEquals.Name.Identifier.ValueText == "Skip"); } + + private static bool IsAssertion(InvocationExpressionSyntax invocation) => + invocation.Expression + .ToString() + .SplitCamelCaseToWords() + .Intersect(UnitTestHelper.KnownAssertionMethodParts) + .Any(); + + private static bool IsKnownAssertion(ISymbol methodSymbol) => + (KnownAssertions.GetValueOrDefault(methodSymbol.Name) is { } types && Array.Exists(types, x => methodSymbol.ContainingType.ConstructedFrom.Is(x))) + || methodSymbol.ContainingType.DerivesFromAny(KnownAssertionTypes); + + private static bool IsCustomAssertion(ISymbol methodSymbol) => + methodSymbol.GetAttributesWithInherited().Any(x => x.AttributeClass.Name == CustomAssertionAttributeName); } diff --git a/analyzers/tests/SonarAnalyzer.Test/Rules/TestMethodShouldContainAssertionTest.cs b/analyzers/tests/SonarAnalyzer.Test/Rules/TestMethodShouldContainAssertionTest.cs index 1f5c0596460..c3602617181 100644 --- a/analyzers/tests/SonarAnalyzer.Test/Rules/TestMethodShouldContainAssertionTest.cs +++ b/analyzers/tests/SonarAnalyzer.Test/Rules/TestMethodShouldContainAssertionTest.cs @@ -100,34 +100,35 @@ public void TestMethodShouldContainAssertion_Xunit_Legacy() => [DataRow(NUnitVersions.Ver25, FluentAssertionVersions.Ver1)] [DataRow(NUnitVersions.Ver25, FluentAssertionVersions.Ver4)] public void TestMethodShouldContainAssertion_NUnit_FluentAssertionsLegacy(string testFwkVersion, string fluentVersion) => - WithTestReferences(NuGetMetadataReference.NUnit(testFwkVersion), fluentVersion).AddSnippet(@" -using System; -using FluentAssertions; -using NUnit.Framework; + WithTestReferences(NuGetMetadataReference.NUnit(testFwkVersion), fluentVersion).AddSnippet(""" + using System; + using FluentAssertions; + using NUnit.Framework; -[TestFixture] -public class Foo -{ - [Test] - public void Test1() // Noncompliant - { - var x = 42; - } + [TestFixture] + public class Foo + { + [Test] + public void Test1() // Noncompliant + { + var x = 42; + } - [Test] - public void ShouldThrowTest() - { - Action act = () => { throw new Exception(); }; - act.ShouldThrow(); - } + [Test] + public void ShouldThrowTest() + { + Action act = () => { throw new Exception(); }; + act.ShouldThrow(); + } - [Test] - public void ShouldNotThrowTest() - { - Action act = () => { throw new Exception(); }; - act.ShouldNotThrow(); - } -}").Verify(); + [Test] + public void ShouldNotThrowTest() + { + Action act = () => { throw new Exception(); }; + act.ShouldNotThrow(); + } + } + """).Verify(); [TestMethod] public void TestMethodShouldContainAssertion_NUnit_NFluentLegacy() => @@ -147,6 +148,10 @@ public void Test1() } """).VerifyNoIssues(); + [TestMethod] + public void TestMethodShouldContainAssertion_Moq() => + WithTestReferences(NuGetMetadataReference.MSTestTestFramework(Latest)).AddPaths("TestMethodShouldContainAssertion.Moq.cs").Verify(); + [TestMethod] public void TestMethodShouldContainAssertion_CustomAssertionMethod() => builder.AddPaths("TestMethodShouldContainAssertion.Custom.cs").AddReferences(NuGetMetadataReference.MSTestTestFramework(Latest)).Verify(); @@ -175,13 +180,15 @@ internal static VerifierBuilder WithTestReferences(IEnumerable + string shouldlyVersion = Latest, + string moqVersion = Latest) => new VerifierBuilder() .AddReferences(testFrameworkReference) .AddReferences(NuGetMetadataReference.FluentAssertions(fluentVersion)) .AddReferences(NuGetMetadataReference.NSubstitute(nSubstituteVersion)) .AddReferences(NuGetMetadataReference.NFluent(nFluentVersion)) .AddReferences(NuGetMetadataReference.Shouldly(shouldlyVersion)) + .AddReferences(NuGetMetadataReference.Moq(moqVersion)) .AddReferences(MetadataReferenceFacade.SystemData) .AddReferences(MetadataReferenceFacade.SystemNetHttp) .AddReferences(MetadataReferenceFacade.SystemXml) diff --git a/analyzers/tests/SonarAnalyzer.Test/TestCases/TestMethodShouldContainAssertion.Moq.cs b/analyzers/tests/SonarAnalyzer.Test/TestCases/TestMethodShouldContainAssertion.Moq.cs new file mode 100644 index 00000000000..b60820bdb9a --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/TestCases/TestMethodShouldContainAssertion.Moq.cs @@ -0,0 +1,79 @@ +namespace TestMoq +{ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Moq; + + public delegate void CalculatorEvent(int i, bool b); + internal interface ICalculator + { + int Property { get; set; } + event CalculatorEvent Event; + int Add(int a, int b); + } + + [TestClass] + public class MoqVerifyTests + { + [TestMethod] + public void MoqVerify() + { + var mock = new Mock(); + + mock.Verify(x => x.Add(It.IsAny(), It.IsAny()), Times.Never()); + } + + [TestMethod] + public void MoqVerifyAdd() + { + var mock = new Mock(); + + mock.VerifyAdd(x => x.Event += It.IsAny()); + } + + [TestMethod] + public void MoqVerifyRemove() + { + var mock = new Mock(); + + mock.VerifyRemove(x => x.Event -= It.IsAny(), Times.AtMostOnce()); + } + + [TestMethod] + public void MoqVerifySet() + { + var mock = new Mock(); + + mock.VerifySet(x => x.Property = It.IsAny()); + } + + [TestMethod] + public void MoqVerifyGet() + { + var mock = new Mock(); + + mock.VerifyGet(x => x.Property); + } + + [TestMethod] + public void MoqVerifyNoOtherCalls() + { + var mock = new Mock(); + + mock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void MoqVerifyAll() + { + var mock = new Mock(); + + mock.VerifyAll(); + } + + [TestMethod] + public void MoqNoVerify() // Noncompliant + { + var mock = new Mock(); + } + } +}