Skip to content

Commit f2ad659

Browse files
SONARPY-1101 Fix FP on S5899 for helper methods (#1196)
1 parent b260ca0 commit f2ad659

File tree

4 files changed

+185
-59
lines changed

4 files changed

+185
-59
lines changed
Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,56 @@
11
{
2-
'project:django-2.2.3/django/test/testcases.py':[
3-
292,
4-
299,
5-
306,
6-
437,
7-
456,
8-
467,
9-
522,
10-
626,
11-
655,
12-
690,
13-
706,
14-
716,
15-
762,
16-
780,
17-
790,
18-
802,
19-
819,
20-
836,
21-
856,
22-
],
23-
'project:mypy-0.782/test-data/stdlib-samples/3.2/test/test_set.py':[
24-
741,
25-
],
26-
'project:mypy-0.782/test-data/stdlib-samples/3.2/test/test_tempfile.py':[
27-
50,
28-
55,
29-
],
30-
'project:mypy-0.782/test-data/stdlib-samples/3.2/test/test_textwrap.py':[
31-
40,
32-
45,
33-
],
34-
'project:numpy-1.16.4/numpy/testing/_private/utils.py':[
35-
1262,
36-
],
37-
'project:tornado-2.3/tornado/testing.py':[
38-
169,
39-
],
2+
'project:buildbot-0.8.6p1/buildbot/test/unit/test_process_properties.py':[
3+
502,
4+
],
5+
'project:django-cms-3.7.1/cms/tests/test_cache.py':[
6+
257,
7+
],
8+
'project:django-cms-3.7.1/cms/tests/test_page_admin.py':[
9+
126,
10+
],
11+
'project:django-cms-3.7.1/cms/tests/test_plugins.py':[
12+
132,
13+
],
14+
'project:django-cms-3.7.1/cms/tests/test_toolbar.py':[
15+
78,
16+
81,
17+
86,
18+
91,
19+
2086,
20+
],
21+
'project:mypy-0.782/test-data/stdlib-samples/3.2/test/test_set.py':[
22+
741,
23+
],
24+
'project:mypy-0.782/test-data/stdlib-samples/3.2/test/test_tempfile.py':[
25+
128,
26+
],
27+
'project:numpy-1.16.4/numpy/testing/_private/utils.py':[
28+
1262,
29+
],
30+
'project:tornado-2.3/tornado/test/httpserver_test.py':[
31+
124,
32+
342,
33+
],
34+
'project:tornado-2.3/tornado/test/web_test.py':[
35+
23,
36+
],
37+
'project:twisted-12.1.0/twisted/conch/test/test_cftp.py':[
38+
466,
39+
474,
40+
],
41+
'project:twisted-12.1.0/twisted/test/test_amp.py':[
42+
892,
43+
896,
44+
],
45+
'project:twisted-12.1.0/twisted/trial/test/sample.py':[
46+
69,
47+
],
48+
'project:twisted-12.1.0/twisted/trial/test/test_tests.py':[
49+
627,
50+
631,
51+
641,
52+
],
53+
'project:twisted-12.1.0/twisted/words/test/test_jabbercomponent.py':[
54+
221,
55+
],
4056
}

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

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,18 @@
2323
import java.util.HashSet;
2424
import java.util.List;
2525
import java.util.Map;
26+
import java.util.Objects;
2627
import java.util.Optional;
2728
import java.util.Set;
29+
import java.util.stream.Collectors;
2830
import org.sonar.check.Rule;
2931
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
3032
import org.sonar.plugins.python.api.SubscriptionContext;
31-
import org.sonar.plugins.python.api.symbols.ClassSymbol;
3233
import org.sonar.plugins.python.api.symbols.FunctionSymbol;
3334
import org.sonar.plugins.python.api.symbols.Symbol;
3435
import org.sonar.plugins.python.api.symbols.Usage;
3536
import org.sonar.plugins.python.api.tree.ClassDef;
37+
import org.sonar.plugins.python.api.tree.Decorator;
3638
import org.sonar.plugins.python.api.tree.FunctionDef;
3739
import org.sonar.plugins.python.api.tree.Statement;
3840
import org.sonar.plugins.python.api.tree.Tree;
@@ -43,26 +45,27 @@
4345
public class NotDiscoverableTestMethodCheck extends PythonSubscriptionCheck {
4446

4547
private static final String MESSAGE = "Rename this method so that it starts with \"test\" or remove this unused helper.";
48+
private static final Set<String> globalFixture = new HashSet<>();
4649

4750
@Override
4851
public void initialize(Context context) {
52+
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, ctx -> NotDiscoverableTestMethodCheck.lookForGlobalFixture((FunctionDef) ctx.syntaxNode()));
53+
4954
context.registerSyntaxNodeConsumer(Tree.Kind.CLASSDEF, ctx -> {
5055
ClassDef classDefinition = (ClassDef) ctx.syntaxNode();
5156

5257
if (inheritsOnlyFromUnitTest(classDefinition)) {
5358
Map<FunctionSymbol, FunctionDef> suspiciousFunctionsAndDefinitions = new HashMap<>();
5459
Set<Tree> allDefinitions = new HashSet<>();
60+
Set<String> classFixtures = getFixturesFromClass(classDefinition);
5561
// We only consider method definitions, and not nested functions
5662
for (Statement statement : classDefinition.body().statements()) {
5763
if (statement.is(Tree.Kind.FUNCDEF)) {
5864
FunctionDef functionDef = ((FunctionDef) statement);
59-
String functionName = functionDef.name().name();
60-
Symbol symbol = functionDef.name().symbol();
61-
// If it doesn't override existing methods and doesn't start with test, it is added to the map
62-
if (!overrideExistingMethod(functionName) && !functionName.startsWith("test")) {
63-
Optional.ofNullable(symbol)
64-
.filter(s -> s.is(Symbol.Kind.FUNCTION))
65-
.ifPresent(s -> suspiciousFunctionsAndDefinitions.put(((FunctionSymbol) s), functionDef));
65+
if (!isException(functionDef, classFixtures)) {
66+
Optional.ofNullable(functionDef.name().symbol())
67+
.filter(symbol -> symbol.is(Symbol.Kind.FUNCTION))
68+
.ifPresent(symbol -> suspiciousFunctionsAndDefinitions.put(((FunctionSymbol) symbol), functionDef));
6669
}
6770
allDefinitions.add(functionDef);
6871
}
@@ -73,9 +76,38 @@ public void initialize(Context context) {
7376
});
7477
}
7578

76-
@Override
77-
public CheckScope scope() {
78-
return CheckScope.ALL;
79+
private static void lookForGlobalFixture(FunctionDef functionDef) {
80+
if (functionDef.isMethodDefinition()) {
81+
return;
82+
}
83+
if (functionDef.decorators().stream().anyMatch(NotDiscoverableTestMethodCheck::isPytestFixture)) {
84+
globalFixture.add(functionDef.name().name());
85+
}
86+
}
87+
88+
/** https://docs.pytest.org/en/6.2.x/fixture.html
89+
* Retrieve all Fixtures defined in a class.
90+
* A fixture is a method which has the specific decorator @pytest.fixture
91+
* In those cases, pytest will invoke the method fixture and inject the result in any test method
92+
* for which one of their parameter name match with the fixture method name.
93+
*/
94+
private static Set<String> getFixturesFromClass(ClassDef classDefinition) {
95+
return classDefinition.body().statements().stream()
96+
.filter(statement -> statement.is(Tree.Kind.FUNCDEF))
97+
.map(FunctionDef.class::cast)
98+
.filter(functionDef -> functionDef.decorators().stream().anyMatch(NotDiscoverableTestMethodCheck::isPytestFixture))
99+
.map(functionDef -> functionDef.name().name())
100+
.collect(Collectors.toSet());
101+
}
102+
103+
private static boolean isException(FunctionDef functionDef, Set<String> classFixtures) {
104+
String functionName = functionDef.name().name();
105+
return overrideExistingMethod(functionName) || functionName.startsWith("test") || isHelper(functionDef, classFixtures);
106+
}
107+
108+
private static boolean isPytestFixture(Decorator decorator) {
109+
String decoratorName = TreeUtils.decoratorNameFromExpression(decorator.expression());
110+
return "pytest.fixture".equals(decoratorName);
79111
}
80112

81113
// Only raises issue when the (non-test) method is not used inside the class
@@ -88,19 +120,26 @@ private static void checkSuspiciousFunctionsUsages(SubscriptionContext ctx, Map<
88120
});
89121
}
90122

91-
private static boolean inheritsOnlyFromUnitTest(ClassDef classDefinition) {
92-
Optional<ClassSymbol> classSymbolFromDef = Optional.ofNullable(TreeUtils.getClassSymbolFromDef(classDefinition));
93-
return classSymbolFromDef.filter(classSymbol -> !classSymbol.superClasses().isEmpty()).isPresent() &&
94-
classSymbolFromDef
95-
.map(ClassSymbol::superClasses)
96-
.stream()
97-
.flatMap(List::stream)
98-
.map(Symbol::fullyQualifiedName)
99-
.allMatch("unittest.case.TestCase"::equals);
123+
private static boolean inheritsOnlyFromUnitTest(ClassDef classDef) {
124+
return TreeUtils.getParentClassesFQN(classDef).stream().anyMatch(name -> name.contains("unittest") && name.contains("TestCase"))
125+
&& Optional.ofNullable(TreeUtils.getClassSymbolFromDef(classDef)).stream()
126+
.anyMatch(classSym -> classSym.superClasses().size() == 1);
100127
}
101128

102129
private static boolean overrideExistingMethod(String functionName) {
103130
return UnittestUtils.allMethods().contains(functionName) || functionName.startsWith("_");
104131
}
105132

133+
private static boolean isHelper(FunctionDef functionDef, Set<String> currentClassFixture) {
134+
return Optional.ofNullable(TreeUtils.getFunctionSymbolFromDef(functionDef)).stream()
135+
.anyMatch(functionSymbol -> functionSymbol.hasDecorators() || !functionSymbol.parameters().stream()
136+
.map(FunctionSymbol.Parameter::name)
137+
.filter(Objects::nonNull)
138+
.allMatch(name -> name.equals("self") || globalFixture.contains(name) || currentClassFixture.contains(name)));
139+
}
140+
141+
@Override
142+
public CheckScope scope() {
143+
return CheckScope.ALL;
144+
}
106145
}

python-checks/src/test/resources/checks/tests/notDiscoverableTestMethod.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_something(self):
9696
self.some_helper()
9797
...
9898

99-
def method_not_in_parent(self):
99+
def method_not_in_parent(self): # Noncompliant
100100
...
101101

102102

@@ -157,3 +157,72 @@ def test_encode_decode(self) -> None:
157157

158158
def _not_a_test(self):
159159
...
160+
161+
# SONARPY-1101 Fix FP on S5899 for helper methods
162+
# We ignore helper methods which have arguments excluding the Pytest's fixtures, considering they are real helper methods not to be reported
163+
# We also ignore methods which have some decorators
164+
@pytest.fixture
165+
def outFixture(): ...
166+
167+
class MyParentTestClass(unittest.TestCase):
168+
@pytest.fixture
169+
def parentFixture(): ...
170+
171+
class MyTestClass(MyParentTestClass):
172+
@pytest.fixture
173+
def inFixture(): ...
174+
175+
def helper_method_with_argument(self, arg): ...
176+
177+
def helper_method_with_arguments(self, arg1, arg2): ...
178+
179+
def helper_method_with_only_self(self): # Noncompliant
180+
...
181+
182+
def helper_method_with_no_argument(): # Noncompliant
183+
...
184+
185+
def helper_method_with_self_and_in_fixture(self, inFixture): # Noncompliant
186+
...
187+
188+
def helper_method_with_self_and_in_after_fixture(self, inAfterFixture): # Noncompliant
189+
...
190+
191+
def helper_method_with_self_and_out_fixture(self, outFixture): # Noncompliant
192+
...
193+
194+
#Accepted FN : we do not check fixture coming from parent class
195+
def helper_method_with_self_and_parent_fixture(self, parentFixture):
196+
...
197+
198+
def helper_method_with_only_in_fixture(inFixture): # Noncompliant
199+
...
200+
201+
def helper_method_with_only_in_after_fixture(inAfterFixture): # Noncompliant
202+
...
203+
204+
def helper_method_with_only_out_fixture(outFixture): # Noncompliant
205+
...
206+
207+
def helper_method_with_self_in_fixture_and_argument(self, inFixture, arg):
208+
...
209+
210+
def helper_method_with_self_out_fixture_and_argument(self, outFixture, arg):
211+
...
212+
213+
def helper_method_with_self_parent_fixture_and_argument(self, parentFixture, arg):
214+
...
215+
216+
@custom_decorator
217+
def helper_method_with_decorator():
218+
...
219+
220+
@pytest.fixture
221+
def inAfterFixture(): ...
222+
223+
# Edge case
224+
class EdgeCaseLookingLikeUnittest1(unittest.fake):
225+
def testMethod(): ...
226+
227+
class EdgeCaseLookingLikeUnittest2(fake.TestCase):
228+
def testMethod(): ...

python-frontend/src/test/java/org/sonar/python/tests/UnittestUtilsTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222

2323
import org.junit.Test;
24+
import org.sonar.plugins.python.api.tree.ClassDef;
2425
import org.sonar.plugins.python.api.tree.FileInput;
26+
import org.sonar.plugins.python.api.tree.FunctionDef;
2527
import org.sonar.plugins.python.api.tree.Tree;
2628
import org.sonar.python.PythonTestUtils;
2729
import org.sonar.python.semantic.SymbolTableBuilder;

0 commit comments

Comments
 (0)