N+1 AND QUERY REGRESSIONS FOR JUNIT 5
Catch N+1 queries
before merge.
QueryAudit reports an N+1 at the line that issues it, then locks the fixed query counts into contracts that every pull request has to keep.
Java 17+ · JUnit 5 · Test dependencies only
@Test @QueryAudit
void listsOrderSummaries() { … }
[ERROR] N+1 Query detected (table: customers)
The same SELECT ran 5 times from one call site
at OrderService.recentOrderSummaries:42
Fetch the customers once. Run the test again.
"outcome": "PASS" Find an N+1¶
After installing the starter, add @QueryAudit to a test that reads related data:
@SpringBootTest
@QueryAudit
class OrderServiceTest {
@Autowired OrderService orderService;
@Test
void listsOrderSummaries() {
assertEquals(5, orderService.recentOrderSummaries().size());
}
}
With no configuration, one rule runs: the same SELECT with different values, three or more times, from one full application call stack. If recentOrderSummaries() loads each order's customer inside its loop, the test fails at that line:
QueryAudit detected 1 issue(s) in listsOrderSummaries():
[ERROR] N+1 Query detected (table: customers)
Detail: The same SELECT ran 5 times from one call site
Suggestion: Load the rows once before the loop: JOIN FETCH, @EntityGraph, or one query with an IN list.
Call stack:
at com.example.order.OrderService.recentOrderSummaries:42
at com.example.order.OrderServiceTest.listsOrderSummaries:18
A batched IN (?, ?, ...) fetch is the fix, not the problem, so @BatchSize and batch fetching stay quiet. Paging with OFFSET is not an N+1, and repeating a lookup with the same values is reported as INFO without failing the test. Hibernate lazy-load events add an INFO line that names the association to fetch. Use @EnableQueryInspector instead of @QueryAudit to survey an existing suite without failing it.
How N+1 detection works → · Read SQL and call sites
Lock the fix¶
A budget is a limit you write on the test. At most two SELECTs and no writes:
@Test
@ExpectQueries(select = 2, insert = 0, update = 0, delete = 0)
void listsOrderSummaries() {
assertEquals(5, orderService.recentOrderSummaries().size());
}
If the loop comes back, the test fails even though the returned data is still correct:
A contract is a count QueryAudit records for you. QueryContractScope counts only the request or job you hand it, not fixture setup or assertions, and waits for the thread pools you name:
@Autowired QueryContractScope contracts;
@Test
void listsLinks() throws Exception {
contracts.verify("link-list", () -> mockMvc.perform(get("/api/v1/links")))
.andExpect(status().isOk());
}
Record once with -DqueryAudit.contracts.record=true, commit the file, and review every later change as one line in the pull request:
Without the re-record, the test fails with the delta and the SQL that grew. Whole test methods can keep contracts in the same file.
Try a budget failure with the published library and in-memory H2:
git clone https://github.com/haroya01/query-audit.git
cd query-audit
./gradlew -p examples/first-audit test -PextraWrite=true --rerun-tasks
Rerun without -PextraWrite=true and the test passes.
Lock a fixed path → · Contracts · Quick start
Gate the pull request¶
Save the JSON report from the base branch and from the pull request, then compare them with the matching core JAR:
java -cp "$QUERY_AUDIT_CORE_JAR" \
io.queryaudit.core.reporter.ReportComparator before.json after.json verdict.json
| The pull request… | Result |
|---|---|
| adds no confirmed finding and keeps every budget and contract | PASS |
| breaks a budget or a contract | FAIL |
| skips or loses a test listed in the expected-test manifest | INCONCLUSIVE |
| changes rules, thresholds, or required analysis inputs | INCONCLUSIVE |
Add --require-resolved <findingId> to prove that one specific finding is gone.
Set up the first CI check → · Compare two runs · Comparison inputs
Used on a production service¶
QueryAudit is dogfooded on short-link, a production URL shortener built with Spring Boot and MySQL. Its test suite is the acceptance test for 0.7:
- On the same 45 audited tests, the default confirmed findings went from 142 under 0.6.0 to one N+1: bulk link creation looks up each new code with
findByShortCode. The same loop repeatscountByUserIdfor one user, which is reported as INFO. 0.6.0 had reported neither. - All 584 HTTP query contracts kept the same counts after the move from a hand-written helper to
QueryContractScope, which needs no internal QueryAudit class. - One injected extra SELECT in link creation failed 13 contracts across 8 test classes, and each failure listed the repeated statement with its call site.
Need more than N+1? Index, EXPLAIN, and SQL style rules turn on with profile: strict or enabled-rules. Optional rules
Check your setup: Spring Boot · Plain JUnit · QuickPerf comparison · Versions · Known limitations