From 5f685fc994fc680183dbcd7c2694442be0bbe8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 13:54:26 +0200 Subject: [PATCH 1/8] generate doc for S9017 --- .../org/sonar/l10n/java/rules/java/S9017.html | 96 +++++++++++++++++++ .../org/sonar/l10n/java/rules/java/S9017.json | 26 +++++ .../main/resources/profiles/Sonar_way/S9017 | 0 3 files changed, 122 insertions(+) create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json create mode 100644 sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9017 diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html new file mode 100644 index 00000000000..698b67d721b --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html @@ -0,0 +1,96 @@ +

This rule raises an issue when test mock stubbing is syntactically incomplete:

+ +

In Mockito specifically, this means: a when() call must be followed by thenReturn(), thenThrow(), +thenAnswer(), or thenCallRealMethod(); and a doReturn(), doThrow(), doAnswer(), +doNothing(), or doCallRealMethod() call must be followed by .when(mock).method().

+

Why is this an issue?

+

Test double frameworks allow you to configure behavior through chained API calls. These chains must be syntactically complete to take effect.

+

When you write the first part of a stub configuration (specifying which method call to intercept) without completing the chain with a behavior +specification (what value to return or action to take), the configuration is discarded. The method continues to return its default value (null for +objects, 0 for numbers, false for booleans).

+

Similarly, when you write the behavior specification first (what to return) without completing it with the method specification (which method to +stub), the configured behavior is never attached to any method. It is silently dropped.

+

Both patterns compile and run without errors in lenient testing frameworks. This makes them hard to spot during code review or testing. The test +may pass because it never actually exercises the intended stub, or it may fail with a confusing error message that doesn’t point to the incomplete +stubbing.

+

The incomplete stub creates a gap between what you intended to test and what the test actually verifies. This undermines the reliability of your +test suite.

+

What is the potential impact?

+

Incomplete mock object stubs can cause:

+ +

How to fix it in Mockito

+

Complete the when() chain by adding a behavior method such as thenReturn(), thenThrow(), +thenAnswer(), or thenCallRealMethod().

+

Code examples

+

Noncompliant code example

+
+@Test
+void testFindUser() {
+  UserRepository mock = mock(UserRepository.class);
+  when(mock.findUser(42)); // Noncompliant
+
+  User user = service.getUser(42);
+  assertNotNull(user); // Will fail - findUser returns null
+}
+
+

Compliant solution

+
+@Test
+void testFindUser() {
+  UserRepository mock = mock(UserRepository.class);
+  when(mock.findUser(42)).thenReturn(new User("Alice"));
+
+  User user = service.getUser(42);
+  assertNotNull(user); // Now passes correctly
+}
+
+

Complete the do*() chain by adding .when(mock).method() to specify which method should have the configured behavior.

+

Noncompliant code example

+
+@Test
+void testDeleteUser() {
+  UserRepository mock = mock(UserRepository.class);
+  doThrow(new RuntimeException()); // Noncompliant
+
+  assertThrows(RuntimeException.class, () -> {
+    service.deleteUser(42);
+  }); // Will fail - no exception is thrown
+}
+
+

Compliant solution

+
+@Test
+void testDeleteUser() {
+  UserRepository mock = mock(UserRepository.class);
+  doThrow(new RuntimeException()).when(mock).deleteUser(42);
+
+  assertThrows(RuntimeException.class, () -> {
+    service.deleteUser(42);
+  }); // Now passes correctly
+}
+
+

Resources

+

Documentation

+ +

Related rules

+ + diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json new file mode 100644 index 00000000000..d51466440de --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json @@ -0,0 +1,26 @@ +{ + "title": "Mockito stubbing chains should be completed", + "type": "BUG", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5 min" + }, + "tags": [ + "mockito", + "tests", + "junit" + ], + "defaultSeverity": "Critical", + "ruleSpecification": "RSPEC-9017", + "sqKey": "S9017", + "scope": "Tests", + "quickfix": "unknown", + "code": { + "impacts": { + "RELIABILITY": "HIGH", + "MAINTAINABILITY": "MEDIUM" + }, + "attribute": "COMPLETE" + } +} diff --git a/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9017 b/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9017 new file mode 100644 index 00000000000..e69de29bb2d From 54abfd8d9717302690e446dc20915944a40518ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 14:56:00 +0200 Subject: [PATCH 2/8] implement rule --- .../MockitoStubbingChainCheckSample.java | 78 ++++++++++++++ .../tests/MockitoStubbingChainCheck.java | 101 ++++++++++++++++++ .../tests/MockitoStubbingChainCheckTest.java | 42 ++++++++ 3 files changed, 221 insertions(+) create mode 100644 java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java create mode 100644 java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java create mode 100644 java-checks/src/test/java/org/sonar/java/checks/tests/MockitoStubbingChainCheckTest.java diff --git a/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java b/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java new file mode 100644 index 00000000000..a84132ec51a --- /dev/null +++ b/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java @@ -0,0 +1,78 @@ +package checks.tests; + +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.OngoingStubbing; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MockitoStubbingChainCheckSample { + + interface UserRepository { + User findUser(long id); + void deleteUser(long id); + User saveUser(User user); + } + + record User(String name) {} + + @Test + void incompleteWhen() { + UserRepository repo = mock(UserRepository.class); + when(repo.findUser(42)); // Noncompliant {{Complete this stubbing by adding "thenReturn()", "thenThrow()", "thenAnswer()", or "thenCallRealMethod()".}} + //^^^^^^^^^^^^^^^^^^^^^^^ + } + + @Test + void completeWhen() { + UserRepository repo = mock(UserRepository.class); + when(repo.findUser(42)).thenReturn(new User("Alice")); // Compliant + when(repo.findUser(43)).thenThrow(new RuntimeException()); // Compliant + when(repo.findUser(44)).thenAnswer(inv -> new User("Bob")); // Compliant + when(repo.findUser(45)).thenCallRealMethod(); // Compliant + } + + @Test + void incompleteDoMethods() { + UserRepository repo = mock(UserRepository.class); + doReturn(new User("Alice")); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + //^^^^^^^^^^^^^^^^^^^^^^^^^^^ + doThrow(new RuntimeException()); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} [[sc=5;ec=36]] + doAnswer(inv -> null); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + doNothing(); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + doCallRealMethod(); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + } + + @Test + void incompleteDoChainMissingMethodCall() { + UserRepository repo = mock(UserRepository.class); + doThrow(new RuntimeException()).when(repo); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + } + + @Test + void completeDoMethods() { + UserRepository repo = mock(UserRepository.class); + doReturn(new User("Alice")).when(repo).findUser(42); // Compliant + doThrow(new RuntimeException()).when(repo).deleteUser(99); // Compliant + doAnswer(inv -> new User("Bob")).when(repo).saveUser(new User("Bob")); // Compliant + doNothing().when(repo).deleteUser(1); // Compliant + doCallRealMethod().when(repo).findUser(1); // Compliant + } + + @Test + void whenStoredInVariable() { + UserRepository repo = mock(UserRepository.class); + OngoingStubbing stub = when(repo.findUser(42)); // Compliant - variable assignment skipped + stub.thenReturn(new User("Alice")); + } + + OngoingStubbing helperMethod(UserRepository repo) { + return when(repo.findUser(42)); // Compliant - return statement skipped + } +} + diff --git a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java new file mode 100644 index 00000000000..390817f6719 --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java @@ -0,0 +1,101 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * 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 Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks.tests; + +import org.sonar.check.Rule; +import org.sonar.plugins.java.api.JavaFileScanner; +import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.plugins.java.api.semantic.MethodMatchers; +import org.sonar.plugins.java.api.tree.BaseTreeVisitor; +import org.sonar.plugins.java.api.tree.MethodInvocationTree; +import org.sonar.plugins.java.api.tree.ReturnStatementTree; +import org.sonar.plugins.java.api.tree.VariableTree; + +@Rule(key = "S9017") +public class MockitoStubbingChainCheck extends BaseTreeVisitor implements JavaFileScanner { + + private static final String WHEN_MESSAGE = "Complete this stubbing by adding \"thenReturn()\", \"thenThrow()\", \"thenAnswer()\", or \"thenCallRealMethod()\"."; + private static final String DO_MESSAGE = "Complete this stubbing by adding \".when(mock).method()\"."; + + private static final MethodMatchers MOCKITO_WHEN = MethodMatchers.create() + .ofTypes("org.mockito.Mockito") + .names("when") + .withAnyParameters() + .build(); + + private static final MethodMatchers MOCKITO_DO_METHODS = MethodMatchers.create() + .ofTypes("org.mockito.Mockito") + .names("doReturn", "doThrow", "doAnswer", "doNothing", "doCallRealMethod") + .withAnyParameters() + .build(); + + // Detects do*().when(mock) that is not followed by a method call on the mock + private static final MethodMatchers STUBBER_WHEN = MethodMatchers.create() + .ofSubTypes("org.mockito.stubbing.Stubber") + .names("when") + .withAnyParameters() + .build(); + + private Boolean isChained = null; + private JavaFileScannerContext context; + + @Override + public void scanFile(JavaFileScannerContext context) { + if (context.getSemanticModel() == null) { + return; + } + this.context = context; + scan(context.getTree()); + } + + @Override + public void visitVariable(VariableTree tree) { + // skip: the stub may be stored in a variable and completed later + } + + @Override + public void visitReturnStatement(ReturnStatementTree tree) { + // skip: returning a partial stub is valid (e.g. helper methods) + } + + @Override + public void visitMethodInvocation(MethodInvocationTree mit) { + if (isIncompleteStubbing(mit)) { + return; + } + Boolean previous = isChained; + isChained = true; + scan(mit.methodSelect()); + // arguments are intentionally not scanned + isChained = previous; + } + + private boolean isIncompleteStubbing(MethodInvocationTree mit) { + if (Boolean.TRUE.equals(isChained)) { + return false; + } + if (MOCKITO_WHEN.matches(mit)) { + context.reportIssue(this, mit, WHEN_MESSAGE); + return true; + } + if (MOCKITO_DO_METHODS.matches(mit) || STUBBER_WHEN.matches(mit)) { + context.reportIssue(this, mit, DO_MESSAGE); + return true; + } + return false; + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/tests/MockitoStubbingChainCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/tests/MockitoStubbingChainCheckTest.java new file mode 100644 index 00000000000..40deb31dba0 --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/tests/MockitoStubbingChainCheckTest.java @@ -0,0 +1,42 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * 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 Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks.tests; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.testCodeSourcesPath; + +class MockitoStubbingChainCheckTest { + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(testCodeSourcesPath("checks/tests/MockitoStubbingChainCheckSample.java")) + .withCheck(new MockitoStubbingChainCheck()) + .verifyIssues(); + } + + @Test + void testWithoutSemantics() { + CheckVerifier.newVerifier() + .onFile(testCodeSourcesPath("checks/tests/MockitoStubbingChainCheckSample.java")) + .withCheck(new MockitoStubbingChainCheck()) + .withoutSemantic() + .verifyNoIssues(); + } +} From 15f58d72845f3a4631ef5637ff8833de8c2b228c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 15:06:04 +0200 Subject: [PATCH 3/8] update rule metadata --- .../org/sonar/l10n/java/rules/java/S9017.html | 22 +++++++++---------- .../org/sonar/l10n/java/rules/java/S9017.json | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html index 698b67d721b..ac778803e52 100644 --- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.html @@ -1,11 +1,5 @@ -

This rule raises an issue when test mock stubbing is syntactically incomplete:

-
    -
  • A method that begins configuring a mock’s behavior by specifying which method call to intercept is not followed by a specification of what value - to return, what exception to throw, what custom logic to execute, or whether to call the real implementation
  • -
  • A method that specifies what action to take (return a value, throw an exception, execute custom logic, do nothing, or call the real - implementation) is not followed by the specification of which mock object and method the action applies to
  • -
-

In Mockito specifically, this means: a when() call must be followed by thenReturn(), thenThrow(), +

This rule raises an issue when test mock stubbing is syntactically incomplete.

+

In Mockito, this means: a when() call must be followed by thenReturn(), thenThrow(), thenAnswer(), or thenCallRealMethod(); and a doReturn(), doThrow(), doAnswer(), doNothing(), or doCallRealMethod() call must be followed by .when(mock).method().

Why is this an issue?

@@ -84,10 +78,14 @@

Compliant solution

Resources

Documentation

Related rules

    diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json index d51466440de..b15d6e7e983 100644 --- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9017.json @@ -1,5 +1,5 @@ { - "title": "Mockito stubbing chains should be completed", + "title": "Mockito stubbing chains should be complete", "type": "BUG", "status": "ready", "remediation": { From 5fa2cf239aa3a29d68a401e4f8b6b614e9c95a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 15:19:43 +0200 Subject: [PATCH 4/8] address review comments --- .../MockitoStubbingChainCheckSample.java | 29 +++++++++++++++++-- .../tests/MockitoStubbingChainCheck.java | 24 +++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java b/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java index a84132ec51a..aa2cd691ff4 100644 --- a/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java +++ b/java-checks-test-sources/default/src/test/java/checks/tests/MockitoStubbingChainCheckSample.java @@ -51,7 +51,8 @@ void incompleteDoMethods() { @Test void incompleteDoChainMissingMethodCall() { UserRepository repo = mock(UserRepository.class); - doThrow(new RuntimeException()).when(repo); // Noncompliant {{Complete this stubbing by adding ".when(mock).method()".}} + doThrow(new RuntimeException()).when(repo); // Noncompliant {{Complete this stubbing by adding the method to stub.}} + //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ } @Test @@ -67,10 +68,34 @@ void completeDoMethods() { @Test void whenStoredInVariable() { UserRepository repo = mock(UserRepository.class); - OngoingStubbing stub = when(repo.findUser(42)); // Compliant - variable assignment skipped + OngoingStubbing stub = when(repo.findUser(42)); // Compliant - variable declaration skipped stub.thenReturn(new User("Alice")); } + @Test + void whenStoredViaAssignment() { + UserRepository repo = mock(UserRepository.class); + OngoingStubbing stub; + stub = when(repo.findUser(42)); // Compliant - assignment expression skipped + stub.thenReturn(new User("Alice")); + } + + @Test + void incompleteWhenInsideLambda() { + UserRepository repo = mock(UserRepository.class); + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> { + when(repo.findUser(1)); // Noncompliant {{Complete this stubbing by adding "thenReturn()", "thenThrow()", "thenAnswer()", or "thenCallRealMethod()".}} + }); + } + + @Test + void completeWhenInsideLambda() { + UserRepository repo = mock(UserRepository.class); + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> { + when(repo.findUser(1)).thenReturn(new User("Alice")); // Compliant + }); + } + OngoingStubbing helperMethod(UserRepository repo) { return when(repo.findUser(42)); // Compliant - return statement skipped } diff --git a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java index 390817f6719..7292dd2d731 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java @@ -20,9 +20,12 @@ import org.sonar.plugins.java.api.JavaFileScanner; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.semantic.MethodMatchers; +import org.sonar.plugins.java.api.tree.AssignmentExpressionTree; import org.sonar.plugins.java.api.tree.BaseTreeVisitor; +import org.sonar.plugins.java.api.tree.ExpressionTree; import org.sonar.plugins.java.api.tree.MethodInvocationTree; import org.sonar.plugins.java.api.tree.ReturnStatementTree; +import org.sonar.plugins.java.api.tree.Tree; import org.sonar.plugins.java.api.tree.VariableTree; @Rule(key = "S9017") @@ -30,6 +33,7 @@ public class MockitoStubbingChainCheck extends BaseTreeVisitor implements JavaFi private static final String WHEN_MESSAGE = "Complete this stubbing by adding \"thenReturn()\", \"thenThrow()\", \"thenAnswer()\", or \"thenCallRealMethod()\"."; private static final String DO_MESSAGE = "Complete this stubbing by adding \".when(mock).method()\"."; + private static final String STUBBER_WHEN_MESSAGE = "Complete this stubbing by adding the method to stub."; private static final MethodMatchers MOCKITO_WHEN = MethodMatchers.create() .ofTypes("org.mockito.Mockito") @@ -67,6 +71,11 @@ public void visitVariable(VariableTree tree) { // skip: the stub may be stored in a variable and completed later } + @Override + public void visitAssignmentExpression(AssignmentExpressionTree tree) { + // skip: the stub may be stored in a pre-declared variable and completed later + } + @Override public void visitReturnStatement(ReturnStatementTree tree) { // skip: returning a partial stub is valid (e.g. helper methods) @@ -80,7 +89,14 @@ public void visitMethodInvocation(MethodInvocationTree mit) { Boolean previous = isChained; isChained = true; scan(mit.methodSelect()); - // arguments are intentionally not scanned + // Scan lambda/anonymous-class arguments as independent chains (reset isChained so + // incomplete stubs inside their bodies are detected, but they are not part of this chain) + isChained = null; + for (ExpressionTree arg : mit.arguments()) { + if (arg.is(Tree.Kind.LAMBDA_EXPRESSION, Tree.Kind.NEW_CLASS)) { + scan(arg); + } + } isChained = previous; } @@ -92,10 +108,14 @@ private boolean isIncompleteStubbing(MethodInvocationTree mit) { context.reportIssue(this, mit, WHEN_MESSAGE); return true; } - if (MOCKITO_DO_METHODS.matches(mit) || STUBBER_WHEN.matches(mit)) { + if (MOCKITO_DO_METHODS.matches(mit)) { context.reportIssue(this, mit, DO_MESSAGE); return true; } + if (STUBBER_WHEN.matches(mit)) { + context.reportIssue(this, mit, STUBBER_WHEN_MESSAGE); + return true; + } return false; } } From 33a60d8c121cd11d475ae8ed07f2d6799acc2859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 15:45:24 +0200 Subject: [PATCH 5/8] fix autoscan --- .../src/test/resources/autoscan/diffs/diff_S2699.json | 2 +- .../src/test/resources/autoscan/diffs/diff_S3577.json | 2 +- .../src/test/resources/autoscan/diffs/diff_S9017.json | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json index b01aa5d1a61..4d20a2698c4 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json @@ -1,6 +1,6 @@ { "ruleKey": "S2699", "hasTruePositives": true, - "falseNegatives": 164, + "falseNegatives": 170, "falsePositives": 1 } diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S3577.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S3577.json index 6b9a0072838..f6f9d38e209 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S3577.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S3577.json @@ -1,6 +1,6 @@ { "ruleKey": "S3577", "hasTruePositives": true, - "falseNegatives": 49, + "falseNegatives": 50, "falsePositives": 0 } diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json new file mode 100644 index 00000000000..cd8189f3b80 --- /dev/null +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json @@ -0,0 +1,6 @@ +{ + "ruleKey": "S9017", + "hasTruePositives": false, + "falseNegatives": 7, + "falsePositives": 0 +} From dd0611aa1a7952228ab3d6ce3791bc226bf8421f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 13 Jul 2026 15:56:06 +0200 Subject: [PATCH 6/8] fix autoscan --- its/autoscan/src/test/resources/autoscan/diffs/diff_S1481.json | 2 +- its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json | 2 +- its/autoscan/src/test/resources/autoscan/diffs/diff_S5778.json | 2 +- its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S1481.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S1481.json index 0a9e1c62b2b..2c9fcebef19 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S1481.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S1481.json @@ -1,6 +1,6 @@ { "ruleKey": "S1481", "hasTruePositives": true, - "falseNegatives": 8, + "falseNegatives": 9, "falsePositives": 0 } diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json index 4d20a2698c4..039237eaa74 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S2699.json @@ -1,6 +1,6 @@ { "ruleKey": "S2699", "hasTruePositives": true, - "falseNegatives": 170, + "falseNegatives": 171, "falsePositives": 1 } diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S5778.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S5778.json index 9fc6644b170..b283342d7b4 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S5778.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S5778.json @@ -1,6 +1,6 @@ { "ruleKey": "S5778", "hasTruePositives": false, - "falseNegatives": 44, + "falseNegatives": 46, "falsePositives": 0 } diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json index cd8189f3b80..a1908412882 100644 --- a/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9017.json @@ -1,6 +1,6 @@ { "ruleKey": "S9017", "hasTruePositives": false, - "falseNegatives": 7, + "falseNegatives": 8, "falsePositives": 0 } From 5f777a1f7609fc7ff5ca14385213468107e4b2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 14 Jul 2026 10:00:06 +0200 Subject: [PATCH 7/8] address review comment --- .../tests/MockitoStubbingChainCheck.java | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java index 7292dd2d731..e505815a91a 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java @@ -22,10 +22,12 @@ import org.sonar.plugins.java.api.semantic.MethodMatchers; import org.sonar.plugins.java.api.tree.AssignmentExpressionTree; import org.sonar.plugins.java.api.tree.BaseTreeVisitor; +import org.sonar.plugins.java.api.tree.BlockTree; import org.sonar.plugins.java.api.tree.ExpressionTree; +import org.sonar.plugins.java.api.tree.LambdaExpressionTree; +import org.sonar.plugins.java.api.tree.MemberSelectExpressionTree; import org.sonar.plugins.java.api.tree.MethodInvocationTree; import org.sonar.plugins.java.api.tree.ReturnStatementTree; -import org.sonar.plugins.java.api.tree.Tree; import org.sonar.plugins.java.api.tree.VariableTree; @Rule(key = "S9017") @@ -54,7 +56,6 @@ public class MockitoStubbingChainCheck extends BaseTreeVisitor implements JavaFi .withAnyParameters() .build(); - private Boolean isChained = null; private JavaFileScannerContext context; @Override @@ -83,25 +84,28 @@ public void visitReturnStatement(ReturnStatementTree tree) { @Override public void visitMethodInvocation(MethodInvocationTree mit) { - if (isIncompleteStubbing(mit)) { - return; - } - Boolean previous = isChained; - isChained = true; - scan(mit.methodSelect()); - // Scan lambda/anonymous-class arguments as independent chains (reset isChained so - // incomplete stubs inside their bodies are detected, but they are not part of this chain) - isChained = null; + visitMethodInvocation(mit, false); + // also check stubs inside block-bodied lambda arguments (e.g. assertThrows(() -> { when(...); })) for (ExpressionTree arg : mit.arguments()) { - if (arg.is(Tree.Kind.LAMBDA_EXPRESSION, Tree.Kind.NEW_CLASS)) { - scan(arg); + if (arg instanceof LambdaExpressionTree lambda + && lambda.body() instanceof BlockTree block) { + visitBlock(block); } } - isChained = previous; } - private boolean isIncompleteStubbing(MethodInvocationTree mit) { - if (Boolean.TRUE.equals(isChained)) { + private void visitMethodInvocation(MethodInvocationTree mit, boolean isChained) { + if (!isChained && isIncompleteStubbing(mit, isChained)) { + return; + } + if (mit.methodSelect() instanceof MemberSelectExpressionTree mset + && mset.expression() instanceof MethodInvocationTree innerMit) { + visitMethodInvocation(innerMit, true); + } + } + + private boolean isIncompleteStubbing(MethodInvocationTree mit, boolean isChained) { + if (isChained) { return false; } if (MOCKITO_WHEN.matches(mit)) { From 01ed92986ed4068754c8ce49d3670df2c2b0d158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 14 Jul 2026 10:29:27 +0200 Subject: [PATCH 8/8] remove redundant isChained parameter and check --- .../sonar/java/checks/tests/MockitoStubbingChainCheck.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java index e505815a91a..deccd175084 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/tests/MockitoStubbingChainCheck.java @@ -95,7 +95,7 @@ public void visitMethodInvocation(MethodInvocationTree mit) { } private void visitMethodInvocation(MethodInvocationTree mit, boolean isChained) { - if (!isChained && isIncompleteStubbing(mit, isChained)) { + if (!isChained && isIncompleteStubbing(mit)) { return; } if (mit.methodSelect() instanceof MemberSelectExpressionTree mset @@ -104,10 +104,7 @@ private void visitMethodInvocation(MethodInvocationTree mit, boolean isChained) } } - private boolean isIncompleteStubbing(MethodInvocationTree mit, boolean isChained) { - if (isChained) { - return false; - } + private boolean isIncompleteStubbing(MethodInvocationTree mit) { if (MOCKITO_WHEN.matches(mit)) { context.reportIssue(this, mit, WHEN_MESSAGE); return true;