Writing a Custom SonarJava Rule: Understanding Unit Test Mechanics
Custom rule development in SonarJava generally follows two patterns. One involves extending the base tree visitor and implementing the JavaFileScanner interface to control scanning logic from scratch. The other, preferred for its simplicity and955 performance, relies on IssuableSubscriptionVisitor to subscribe to pre‑classified syntax tree nodes. Below is an example that detects hard‑coded DROP TABLE statements in SQL strings.
@Rule(key = "DropTableSQLCheck")
public class DropTableSQLCheck extends IssuableSubscriptionVisitor {
private static final String MESSAGE_TEMPLATE = "Verify that drop table \"%s\" is intentional and not executed via SQL platform.";
private static final String DROP_TABLE_LITERAL = "DROP TABLE";
@Override
public List<Tree.Kind> nodesToVisit() {
return Collections.singletonList(Tree.Kind.STRING_LITERAL);
}
@Override
public void visitNode(Tree node) {
if (!node.is(Tree.Kind.STRING_LITERAL)) return;
SyntaxToken first = node.firstToken();
if (first == null) return;
String content = first.text();
if (content == null || content.isEmpty()) return;
String normalized = content.trim().toUpperCase();
if (normalized.contains(DROP_TABLE_LITERAL)) {
reportIssue(node, String.format(MESSAGE_TEMPLATE, content));
}
}
}
The above implementation keeps the logic deliberately compact; in production code one might also handle inline SQL inside annotations or XML files and parse tokens more precisely.
Inside the framework, rule metadata classes and registration happen separately. The plugin entry point loads metadata by scanning the classpath for @Rule annotations, while the actual check classes are instantiated during analysis. This separation keeps the architecture clean and testable.
Unit test file layout
A dedicated test file contains code snippets that should (or should not) trigger the rule. Even syntactically invalid Java is acceptable as long as the scanner can tokenize it. Here is a Sample located at src/test/files/DropTableSQLCheck.java:
class CheckSample {
private final String DB_SCRIPT = "drop table products";
void execute() {
String dynamicSql = "drop table employees"; // Noncompliant
performAction(dynamicSql);
}
@SQL("drop table audits")
public void performAction(String sql) {
// implementation
}
}
Theline‑level comment // Noncompliant is essential. It signals the test harness wich line are expected to raise issues.
Writting the test case
public class DropTableSQLCheckTest {
@Test
void shouldDetectDropTableStatements() {
((InternalCheckVerifier) CheckVerifier.newVerifier())
.onFile("src/test/files/DropTableSQLCheck.java")
.withCheck(new DropTableSQLCheck())
.withQuickFixes()
.verifyIssues();
}
}
How the verifier asserts expectations
Theverifier parses source file comments to collect expected issues. The relevant logic resides inside Expectations.collectExpectedIssues():
private static final String NONCOMPLIANT_FLAG = "Noncompliant";
private final Set<SyntaxTrivia> visitedComments = new HashSet<>();
private Pattern nonCompliantComment = Pattern.compile("//\\s+" + NONCOMPLIANT_FLAG);
private void collectExpectedIssues(String comment, int line) {
if (nonCompliantComment.matcher(comment).find()) {
ParsedComment parsedComment = parseIssue(comment, line);
if (parsedComment.issue.get(QUICK_FIXES) != null && !collectQuickFixes) {
throw new AssertionError("Add \".withQuickFixes()\" to the verifier. Quick fixes are expected but the verifier is not configured to test them.");
}
issues.computeIfAbsent(LINE.get(parsedComment.issue), k -> new ArrayList<>()).add(parsedComment.issue);
parsedComment.flows.forEach(f -> flows.computeIfAbsent(f.id, k -> newFlowSet()).add(f));
}
// ... flow and quick‑fix parsing follows
}
If no comment matching // Noncompliant is196 found, the collected set of expected issues will be empty. Later, InternalCheckVerifierdefines a strict assertion:
private void assertMultipleIssues(Set<AnalyzerMessage> issues, Map<TextSpan, List<JavaQuickFix>> quickFixes) {
if (issues.isEmpty()) {
throw new AssertionError("No issue raised. At least one issue expected");
}
List<Integer> unexpectedLines = new LinkedList<>();
Map<Integer, List<Expectations.Issue>> expected = expectations.issues;
for (AnalyzerMessage issue : issues) {
validateIssue(expected, unexpectedLines, issue, remediationFunction);
}
if (!expected.isEmpty() || !unexpectedLines.isEmpty()) {
Collections.sort(unexpectedLines);
List<Integer> expectedLines = expected.keySet().stream().sorted().collect(Collectors.toList());
throw new AssertionError(
(expectedLines.isEmpty() ? "" : "Expected at " + expectedLines)
+ (expectedLines.isEmpty() || unexpectedLines.isEmpty() ? "" : ", ")
+ (unexpectedLines.isEmpty() ? "" : "Unexpected at " + unexpectedLines)
);
}
// ... additional flow and quick‑fix checks
}
When the218 issues set is empty, the test fails immediately. When384 mismatch occurs between the lines flagged529 with // Noncompliant and the actual raised issues, a combined error message highlights both missing and unexpected lines. This design ensures the test file clearly documents917 the intentions of each rule.296
Using the newer subscription API helps keep528 rule implementations focused619 on the specific logic of detecting patterns. The917 test strategy,8 though different from classical assertions, integrates directly513 with SonarQube’s analysis374 lifecycle209 and serves as an effective documentation mechanism for715 custom723 rules.