Skip to content

Commit 1b60fa7

Browse files
SONARPY-765 Rule S5915: Assertions should not be made at the end of blocks expecting an exception (#1180)
1 parent b2cba26 commit 1b60fa7

File tree

13 files changed

+497
-7
lines changed

13 files changed

+497
-7
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
'project:docker-compose-1.24.1/tests/unit/config/config_test.py':[
3+
641,
4+
1482,
5+
],
6+
'project:tensorflow/python/data/experimental/kernel_tests/take_while_test.py':[
7+
95,
8+
],
9+
'project:tensorflow/python/debug/cli/command_parser_test.py':[
10+
261,
11+
265,
12+
269,
13+
],
14+
'project:tensorflow/python/debug/lib/session_debug_grpc_test.py':[
15+
311,
16+
697,
17+
],
18+
'project:tensorflow/python/eager/device_placement_test.py':[
19+
168,
20+
],
21+
'project:tensorflow/python/eager/tensor_test.py':[
22+
215,
23+
],
24+
'project:tensorflow/python/feature_column/feature_column_v2_test.py':[
25+
293,
26+
],
27+
'project:tensorflow/python/framework/importer_test.py':[
28+
491,
29+
],
30+
'project:tensorflow/python/training/monitored_session_test.py':[
31+
1979,
32+
2008,
33+
2243,
34+
],
35+
'project:tensorflow/python/util/deprecation_test.py':[
36+
941,
37+
],
38+
'project:tensorflow/python/util/keyword_args_test.py':[
39+
43,
40+
48,
41+
],
42+
}

python-checks/src/main/java/org/sonar/python/checks/CheckList.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
import org.sonar.python.checks.regex.UnquantifiedNonCapturingGroupCheck;
7676
import org.sonar.python.checks.regex.VerboseRegexCheck;
7777
import org.sonar.python.checks.tests.AssertOnDissimilarTypesCheck;
78+
import org.sonar.python.checks.tests.AssertAfterRaiseCheck;
7879
import org.sonar.python.checks.tests.AssertOnTupleLiteralCheck;
7980
import org.sonar.python.checks.tests.DedicatedAssertionCheck;
8081
import org.sonar.python.checks.tests.ImplicitlySkippedTestCheck;
@@ -97,6 +98,7 @@ public static Iterable<Class> getChecks() {
9798
ArgumentNumberCheck.class,
9899
ArgumentTypeCheck.class,
99100
AssertOnDissimilarTypesCheck.class,
101+
AssertAfterRaiseCheck.class,
100102
AssertOnTupleLiteralCheck.class,
101103
BackslashInStringCheck.class,
102104
BackticksUsageCheck.class,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2022 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks.tests;
21+
22+
import java.util.List;
23+
import java.util.Objects;
24+
import java.util.Optional;
25+
import org.sonar.check.Rule;
26+
import org.sonar.plugins.python.api.IssueLocation;
27+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
28+
import org.sonar.plugins.python.api.symbols.Symbol;
29+
import org.sonar.plugins.python.api.tree.CallExpression;
30+
import org.sonar.plugins.python.api.tree.ExpressionStatement;
31+
import org.sonar.plugins.python.api.tree.Name;
32+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
33+
import org.sonar.plugins.python.api.tree.RegularArgument;
34+
import org.sonar.plugins.python.api.tree.Statement;
35+
import org.sonar.plugins.python.api.tree.Tree;
36+
import org.sonar.plugins.python.api.tree.WithStatement;
37+
import org.sonar.python.tests.UnittestUtils;
38+
import org.sonar.python.tree.TreeUtils;
39+
40+
@Rule(key = "S5915")
41+
public class AssertAfterRaiseCheck extends PythonSubscriptionCheck {
42+
private static final String MESSAGE_MULTIPLE_STATEMENT = "Don’t perform an assertion here; An exception is expected to be raised before its execution.";
43+
private static final String MESSAGE_SINGLE_STATEMENT = "Refactor this test; if this assertion’s argument raises an exception, the assertion will never get executed.";
44+
private static final String MESSAGE_SECONDARY = "An exception is expected to be raised in this block.";
45+
46+
private static final String ASSERTION_ERROR = "AssertionError";
47+
private static final String PYTEST_RAISE_CALL = "pytest.raises";
48+
private static final String PYTEST_ARG_EXCEPTION = "expected_exception";
49+
private static final String UNITTEST_ARG_EXCEPTION = "exception";
50+
51+
@Override
52+
public void initialize(Context context) {
53+
context.registerSyntaxNodeConsumer(Tree.Kind.WITH_STMT, ctx -> {
54+
WithStatement withStatement = (WithStatement) ctx.syntaxNode();
55+
if (!isWithStatementItemARaise(withStatement)) {
56+
return;
57+
}
58+
59+
List<Statement> statements = withStatement.statements().statements();
60+
Statement statement = statements.get(statements.size()-1);
61+
if (isAnAssert(statement)) {
62+
ctx.addIssue(statement, statements.size() > 1 ? MESSAGE_MULTIPLE_STATEMENT : MESSAGE_SINGLE_STATEMENT)
63+
.secondary(IssueLocation.preciseLocation(withStatement.firstToken(), withStatement.colon(), MESSAGE_SECONDARY));
64+
}
65+
});
66+
}
67+
68+
public boolean isWithStatementItemARaise(WithStatement withStatement) {
69+
return withStatement.withItems().stream()
70+
.filter(withItem -> withItem.test().is(Tree.Kind.CALL_EXPR))
71+
.map(withItem -> (CallExpression) withItem.test())
72+
.anyMatch(callExpression -> isValidPytestRaise(callExpression) || isValidUnittestRaise(callExpression));
73+
}
74+
75+
public boolean isValidPytestRaise(CallExpression callExpression) {
76+
return Optional.of(callExpression).stream()
77+
.map(call -> TreeUtils.getSymbolFromTree(call.callee()))
78+
.filter(Optional::isPresent)
79+
.map(Optional::get)
80+
.map(Symbol::fullyQualifiedName)
81+
.filter(Objects::nonNull)
82+
.anyMatch(fqn -> fqn.contains(PYTEST_RAISE_CALL))
83+
&& isNotAssertionErrorArgument(TreeUtils.nthArgumentOrKeyword(0, PYTEST_ARG_EXCEPTION, callExpression.arguments()));
84+
}
85+
86+
public boolean isValidUnittestRaise(CallExpression callExpression) {
87+
return Optional.of(callExpression).stream()
88+
.filter(call -> call.callee().is(Tree.Kind.QUALIFIED_EXPR))
89+
.map(call -> (QualifiedExpression) call.callee())
90+
.anyMatch(
91+
callee -> callee.qualifier().is(Tree.Kind.NAME)
92+
&& ((Name) callee.qualifier()).name().equals("self")
93+
&& UnittestUtils.RAISE_METHODS.contains(callee.name().name()))
94+
&& isNotAssertionErrorArgument(TreeUtils.nthArgumentOrKeyword(0, UNITTEST_ARG_EXCEPTION, callExpression.arguments()));
95+
}
96+
97+
public boolean isNotAssertionErrorArgument(RegularArgument regularArgument) {
98+
return Optional.ofNullable(regularArgument).stream()
99+
.filter(Objects::nonNull)
100+
.map(arg -> TreeUtils.getSymbolFromTree(arg.expression()))
101+
.anyMatch(optSym -> optSym.isEmpty() || !ASSERTION_ERROR.equals(optSym.get().fullyQualifiedName()));
102+
}
103+
104+
public boolean isAnAssert(Statement statement) {
105+
if (statement.is(Tree.Kind.ASSERT_STMT)) {
106+
return true;
107+
}
108+
109+
return Optional.of(statement).stream()
110+
.filter(stat -> stat.is(Tree.Kind.EXPRESSION_STMT))
111+
.map(ExpressionStatement.class::cast)
112+
.map(ExpressionStatement::expressions)
113+
.anyMatch(expressions ->
114+
expressions.stream()
115+
.filter(expression -> expression.is(Tree.Kind.CALL_EXPR))
116+
.map(expression -> ((CallExpression) expression).callee())
117+
.filter(callee -> callee.is(Tree.Kind.QUALIFIED_EXPR))
118+
.map(QualifiedExpression.class::cast)
119+
.anyMatch(this::isUnittestAssert)
120+
);
121+
}
122+
123+
public boolean isUnittestAssert(QualifiedExpression callee) {
124+
return callee.qualifier().is(Tree.Kind.NAME) && ((Name) callee.qualifier()).name().equals("self")
125+
&& UnittestUtils.allAssertMethods().contains(callee.name().name());
126+
}
127+
}

python-checks/src/main/java/org/sonar/python/checks/tests/ImplicitlySkippedTestCheck.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ private static ReturnStatement getReturnStatementFromFirstIfStatement(FunctionDe
9494

9595
private static boolean containsAssertion(FunctionDef functionDef) {
9696
Set<String> supportedAssertMethods = UnittestUtils.isWithinUnittestTestCase(functionDef) ?
97-
UnittestUtils.ASSERTIONS_METHODS : Collections.emptySet();
97+
UnittestUtils.allAssertMethods() : Collections.emptySet();
9898
AssertionVisitor assertVisitor = new AssertionVisitor(supportedAssertMethods);
9999
functionDef.accept(assertVisitor);
100100
return assertVisitor.hasAnAssert;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<p>Using <code>pytest.raises</code> or <code>unittest.TestCase.assertRaises</code> will assert that an exception is raised in the following block.
2+
Ending such block in an assertion means that the test can succeed with that last assertion never being executed.</p>
3+
<h2>Noncompliant Code Example</h2>
4+
<pre>
5+
import pytest
6+
def foo(): return 1 / 0
7+
def bar(): return 42
8+
def test_something():
9+
with pytest.raises(ZeroDivisionError):
10+
foo()
11+
assert bar() == 42 # Noncompliant
12+
</pre>
13+
<h2>Compliant Solution</h2>
14+
<pre>
15+
import pytest
16+
def foo(): return 1 / 0
17+
def bar(): return 42
18+
def test_something():
19+
with pytest.raises(ZeroDivisionError):
20+
foo()
21+
assert bar() == 42
22+
</pre>
23+
<h2>See</h2>
24+
<ul>
25+
<li> <a href="https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertRaises">Unittest: assertRaises</a> </li>
26+
<li> <a href="https://docs.pytest.org/en/stable/assert.html#assertions-about-expected-exceptions">Pytest: assertions about expected exceptions</a>
27+
</li>
28+
</ul>
29+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"title": "Assertions should not be made at the end of blocks expecting an exception",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"tests",
11+
"unused",
12+
"pitfall"
13+
],
14+
"defaultSeverity": "Critical",
15+
"ruleSpecification": "RSPEC-5915",
16+
"sqKey": "S5915",
17+
"scope": "Tests",
18+
"quickfix": "unknown"
19+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2022 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks.tests;
21+
22+
import org.junit.Test;
23+
import org.sonar.python.checks.utils.PythonCheckVerifier;
24+
25+
public class AssertAfterRaiseCheckTest {
26+
@Test
27+
public void test() {
28+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertAfterRaise.py", new AssertAfterRaiseCheck());
29+
}
30+
31+
@Test
32+
public void testWithWrapper() {
33+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertAfterRaiseWithWrapper.py", new AssertAfterRaiseCheck());
34+
}
35+
36+
@Test
37+
public void testWithAnotherLibraryUnittest() {
38+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertAfterRaiseAnotherLibraryUnittest.py", new AssertAfterRaiseCheck());
39+
}
40+
41+
@Test
42+
public void testImportPytestAs() {
43+
PythonCheckVerifier.verify("src/test/resources/checks/tests/assertAfterRaiseImportPytestAs.py", new AssertAfterRaiseCheck());
44+
}
45+
}

0 commit comments

Comments
 (0)