diff --git a/analyzers/rspec/cs/S2970_c#.html b/analyzers/rspec/cs/S2970_c#.html
new file mode 100644
index 00000000000..7a5d5177ce6
--- /dev/null
+++ b/analyzers/rspec/cs/S2970_c#.html
@@ -0,0 +1,29 @@
+
It is very easy to write incomplete assertions when using some test frameworks. This rule enforces complete assertions in the following cases:
+
+ - Fluent Assertions:
Should()
is not followed by an assertion invocation.
+
+ - NFluent:
Check.That()
is not followed by an assertion invocation.
+ - NSubstitute:
Received()
is not followed by an invocation.
+
+In such cases, what is intended to be a test doesn’t actually verify anything.
+Noncompliant Code Example
+
+string actual = "Hello World!";
+// Fluent Assertions
+actual.Should(); // Noncompliant
+// NFluent
+Check.That(actual); // Noncompliant
+// NSubstitute
+command.Received(); // Noncompliant
+
+Compliant Solution
+
+string actual = "Hello World!";
+// Fluent Assertions
+actual.Should().Contain("Hello");
+// NFluent
+Check.That(actual).Contains("Hello");
+// NSubstitute
+command.Received().Execute();
+
+
diff --git a/analyzers/rspec/cs/S2970_c#.json b/analyzers/rspec/cs/S2970_c#.json
new file mode 100644
index 00000000000..9cd57fbfeb4
--- /dev/null
+++ b/analyzers/rspec/cs/S2970_c#.json
@@ -0,0 +1,17 @@
+{
+ "title": "Assertions should be complete",
+ "type": "CODE_SMELL",
+ "status": "ready",
+ "remediation": {
+ "func": "Constant\/Issue",
+ "constantCost": "5min"
+ },
+ "tags": [
+ "tests"
+ ],
+ "defaultSeverity": "Blocker",
+ "ruleSpecification": "RSPEC-2970",
+ "sqKey": "S2970",
+ "scope": "Tests",
+ "quickfix": "unknown"
+}
diff --git a/analyzers/rspec/cs/Sonar_way_profile.json b/analyzers/rspec/cs/Sonar_way_profile.json
index 2b7c15c5eb5..76e9af503d7 100644
--- a/analyzers/rspec/cs/Sonar_way_profile.json
+++ b/analyzers/rspec/cs/Sonar_way_profile.json
@@ -119,6 +119,7 @@
"S2933",
"S2934",
"S2953",
+ "S2970",
"S2971",
"S2995",
"S2996",
diff --git a/analyzers/src/SonarAnalyzer.CSharp/Helpers/CSharpSyntaxHelper.cs b/analyzers/src/SonarAnalyzer.CSharp/Helpers/CSharpSyntaxHelper.cs
index 8b30ece4ad5..4dd747bae43 100644
--- a/analyzers/src/SonarAnalyzer.CSharp/Helpers/CSharpSyntaxHelper.cs
+++ b/analyzers/src/SonarAnalyzer.CSharp/Helpers/CSharpSyntaxHelper.cs
@@ -280,6 +280,11 @@ node switch
public static bool NameIs(this SyntaxNode node, string name) =>
node.GetName().Equals(name, StringComparison.InvariantCulture);
+ public static bool NameIs(this SyntaxNode node, string name, params string[] orNames) =>
+ node.GetName() is { } nodeName
+ && (nodeName.Equals(name, StringComparison.InvariantCulture)
+ || orNames.Any(x => nodeName.Equals(x, StringComparison.InvariantCulture)));
+
public static bool HasConstantValue(this ExpressionSyntax expression, SemanticModel semanticModel) =>
expression.RemoveParentheses().IsAnyKind(LiteralSyntaxKinds) || expression.FindConstantValue(semanticModel) != null;
diff --git a/analyzers/src/SonarAnalyzer.CSharp/Rules/AssertionsShouldBeComplete.cs b/analyzers/src/SonarAnalyzer.CSharp/Rules/AssertionsShouldBeComplete.cs
new file mode 100644
index 00000000000..df4bcc22984
--- /dev/null
+++ b/analyzers/src/SonarAnalyzer.CSharp/Rules/AssertionsShouldBeComplete.cs
@@ -0,0 +1,131 @@
+/*
+ * SonarAnalyzer for .NET
+ * Copyright (C) 2015-2023 SonarSource SA
+ * mailto: contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace SonarAnalyzer.Rules.CSharp;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class AssertionsShouldBeComplete : SonarDiagnosticAnalyzer
+{
+ private const string DiagnosticId = "S2970";
+ private const string MessageFormat = "Complete the assertion";
+
+ private static readonly DiagnosticDescriptor Rule = DescriptorFactory.Create(DiagnosticId, MessageFormat);
+
+ public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule);
+
+ protected override void Initialize(SonarAnalysisContext context) =>
+ context.RegisterCompilationStartAction(start =>
+ {
+ if (start.Compilation.References(KnownAssembly.FluentAssertions))
+ {
+ start.RegisterNodeAction(c =>
+ CheckInvocation(c, invocation =>
+ invocation.NameIs("Should")
+ && c.SemanticModel.GetSymbolInfo(invocation).AllSymbols().Any(x =>
+ x is IMethodSymbol
+ {
+ IsExtensionMethod: true,
+ ReturnsVoid: false,
+ ContainingType: { } container,
+ ReturnType: { } returnType,
+ }
+ && (container.Is(KnownType.FluentAssertions_AssertionExtensions)
+ // ⬆️ Built in assertions. ⬇️ Custom assertions (the majority at least).
+ || returnType.DerivesFrom(KnownType.FluentAssertions_Primitives_ReferenceTypeAssertions)))),
+ SyntaxKind.InvocationExpression);
+ }
+ if (start.Compilation.References(KnownAssembly.NFluent))
+ {
+ start.RegisterNodeAction(c =>
+ CheckInvocation(c, invocation =>
+ invocation.NameIs("That", "ThatEnum", "ThatCode", "ThatAsyncCode", "ThatDynamic")
+ && c.SemanticModel.GetSymbolInfo(invocation) is
+ {
+ Symbol: IMethodSymbol
+ {
+ IsStatic: true,
+ ReturnsVoid: false,
+ ContainingType: { IsStatic: true } container
+ }
+ }
+ && container.Is(KnownType.NFluent_Check)),
+ SyntaxKind.InvocationExpression);
+ }
+ if (start.Compilation.References(KnownAssembly.NSubstitute))
+ {
+ start.RegisterNodeAction(c =>
+ CheckInvocation(c, invocation =>
+ invocation.NameIs("Received", "DidNotReceive", "ReceivedWithAnyArgs", "DidNotReceiveWithAnyArgs", "ReceivedCalls")
+ && c.SemanticModel.GetSymbolInfo(invocation) is
+ {
+ Symbol: IMethodSymbol
+ {
+ IsExtensionMethod: true,
+ ReturnsVoid: false,
+ ContainingType: { } container,
+ }
+ }
+ && container.Is(KnownType.NSubstitute_SubstituteExtensions)),
+ SyntaxKind.InvocationExpression);
+ }
+ });
+
+ private static void CheckInvocation(SonarSyntaxNodeReportingContext c, Func isAssertionMethod)
+ {
+ if (c.Node is InvocationExpressionSyntax invocation
+ && isAssertionMethod(invocation)
+ && !HasContinuation(invocation))
+ {
+ c.ReportIssue(Diagnostic.Create(Rule, invocation.GetIdentifier()?.GetLocation()));
+ }
+ }
+
+ private static bool HasContinuation(InvocationExpressionSyntax invocation)
+ {
+ var closeParen = invocation.ArgumentList.CloseParenToken;
+ if (!closeParen.IsKind(SyntaxKind.CloseParenToken) || closeParen.IsMissing || !invocation.GetLastToken().Equals(closeParen))
+ {
+ // Any invocation should end with ")". We are in unknown territory here.
+ return true;
+ }
+ if (closeParen.GetNextToken() is var nextToken
+ && !nextToken.IsKind(SyntaxKind.SemicolonToken))
+ {
+ // There is something right to the invocation that is not a semicolon.
+ return true;
+ }
+ // We are in some kind of statement context "??? Should();"
+ // The result might be stored in a variable or returned from the method/property
+ return nextToken.Parent switch
+ {
+ MethodDeclarationSyntax { ReturnType: { } returnType } => !IsVoid(returnType),
+ { } parent when LocalFunctionStatementSyntaxWrapper.IsInstance(parent) => !IsVoid(((LocalFunctionStatementSyntaxWrapper)parent).ReturnType),
+ PropertyDeclarationSyntax => true,
+ AccessorDeclarationSyntax { Keyword.RawKind: (int)SyntaxKind.GetKeyword } => true,
+ ReturnStatementSyntax => true,
+ LocalDeclarationStatementSyntax => true,
+ ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax } => true,
+ _ => false,
+ };
+ }
+
+ private static bool IsVoid(TypeSyntax type) =>
+ type is PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.VoidKeyword };
+}
diff --git a/analyzers/src/SonarAnalyzer.Common/Extensions/SymbolInfoExtensions.cs b/analyzers/src/SonarAnalyzer.Common/Extensions/SymbolInfoExtensions.cs
new file mode 100644
index 00000000000..f667d11e8ec
--- /dev/null
+++ b/analyzers/src/SonarAnalyzer.Common/Extensions/SymbolInfoExtensions.cs
@@ -0,0 +1,33 @@
+/*
+ * SonarAnalyzer for .NET
+ * Copyright (C) 2015-2023 SonarSource SA
+ * mailto: contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace SonarAnalyzer.Extensions
+{
+ public static class SymbolInfoExtensions
+ {
+ ///
+ /// Returns the or if no symbol could be found the .
+ ///
+ public static IEnumerable AllSymbols(this SymbolInfo symbolInfo) =>
+ symbolInfo.Symbol == null
+ ? symbolInfo.CandidateSymbols
+ : new[] { symbolInfo.Symbol };
+ }
+}
diff --git a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownAssembly.cs b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownAssembly.cs
index d7f085bcefd..79b689de1c3 100644
--- a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownAssembly.cs
+++ b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownAssembly.cs
@@ -30,6 +30,19 @@ public sealed partial class KnownAssembly
And(NameIs("xunit.assert").Or(NameIs("xunit").And(VersionLowerThen("2.0"))),
PublicKeyTokenIs("8d05b1bb7a6fdb6c")));
+ ///
+ /// Any MSTest framework either referenced via
+ /// nuget.org/MicrosoftVisualStudioQualityToolsUnitTestFramework (MSTest V1)
+ /// or nuget.org/MSTest.TestFramework (MSTest V2).
+ ///
+ public static KnownAssembly MSTest { get; } =
+ new(And(NameIs("Microsoft.VisualStudio.QualityTools.UnitTestFramework").Or(NameIs("Microsoft.VisualStudio.TestPlatform.TestFramework")),
+ PublicKeyTokenIs("b03f5f7f11d50a3a")));
+
+ public static KnownAssembly FluentAssertions { get; } = new(NameIs("FluentAssertions").And(PublicKeyTokenIs("33f2691a05b67b6a")));
+ public static KnownAssembly NFluent { get; } = new(NameIs("NFluent").And(OptionalPublicKeyTokenIs("18828b37b84b1437")));
+ public static KnownAssembly NSubstitute { get; } = new(NameIs("NSubstitute").And(PublicKeyTokenIs("92dd2e9066daa5ca")));
+
internal KnownAssembly(Func predicate, params Func[] or)
: this(predicate is null || or.Any(x => x is null)
? throw new ArgumentNullException(nameof(predicate), "All predicates must be non-null.")
diff --git a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs
index 7b0c7b39c21..9eaa2c8dccb 100644
--- a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs
+++ b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs
@@ -37,7 +37,9 @@ public sealed partial class KnownType
public static readonly KnownType Azure_ResourceManager_ArmClient = new("Azure.ResourceManager.ArmClient");
public static readonly KnownType Dapper_SqlMapper = new("Dapper.SqlMapper");
public static readonly KnownType Dapper_CommandDefinition = new("Dapper.CommandDefinition");
+ public static readonly KnownType FluentAssertions_AssertionExtensions = new("FluentAssertions.AssertionExtensions");
public static readonly KnownType FluentAssertions_Execution_AssertionScope = new("FluentAssertions.Execution.AssertionScope");
+ public static readonly KnownType FluentAssertions_Primitives_ReferenceTypeAssertions = new("FluentAssertions.Primitives.ReferenceTypeAssertions", "TSubject", "TAssertions");
public static readonly KnownType JWT_Builder_JwtBuilder = new("JWT.Builder.JwtBuilder");
public static readonly KnownType JWT_IJwtDecoder = new("JWT.IJwtDecoder");
public static readonly KnownType JWT_JwtDecoderExtensions = new("JWT.JwtDecoderExtensions");
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/KnownAssemblyTest.cs b/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/KnownAssemblyTest.cs
index 9170933ef03..c128ff60764 100644
--- a/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/KnownAssemblyTest.cs
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/KnownAssemblyTest.cs
@@ -275,6 +275,84 @@ public void XUnitAssert_NoReference()
compilation.References(XUnit_Assert).Should().BeFalse();
}
+ [TestMethod]
+ public void MSTest_V1()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.MSTestTestFrameworkV1.ToArray()).Model.Compilation;
+ compilation.References(MSTest).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void MSTest_V2()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.MSTestTestFramework("3.0.2").ToArray()).Model.Compilation;
+ compilation.References(MSTest).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void MSTest_MicrosoftVisualStudioQualityToolsUnitTestFramework()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.MicrosoftVisualStudioQualityToolsUnitTestFramework.ToArray()).Model.Compilation;
+ compilation.References(MSTest).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void MSTest_NoReference()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file").Model.Compilation;
+ compilation.References(MSTest).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void FluentAssertions_6_10()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.FluentAssertions("6.10.0").ToArray()).Model.Compilation;
+ compilation.References(KnownAssembly.FluentAssertions).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void FluentAssertions_NoReference()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file").Model.Compilation;
+ compilation.References(KnownAssembly.FluentAssertions).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void NFluent_2_8()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.NFluent("2.8.0").ToArray()).Model.Compilation;
+ compilation.References(NFluent).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void NFluent_1_0()
+ {
+ // 1.0.0 has no publicKeyToken
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.NFluent("1.0.0").ToArray()).Model.Compilation;
+ compilation.References(NFluent).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void NFluent_NoReference()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file").Model.Compilation;
+ compilation.References(NFluent).Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void NSubstitute_5_0()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file", NuGetMetadataReference.NSubstitute("5.0.0").ToArray()).Model.Compilation;
+ compilation.References(NSubstitute).Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void NSubstitute_NoReference()
+ {
+ var compilation = TestHelper.CompileCS("// Empty file").Model.Compilation;
+ compilation.References(NSubstitute).Should().BeFalse();
+ }
+
private static Mock CompilationWithReferenceTo(AssemblyIdentity identity)
{
var compilation = new Mock("compilationName", ImmutableArray.Empty, new Dictionary(), false, null, null);
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/SyntaxHelperTest.cs b/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/SyntaxHelperTest.cs
index 6bd0e120890..d61c17da915 100644
--- a/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/SyntaxHelperTest.cs
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/Helpers/SyntaxHelperTest.cs
@@ -137,6 +137,25 @@ public void NameIs_CS()
toString.NameIs(null).Should().BeFalse();
}
+ [DataTestMethod]
+ [DataRow(true, "Test")]
+ [DataRow(true, "Test", "Test")]
+ [DataRow(true, "Other", "Test")]
+ [DataRow(false)]
+ [DataRow(false, "TEST")]
+ public void NameIsOrNames_CS(bool expected, params string[] orNames)
+ {
+ var identifier = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.IdentifierName("Test");
+ identifier.NameIs("other", orNames).Should().Be(expected);
+ }
+
+ [TestMethod]
+ public void NameIsOrNamesNodeWithoutName_CS()
+ {
+ var returnStatement = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ReturnStatement();
+ returnStatement.NameIs("A", "B", "C").Should().BeFalse();
+ }
+
[TestMethod]
public void NameIs_VB()
{
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/PackagingTests/RuleTypeMappingCS.cs b/analyzers/tests/SonarAnalyzer.UnitTest/PackagingTests/RuleTypeMappingCS.cs
index 4dc49032b0f..03ae2298a3f 100644
--- a/analyzers/tests/SonarAnalyzer.UnitTest/PackagingTests/RuleTypeMappingCS.cs
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/PackagingTests/RuleTypeMappingCS.cs
@@ -2894,7 +2894,7 @@ internal static class RuleTypeMappingCS
// ["S2967"],
// ["S2968"],
// ["S2969"],
- // ["S2970"],
+ ["S2970"] = "CODE_SMELL",
["S2971"] = "CODE_SMELL",
// ["S2972"],
// ["S2973"],
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/Rules/AssertionsShouldBeCompleteTest.cs b/analyzers/tests/SonarAnalyzer.UnitTest/Rules/AssertionsShouldBeCompleteTest.cs
new file mode 100644
index 00000000000..3230246c6a2
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/Rules/AssertionsShouldBeCompleteTest.cs
@@ -0,0 +1,109 @@
+/*
+ * SonarAnalyzer for .NET
+ * Copyright (C) 2015-2023 SonarSource SA
+ * mailto: contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+using SonarAnalyzer.Rules.CSharp;
+using SonarAnalyzer.UnitTest.MetadataReferences;
+
+namespace SonarAnalyzer.UnitTest.Rules;
+
+[TestClass]
+public class AssertionsShouldBeCompleteTest
+{
+ private readonly VerifierBuilder fluentAssertions = new VerifierBuilder()
+ .AddReferences(NuGetMetadataReference.FluentAssertions("6.10.0"))
+ .AddReferences(MetadataReferenceFacade.SystemXml)
+ .AddReferences(MetadataReferenceFacade.SystemXmlLinq)
+ .AddReferences(MetadataReferenceFacade.SystemNetHttp)
+ .AddReferences(MetadataReferenceFacade.SystemData);
+
+ private readonly VerifierBuilder nfluent = new VerifierBuilder()
+ .AddReferences(NuGetMetadataReference.NFluent("2.8.0"));
+
+ private readonly VerifierBuilder nsubstitute = new VerifierBuilder()
+ .AddReferences(NuGetMetadataReference.NSubstitute("5.0.0"));
+
+ private readonly VerifierBuilder allFrameworks;
+
+ public AssertionsShouldBeCompleteTest()
+ {
+ allFrameworks = new VerifierBuilder()
+ .AddReferences(fluentAssertions.References)
+ .AddReferences(nfluent.References)
+ .AddReferences(nsubstitute.References);
+ }
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_FluentAssertions_CSharp7() =>
+ fluentAssertions
+ .WithOptions(ParseOptionsHelper.OnlyCSharp7)
+ .AddPaths("AssertionsShouldBeComplete.FluentAssertions.CSharp7.cs")
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_FluentAssertions_MissingParen() =>
+ fluentAssertions
+ .AddSnippet("""
+ using FluentAssertions;
+ public class Test
+ {
+ public void MissingParen()
+ {
+ var s = "Test";
+ s.Should(; // Error
+ }
+ }
+ """)
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_FluentAssertions_CSharp8() =>
+ fluentAssertions
+ .WithOptions(ParseOptionsHelper.FromCSharp8)
+ .AddPaths("AssertionsShouldBeComplete.FluentAssertions.CSharp8.cs")
+ .WithConcurrentAnalysis(false)
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_NFluent_CSharp() =>
+ nfluent
+ .AddTestReference()
+ .AddPaths("AssertionsShouldBeComplete.NFluent.cs")
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_NFluent_CSharp11() =>
+ nfluent
+ .WithOptions(ParseOptionsHelper.FromCSharp11)
+ .AddTestReference()
+ .AddPaths("AssertionsShouldBeComplete.NFluent.CSharp11.cs")
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_NSubstitute_CS() =>
+ nsubstitute
+ .AddPaths("AssertionsShouldBeComplete.NSubstitute.cs")
+ .Verify();
+
+ [TestMethod]
+ public void AssertionsShouldBeComplete_AllFrameworks_CS() =>
+ allFrameworks
+ .AddPaths("AssertionsShouldBeComplete.AllFrameworks.cs")
+ .Verify();
+}
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.AllFrameworks.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.AllFrameworks.cs
new file mode 100644
index 00000000000..a213fa5c305
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.AllFrameworks.cs
@@ -0,0 +1,25 @@
+using FluentAssertions;
+using NFluent;
+using NSubstitute;
+using System;
+
+namespace AllFrameworksTests
+{
+ internal class Tests
+ {
+ public void FluentAssertions(string s)
+ {
+ s.Should(); // Noncompliant
+ }
+
+ public void NFluent()
+ {
+ Check.That(0); // Noncompliant
+ }
+
+ public void NSubstitute(IComparable comparable)
+ {
+ comparable.Received(); // Noncompliant
+ }
+ }
+}
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp7.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp7.cs
new file mode 100644
index 00000000000..2d1c422f4a9
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp7.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using FluentAssertions;
+using FluentAssertions.Primitives;
+
+public class Program
+{
+ public void StringAssertions()
+ {
+ var s = "Test";
+ s.Should(); // Noncompliant {{Complete the assertion}}
+ //^^^^^^
+ s[0].Should(); // Error [CS0121] ambiguous calls
+ // Noncompliant@-1
+
+ s.Should().Be("Test"); // Compliant
+ }
+
+ public void CollectionAssertions()
+ {
+ var collection = new[] { "Test", "Test" };
+ collection.Should(); // Error [CS0121] ambiguous calls
+ // Noncompliant@-1
+ collection.Should(); // Noncompliant
+
+ collection.Should().Equal("Test", "Test"); // Compliant
+ }
+
+ public void DictionaryAssertions()
+ {
+ var dict = new Dictionary();
+ dict["A"].Should(); // Noncompliant
+ }
+
+ public StringAssertions ReturnedByReturn()
+ {
+ var s = "Test";
+ return s.Should(); // Compliant
+ }
+
+ public StringAssertions ReturnedByArrow(string s) =>
+ s.Should(); // Compliant
+
+ public object ReturnedByArrowWithConversion(string s) =>
+ (object)s.Should(); // Compliant
+
+ public void CalledByArrow(string s) =>
+ s.Should(); // Noncompliant
+
+ public void Assigned()
+ {
+ var s = "Test";
+ var assertion = s.Should(); // Compliant
+ assertion = s.Should(); // Compliant
+ }
+
+ public void PassedAsArgument()
+ {
+ var s = "Test";
+ ValidateString(s.Should()); // Compliant
+ }
+ private void ValidateString(StringAssertions assertion) { }
+
+ public void UnreducedCall()
+ {
+ var s = "Test";
+ FluentAssertions.AssertionExtensions.Should(s); // Noncompliant
+ }
+
+ public void ReturnedInLambda()
+ {
+ Func a = () => "Test".Should();
+ }
+
+ public void CustomAssertions()
+ {
+ var custom = new Custom();
+ custom.Should(); // Noncompliant The custom assertion derives from ReferenceTypeAssertions
+ custom.Should().BeCustomAsserted(); // Compliant
+ }
+
+ public void CustomStructAssertions()
+ {
+ var custom = new CustomStruct();
+ custom.Should(); // Compliant Potential FN. CustomStructAssertion does not derive from ReferenceTypeAssertions and is not considered a custom validation.
+ custom.Should().BeCustomAsserted(); // Compliant
+ }
+}
+
+public static class CustomAssertionExtension
+{
+ public static CustomAssertion Should(this Custom instance)
+ => new CustomAssertion(instance);
+}
+
+public class CustomAssertion : ReferenceTypeAssertions
+{
+ public CustomAssertion(Custom instance)
+ : base(instance)
+ {
+ }
+
+ protected override string Identifier => "custom";
+
+ public void BeCustomAsserted()
+ {
+ // Not implemented
+ }
+}
+
+public class Custom { }
+
+public static class CustomStructAssertionExtension
+{
+ public static CustomStructAssertion Should(this CustomStruct instance)
+ => new CustomStructAssertion(instance);
+}
+
+public class CustomStructAssertion // Does not derive from ReferenceTypeAssertions
+{
+ public CustomStructAssertion(CustomStruct instance) { }
+
+ public void BeCustomAsserted()
+ {
+ // Not implemented
+ }
+}
+
+public struct CustomStruct { }
+
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp8.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp8.cs
new file mode 100644
index 00000000000..fda6f1dafab
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.FluentAssertions.CSharp8.cs
@@ -0,0 +1,70 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using FluentAssertions;
+using FluentAssertions.Primitives;
+
+public class Program
+{
+ public void StringAssertions()
+ {
+ var s = "Test";
+ s.Should(); // Noncompliant
+ s?.Should(); // Noncompliant
+ s[0].Should(); // Noncompliant (no "ambiguous calls" compiler error as in C# 7)
+ s?[0].Should(); // Noncompliant
+ s.Should().Be("Test"); // Compliant
+ s.Should()?.Be("Test"); // Compliant
+ s.Should()!?.Be("Test"); // Compliant
+ }
+
+ public void CollectionAssertions()
+ {
+ var collection = new[] { "Test", "Test" };
+ collection.Should(); // Noncompliant (no "ambiguous calls" compiler error as in C# 7)
+ }
+
+ public void DictAssertions()
+ {
+ var dict = new Dictionary();
+ dict["A"]?.Should(); // Noncompliant
+ // ^^^^^^
+ dict?["A"]?.Should(); // Noncompliant
+ // ^^^^^^
+ dict?["A"]!.Should(); // Noncompliant
+ // ^^^^^^
+ }
+
+ public void LocalFunction()
+ {
+ var s = "Test";
+
+ StringAssertions ExpressionBodyLocalFunction() =>
+ s.Should();
+
+ void VoidReturningExpressionBodyLocalFunction() =>
+ s.Should(); // Noncompliant
+
+ StringAssertions ReturnLocalFunction()
+ {
+ return s.Should();
+ }
+ }
+
+ public StringAssertions PropertyArrow => "Test".Should();
+
+ public StringAssertions PropertyGetterArrow
+ {
+ get => "Test".Should();
+ set => "Test".Should(); // Noncompliant
+ }
+
+ public StringAssertions PropertyGetterReturn
+ {
+ get
+ {
+ return "Test".Should();
+ }
+ }
+}
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.CSharp11.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.CSharp11.cs
new file mode 100644
index 00000000000..6ffb58c0166
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.CSharp11.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading.Tasks;
+using NFluent;
+using static NFluent.Check;
+
+public class Program
+{
+ public void CheckThat()
+ {
+ That(0); // Noncompliant {{Complete the assertion}}
+ // ^^^^
+ That(); // Noncompliant
+
+ That(1).IsEqualTo(1);
+ }
+}
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.cs
new file mode 100644
index 00000000000..2d2a904e6d4
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NFluent.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Threading.Tasks;
+using NFluent;
+
+public class Program
+{
+ public void CheckThat()
+ {
+ Check.That(0); // Noncompliant {{Complete the assertion}}
+ // ^^^^
+ Check.That(); // Noncompliant
+
+ Check.That(1).IsEqualTo(1);
+ }
+
+ public void CheckThatEnum()
+ {
+ Check.ThatEnum(AttributeTargets.All); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^
+ Check.ThatEnum(AttributeTargets.All); // Noncompliant
+
+ Check.ThatEnum(AttributeTargets.All).IsEqualTo(AttributeTargets.All);
+ }
+
+ public void CheckThatCode()
+ {
+ Check.ThatCode(() => { }); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^
+ Check.ThatCode(() => 1); // Noncompliant
+ Check.ThatCode(CheckThatCode); // Noncompliant
+
+ Check.ThatCode(() => { }).DoesNotThrow();
+ }
+
+ public async Task CheckThatAsyncCode()
+ {
+ Check.ThatAsyncCode(async () => await Task.CompletedTask); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^^^
+ Check.ThatAsyncCode(async () => await Task.FromResult(1)); // Noncompliant
+ Check.ThatAsyncCode(CheckThatAsyncCode); // Noncompliant
+
+ Check.ThatAsyncCode(async () => await Task.CompletedTask).DoesNotThrow();
+ }
+
+ public async Task CheckThatDynamic(dynamic expando)
+ {
+ Check.ThatDynamic(1); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^
+ Check.ThatDynamic(expando); // Noncompliant
+
+ Check.ThatDynamic(1).IsNotNull();
+ }
+
+ public ICheck CheckReturnedByReturn()
+ {
+ return Check.That(1);
+ }
+
+ public ICheck CheckReturnedByExpressionBody() =>
+ Check.That(1);
+
+ public void AnonymousInvocation(Func a) =>
+ a()();
+}
+
+namespace OtherCheck
+{
+ public static class Check
+ {
+ public static void That(int i) { }
+ }
+
+ public class Test
+ {
+ public void CheckThat()
+ {
+ Check.That(1); // Compliant. Not NFluent
+ }
+ }
+}
diff --git a/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NSubstitute.cs b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NSubstitute.cs
new file mode 100644
index 00000000000..8952f033795
--- /dev/null
+++ b/analyzers/tests/SonarAnalyzer.UnitTest/TestCases/AssertionsShouldBeComplete.NSubstitute.cs
@@ -0,0 +1,81 @@
+using NSubstitute;
+
+public interface ICommand
+{
+ void Execute();
+}
+
+namespace NSubstituteTests
+{
+ internal class Tests
+ {
+ public void Received()
+ {
+ var command = Substitute.For();
+ command.Received(); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^
+ command.Received(requiredNumberOfCalls: 2); // Noncompliant
+ command.Received(); // Noncompliant
+ SubstituteExtensions.Received(command); // Noncompliant
+
+ command.Received().Execute();
+ }
+
+ public void DidNotReceive()
+ {
+ var command = Substitute.For();
+ command.DidNotReceive(); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^^^
+ command.DidNotReceive(); // Noncompliant
+
+ command.DidNotReceive().Execute();
+ }
+
+ public void ReceivedWithAnyArgs()
+ {
+ var command = Substitute.For();
+ command.ReceivedWithAnyArgs(); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^^^^^^^^^
+ command.ReceivedWithAnyArgs(requiredNumberOfCalls: 2); // Noncompliant
+ command.ReceivedWithAnyArgs(); // Noncompliant
+
+ command.ReceivedWithAnyArgs().Execute();
+ }
+
+ public void DidNotReceiveWithAnyArgs()
+ {
+ var command = Substitute.For();
+ command.DidNotReceiveWithAnyArgs(); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^^^^^^^^^^^^^^
+ command.DidNotReceiveWithAnyArgs(); // Noncompliant
+
+ command.DidNotReceiveWithAnyArgs().Execute();
+ }
+
+ public void ReceivedCalls()
+ {
+ var command = Substitute.For();
+ command.ReceivedCalls(); // Noncompliant {{Complete the assertion}}
+ // ^^^^^^^^^^^^^
+ command.ReceivedCalls(); // Noncompliant
+
+ var calls = command.ReceivedCalls();
+ }
+ }
+}
+
+namespace OtherReceived
+{
+ public static class OtherExtensions
+ {
+ public static void Received(this T something) { }
+ }
+
+ public class Test
+ {
+ public void Received(ICommand command)
+ {
+ command.Received(); // Compliant. Other Received call
+ }
+ }
+}