Skip to content

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

A loop loads one customer per order
@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:

SELECT: executed 6, expected at most 2.

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:

query-audit:
  await-executors: [taskExecutor]
  contracts:
    path: src/test/resources/query-contracts
@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:

-@junit | link-list | 2 | 0 | 0 | 0 | 2
+@junit | link-list | 3 | 0 | 0 | 0 | 3

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
UPDATE: executed 1, expected at most 0.

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 repeats countByUserId for 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