Skip to content

SONARJAVA-6833: S2699 - Fix false positives for disabled tests, Java assert, and AssertJ methods - #6096

Open
asya-vorobeva wants to merge 2 commits into
masterfrom
asya/fix-s2699-fps
Open

SONARJAVA-6833: S2699 - Fix false positives for disabled tests, Java assert, and AssertJ methods#6096
asya-vorobeva wants to merge 2 commits into
masterfrom
asya/fix-s2699-fps

Conversation

@asya-vorobeva

Copy link
Copy Markdown
Contributor
  • Add missing AssertJ assertion method prefixes (accepts, matches, startsWith) to ASSERTJ_ASSERTION_METHODS_PREDICATE so methods like accepts(), startsWith(), and matches() are recognized when called on AssertJ assertion types
  • Skip assertion checking for test methods annotated with @disabled (JUnit 5) or @ignore (JUnit 4) since those tests are never executed
  • Recognize Java built-in assert statement as a valid assertion in AbstractAssertionVisitor, fixing FPs for both S2699 and S6103

…assert, and AssertJ methods

- Add missing AssertJ assertion method prefixes (accepts, matches, startsWith) to
  ASSERTJ_ASSERTION_METHODS_PREDICATE so methods like accepts(), startsWith(), and
  matches() are recognized when called on AssertJ assertion types
- Skip assertion checking for test methods annotated with @disabled (JUnit 5) or
  @ignore (JUnit 4) since those tests are never executed
- Recognize Java built-in assert statement as a valid assertion in AbstractAssertionVisitor,
  fixing FPs for both S2699 and S6103

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

SONARJAVA-6833

Comment on lines +274 to +288
@Test
public void assertj_predicate_accepts() {
Assertions.assertThat(longPredicateMethod()).accepts(1L, 2L);
}

@Test
public void assertj_string_starts_with() {
Assertions.assertThat("hello world").startsWith("hello");
}

@Test
public void assertj_string_matches() {
Assertions.assertThat("hello").matches("[a-z]+");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Quality: New AssertJ samples don't exercise the predicate change

All five new sample tests start their chain with Assertions.assertThat(...), whose name already matches ASSERTION_METHODS_PATTERN ((assert|verify|fail|...).*) in methodNameMatchesAssertionMethodPattern, so AbstractAssertionVisitor sets hasAssertion = true on the assertThat invocation itself and never needs to match the terminal accepts/startsWith/matches call. These samples therefore pass identically with or without the ASSERTJ_ASSERTION_METHODS_PREDICATE change, leaving the PR's main code change with zero coverage (the existing bdd_assertions_* samples in the same file use BDDAssertions.then(...) precisely because then is not name-matched). Use the BDD entry point (or a bare AbstractAssert receiver) so the terminal method is what decides, and/or extend UnitTestUtilsTest.testAssertJAssertionMethodPattern with the three new alternatives.

Drive the samples through BDDAssertions.then(...) so the new terminal-method names are what makes them compliant:

@Test
public void assertj_predicate_accepts() { // Compliant
  BDDAssertions.then(longPredicateMethod()).accepts(1L, 2L);
}

@Test
public void assertj_string_starts_with() { // Compliant
  BDDAssertions.then("hello world").startsWith("hello");
}

@Test
public void assertj_string_matches() { // Compliant
  BDDAssertions.then("hello").matches("[a-z]+");
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +102 to +105
private static boolean isDisabledTest(MethodTree methodTree) {
SymbolMetadata metadata = methodTree.symbol().metadata();
return metadata.isAnnotatedWith(JUNIT5_DISABLED_ANNOTATION) || metadata.isAnnotatedWith(JUNIT4_IGNORE_ANNOTATION);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: @Disabled/@ignore skipped only at method level, not class level

isDisabledTest only inspects the method symbol's metadata, so a test class annotated with @Disabled (JUnit 5) or @Ignore (JUnit 4) — whose methods are equally never executed, which is the PR's stated rationale — still raises S2699 on every assertion-less method. The same gap applies to a @Disabled enclosing @Nested class. Extend the check to the enclosing class(es).

Also consider the enclosing class annotations:

private static boolean isDisabledTest(MethodTree methodTree) {
  Symbol.TypeSymbol enclosingClass = methodTree.symbol().enclosingClass();
  return isDisabled(methodTree.symbol().metadata())
    || (enclosingClass != null && isDisabled(enclosingClass.metadata()));
}

private static boolean isDisabled(SymbolMetadata metadata) {
  return metadata.isAnnotatedWith(JUNIT5_DISABLED_ANNOTATION) || metadata.isAnnotatedWith(JUNIT4_IGNORE_ANNOTATION);
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +83 to +84
private static final String JUNIT5_DISABLED_ANNOTATION = "org.junit.jupiter.api.Disabled";
private static final String JUNIT4_IGNORE_ANNOTATION = "org.junit.Ignore";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Disabled-test annotation names duplicated from IgnoredTestsCheck

IgnoredTestsCheck (S1607) already encodes the same pair of fully qualified names (org.junit.Ignore, org.junit.jupiter.api.Disabled), and JUnit4AnnotationsCheck maps between them; the new private constants and isDisabledTest re-implement that knowledge in a third place, so a future addition (e.g. TestNG's org.testng.annotations.Ignore) has to be applied several times. Move the annotation list and the predicate into UnitTestUtils, next to the other test-annotation sets, and reuse it from both checks.

Add a shared helper in UnitTestUtils and call it from AssertionsInTestsCheck:

// UnitTestUtils.java
  public static final List<String> SKIPPED_TEST_ANNOTATIONS = List.of("org.junit.Ignore", "org.junit.jupiter.api.Disabled");

  public static boolean isSkippedTest(MethodTree methodTree) {
    SymbolMetadata metadata = methodTree.symbol().metadata();
    return SKIPPED_TEST_ANNOTATIONS.stream().anyMatch(metadata::isAnnotatedWith);
  }

// AssertionsInTestsCheck.java, replacing the two constants and isDisabledTest
      if (isSkippedTest(methodTree) || isSpringBootAssertableContext(methodTree)) {
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +60 to +63
@Override
public void visitAssertStatement(AssertStatementTree tree) {
hasAssertion = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Rule descriptions not updated for assert/@disabled exemptions

S2699's description enumerates exactly which assertion mechanisms are recognised ("assertions from any of the following known frameworks") and says nothing about Java's assert statement or about skipped tests being exempt, so after this PR the documented behaviour no longer matches the implementation for both S2699 and S6103 (the latter now accepts a bare assert inside an AssertJ consumer). Since these HTML files are generated from RSPEC, the corresponding RSPEC entries need updating along with the code change.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reviewing your code

Code Review ⚠️ Changes requested 0 resolved / 4 findings

Fixes false positives in S2699 by recognizing Java's assert statement, adding missing AssertJ method prefixes, and skipping disabled tests. Three issues must be addressed: new AssertJ samples don't exercise the predicate change (they use assertThat(...) which matches by name, leaving the terminal method names uncovered), disabled-test checks only apply at method level and should extend to class level, and annotation constants are duplicated across three checks instead of being centralized in UnitTestUtils.

⚠️ Quality: New AssertJ samples don't exercise the predicate change

📄 java-checks-test-sources/default/src/test/java/checks/tests/AssertionsInTestsCheck/AssertJ.java:274-288 📄 java-checks/src/main/java/org/sonar/java/checks/helpers/UnitTestUtils.java:61-62 📄 java-checks/src/main/java/org/sonar/java/checks/helpers/UnitTestUtils.java:295-304 📄 java-checks/src/main/java/org/sonar/java/checks/helpers/AbstractAssertionVisitor.java:37-42

All five new sample tests start their chain with Assertions.assertThat(...), whose name already matches ASSERTION_METHODS_PATTERN ((assert|verify|fail|...).*) in methodNameMatchesAssertionMethodPattern, so AbstractAssertionVisitor sets hasAssertion = true on the assertThat invocation itself and never needs to match the terminal accepts/startsWith/matches call. These samples therefore pass identically with or without the ASSERTJ_ASSERTION_METHODS_PREDICATE change, leaving the PR's main code change with zero coverage (the existing bdd_assertions_* samples in the same file use BDDAssertions.then(...) precisely because then is not name-matched). Use the BDD entry point (or a bare AbstractAssert receiver) so the terminal method is what decides, and/or extend UnitTestUtilsTest.testAssertJAssertionMethodPattern with the three new alternatives.

Drive the samples through BDDAssertions.then(...) so the new terminal-method names are what makes them compliant
@Test
public void assertj_predicate_accepts() { // Compliant
  BDDAssertions.then(longPredicateMethod()).accepts(1L, 2L);
}

@Test
public void assertj_string_starts_with() { // Compliant
  BDDAssertions.then("hello world").startsWith("hello");
}

@Test
public void assertj_string_matches() { // Compliant
  BDDAssertions.then("hello").matches("[a-z]+");
}
💡 Edge Case: @Disabled/@Ignore skipped only at method level, not class level

📄 java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

isDisabledTest only inspects the method symbol's metadata, so a test class annotated with @Disabled (JUnit 5) or @Ignore (JUnit 4) — whose methods are equally never executed, which is the PR's stated rationale — still raises S2699 on every assertion-less method. The same gap applies to a @Disabled enclosing @Nested class. Extend the check to the enclosing class(es).

Also consider the enclosing class annotations
private static boolean isDisabledTest(MethodTree methodTree) {
  Symbol.TypeSymbol enclosingClass = methodTree.symbol().enclosingClass();
  return isDisabled(methodTree.symbol().metadata())
    || (enclosingClass != null && isDisabled(enclosingClass.metadata()));
}

private static boolean isDisabled(SymbolMetadata metadata) {
  return metadata.isAnnotatedWith(JUNIT5_DISABLED_ANNOTATION) || metadata.isAnnotatedWith(JUNIT4_IGNORE_ANNOTATION);
}
💡 Quality: Disabled-test annotation names duplicated from IgnoredTestsCheck

📄 java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:83-84 📄 java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

IgnoredTestsCheck (S1607) already encodes the same pair of fully qualified names (org.junit.Ignore, org.junit.jupiter.api.Disabled), and JUnit4AnnotationsCheck maps between them; the new private constants and isDisabledTest re-implement that knowledge in a third place, so a future addition (e.g. TestNG's org.testng.annotations.Ignore) has to be applied several times. Move the annotation list and the predicate into UnitTestUtils, next to the other test-annotation sets, and reuse it from both checks.

Add a shared helper in UnitTestUtils and call it from AssertionsInTestsCheck
// UnitTestUtils.java
  public static final List<String> SKIPPED_TEST_ANNOTATIONS = List.of("org.junit.Ignore", "org.junit.jupiter.api.Disabled");

  public static boolean isSkippedTest(MethodTree methodTree) {
    SymbolMetadata metadata = methodTree.symbol().metadata();
    return SKIPPED_TEST_ANNOTATIONS.stream().anyMatch(metadata::isAnnotatedWith);
  }

// AssertionsInTestsCheck.java, replacing the two constants and isDisabledTest
      if (isSkippedTest(methodTree) || isSpringBootAssertableContext(methodTree)) {
💡 Quality: Rule descriptions not updated for assert/@Disabled exemptions

📄 java-checks/src/main/java/org/sonar/java/checks/helpers/AbstractAssertionVisitor.java:60-63 📄 java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

S2699's description enumerates exactly which assertion mechanisms are recognised ("assertions from any of the following known frameworks") and says nothing about Java's assert statement or about skipped tests being exempt, so after this PR the documented behaviour no longer matches the implementation for both S2699 and S6103 (the latter now accepts a bare assert inside an AssertJ consumer). Since these HTML files are generated from RSPEC, the corresponding RSPEC entries need updating along with the code change.

🤖 Prompt for agents
Code Review: Fixes false positives in S2699 by recognizing Java's `assert` statement, adding missing AssertJ method prefixes, and skipping disabled tests. Three issues must be addressed: new AssertJ samples don't exercise the predicate change (they use `assertThat(...)` which matches by name, leaving the terminal method names uncovered), disabled-test checks only apply at method level and should extend to class level, and annotation constants are duplicated across three checks instead of being centralized in `UnitTestUtils`.

1. ⚠️ Quality: New AssertJ samples don't exercise the predicate change
   Files: java-checks-test-sources/default/src/test/java/checks/tests/AssertionsInTestsCheck/AssertJ.java:274-288, java-checks/src/main/java/org/sonar/java/checks/helpers/UnitTestUtils.java:61-62, java-checks/src/main/java/org/sonar/java/checks/helpers/UnitTestUtils.java:295-304, java-checks/src/main/java/org/sonar/java/checks/helpers/AbstractAssertionVisitor.java:37-42

   All five new sample tests start their chain with `Assertions.assertThat(...)`, whose name already matches `ASSERTION_METHODS_PATTERN` (`(assert|verify|fail|...).*`) in `methodNameMatchesAssertionMethodPattern`, so `AbstractAssertionVisitor` sets `hasAssertion = true` on the `assertThat` invocation itself and never needs to match the terminal `accepts`/`startsWith`/`matches` call. These samples therefore pass identically with or without the `ASSERTJ_ASSERTION_METHODS_PREDICATE` change, leaving the PR's main code change with zero coverage (the existing `bdd_assertions_*` samples in the same file use `BDDAssertions.then(...)` precisely because `then` is not name-matched). Use the BDD entry point (or a bare `AbstractAssert` receiver) so the terminal method is what decides, and/or extend `UnitTestUtilsTest.testAssertJAssertionMethodPattern` with the three new alternatives.

   Fix (Drive the samples through BDDAssertions.then(...) so the new terminal-method names are what makes them compliant):
   @Test
   public void assertj_predicate_accepts() { // Compliant
     BDDAssertions.then(longPredicateMethod()).accepts(1L, 2L);
   }
   
   @Test
   public void assertj_string_starts_with() { // Compliant
     BDDAssertions.then("hello world").startsWith("hello");
   }
   
   @Test
   public void assertj_string_matches() { // Compliant
     BDDAssertions.then("hello").matches("[a-z]+");
   }

2. 💡 Edge Case: @Disabled/@Ignore skipped only at method level, not class level
   Files: java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

   `isDisabledTest` only inspects the method symbol's metadata, so a test class annotated with `@Disabled` (JUnit 5) or `@Ignore` (JUnit 4) — whose methods are equally never executed, which is the PR's stated rationale — still raises S2699 on every assertion-less method. The same gap applies to a `@Disabled` enclosing `@Nested` class. Extend the check to the enclosing class(es).

   Fix (Also consider the enclosing class annotations):
   private static boolean isDisabledTest(MethodTree methodTree) {
     Symbol.TypeSymbol enclosingClass = methodTree.symbol().enclosingClass();
     return isDisabled(methodTree.symbol().metadata())
       || (enclosingClass != null && isDisabled(enclosingClass.metadata()));
   }
   
   private static boolean isDisabled(SymbolMetadata metadata) {
     return metadata.isAnnotatedWith(JUNIT5_DISABLED_ANNOTATION) || metadata.isAnnotatedWith(JUNIT4_IGNORE_ANNOTATION);
   }

3. 💡 Quality: Disabled-test annotation names duplicated from IgnoredTestsCheck
   Files: java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:83-84, java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

   `IgnoredTestsCheck` (S1607) already encodes the same pair of fully qualified names (`org.junit.Ignore`, `org.junit.jupiter.api.Disabled`), and `JUnit4AnnotationsCheck` maps between them; the new private constants and `isDisabledTest` re-implement that knowledge in a third place, so a future addition (e.g. TestNG's `org.testng.annotations.Ignore`) has to be applied several times. Move the annotation list and the predicate into `UnitTestUtils`, next to the other test-annotation sets, and reuse it from both checks.

   Fix (Add a shared helper in UnitTestUtils and call it from AssertionsInTestsCheck):
   // UnitTestUtils.java
     public static final List<String> SKIPPED_TEST_ANNOTATIONS = List.of("org.junit.Ignore", "org.junit.jupiter.api.Disabled");
   
     public static boolean isSkippedTest(MethodTree methodTree) {
       SymbolMetadata metadata = methodTree.symbol().metadata();
       return SKIPPED_TEST_ANNOTATIONS.stream().anyMatch(metadata::isAnnotatedWith);
     }
   
   // AssertionsInTestsCheck.java, replacing the two constants and isDisabledTest
         if (isSkippedTest(methodTree) || isSpringBootAssertableContext(methodTree)) {

4. 💡 Quality: Rule descriptions not updated for assert/@Disabled exemptions
   Files: java-checks/src/main/java/org/sonar/java/checks/helpers/AbstractAssertionVisitor.java:60-63, java-checks/src/main/java/org/sonar/java/checks/tests/AssertionsInTestsCheck.java:102-105

   S2699's description enumerates exactly which assertion mechanisms are recognised ("assertions from any of the following known frameworks") and says nothing about Java's `assert` statement or about skipped tests being exempt, so after this PR the documented behaviour no longer matches the implementation for both S2699 and S6103 (the latter now accepts a bare `assert` inside an AssertJ consumer). Since these HTML files are generated from RSPEC, the corresponding RSPEC entries need updating along with the code change.

Implementation Status ✅ 3 of 3 objectives covered
SONARJAVA-6833 - 3 of 3 objectives covered

This PR successfully implements all objectives: it adds missing AssertJ assertion methods, skips assertion checking for disabled or ignored tests, and recognizes Java built-in assert statements as valid assertions.

✅ 3 covered here
  • ✅ Add missing AssertJ assertion methods (startsWith, accepts, matches, isLowerCase, doesNotHaveDuplicates) to the set of recognized AssertJ assertions
  • ✅ Skip assertion checking for test methods annotated with JUnit 5's @Disabled or JUnit 4's @Ignored
  • ✅ Recognize Java's built-in assert statement as a valid assertion
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

@datadog-sonarsource

This comment has been minimized.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Ruling needs updating. A fix PR has been created: #6097

Please review and merge it into your branch.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Ruling Diff Summary

Detected changes in 2 rule files: 2 issues removed, 0 issues added.

S2699 (java) on commons-beanutils - 1 issues removed, 0 issues added

Removed src/test/java/org/apache/commons/beanutils2/bugs/Jira509TestCase.java (line 55)

        50 |     /**
        51 |      * The bug makes the {@link WrapDynaClass#createDynaClass} method run in an infinite loop and acquire locks. The
        52 |      * test case adds a timeout. The test case may pass even without this fix because this is a rare scenario.
        53 |      */
        54 |     @Test(timeout = 60_000)
>>>     55 |     public void test_concurrent() throws InterruptedException {
        56 |         final List<Class<?>> classList = Arrays.asList(Map.class, HashMap.class, Collections.class, Arrays.class,
        57 |                 Collection.class, Set.class, ArrayList.class, List.class, HashSet.class);
        58 | 
        59 |         // All daemon threads.
        60 |         final ExecutorService executor = Executors.newFixedThreadPool(100, new ThreadFactory() {
S2699 (java) on eclipse-jetty - 1 issues removed, 0 issues added

Removed jetty-server/src/test/java/org/eclipse/jetty/server/ssl/SelectChannelServerSslTest.java (line 237)

(source file not found at this revision: jetty-server/src/test/java/org/eclipse/jetty/server/ssl/SelectChannelServerSslTest.java)

@sonarqube-next

sonarqube-next Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant