Your team keeps re-introducing code paths that fire one SQL statement per row, and it is only noticed in production. How would you turn the number of SQL statements a code path executes into an automatically enforced, failing assertion in the test suite?
answer
- clear() -> run -> assert getPrepareStatementCount()
- datasource-proxy/p6spy: per-type counts + the SQL in the message
- assert equals, not <=
- flush+clear first, or the first-level cache hides work
- single-threaded, real dialect, realistic row counts
basics
~20 sCount statements around the code and assert on the count. Either clear Hibernate's Statistics and assert getPrepareStatementCount(), or wrap the DataSource with datasource-proxy/p6spy and assert its captured query count. Run the assertion in a single-threaded test against a real database dialect.
solid answer
~60 sMake statement count a measured value, not something a reviewer eyeballs. Two instrumentation points: 1. **Hibernate's own counters** — enable `hibernate.generate_statistics`, call `statistics.clear()`, run the code path, assert `getPrepareStatementCount()` (and optionally entity/collection load counts). Zero extra dependencies, but it only sees what goes through Hibernate. 2. **A JDBC proxy** — `datasource-proxy` (or p6spy) wraps the `DataSource` and records every statement with its text and type. That catches native JDBC too, lets you assert *by category* (`selects <= 3`, `updates == 1`), and gives you the offending SQL in the failure message. Rules that make it stick: assert an exact number, not an upper bound, so improvements as well as regressions break the test; flush/clear the persistence context before measuring so first-level cache hits do not hide work; run single-threaded against the real database (or the real dialect), because a different dialect changes batching and generated SQL; and put the assertion in the test that exercises the realistic data shape — a fixture with one child row will never reveal per-row fetching.
code
java · 13 lines@Test
void dashboardIssuesThreeStatements() {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
em.flush();
em.clear(); // nothing pre-managed
stats.clear(); // start from zero
service.loadDashboard(userId);
assertEquals(3, stats.getPrepareStatementCount());
assertEquals(0, stats.getCollectionFetchCount()); // collections came via join
}go deeper
Know that Hibernate can count statements and that a test can assert on that number after clearing the counters.
Show the clear-run-assert pattern and explain why the persistence context must be cleared first and why the fixture needs several child rows.
Argue instrumentation choice (ORM counters versus JDBC proxy), insist on exact counts, real dialect and single-threaded execution, and place assertions only on paths where fan-out hurts.
Treat query budgets as a policy: which paths carry them, how they interact with parallel test execution and containerised databases, and how they pair with production-side statement statistics for the code no test covers.
## Why this needs automation Per-row fetching is invisible in code review and invisible in a test that asserts only on returned values. It is also invisible in a fixture with two rows: two extra statements cost nothing. It becomes an incident when the same code meets a thousand rows. The only durable defence is to make the statement count a first-class, asserted property of a code path, so any change that multiplies it fails the build. ## Instrumentation option 1: Hibernate Statistics With `hibernate.generate_statistics=true`, the pattern is clear-run-assert: ``` stats.clear(); service.loadDashboard(userId); assertEquals(3, stats.getPrepareStatementCount()); ``` Strengths: no dependency, and you can assert on richer ORM-level facts at the same time — `getEntityLoadCount()`, `getCollectionFetchCount()` (collections fetched by a separate statement rather than a join), `getQueryExecutionCount()`. Limits: counters are factory-wide, so the test must be single-threaded and must not share a factory with concurrent tests; only Hibernate's traffic is counted; and the failure message is just a number — you still have to turn SQL logging on to see which statements they were. ## Instrumentation option 2: a JDBC proxy `datasource-proxy` wraps the `DataSource` and hands every execution to a listener, so you can build a per-test recorder. `p6spy` does the same at driver level. Advantages that matter in practice: - **Everything is counted**, including native JDBC, Flyway/Liquibase-style plumbing, and statements from a second ORM. - **Categorised assertions**: select/insert/update/delete counts separately, so a test can pin 'exactly one UPDATE' without caring about reads. - **Good failure messages**: the recorder can print the captured SQL, which turns a red build into a diagnosis without a rerun. - **Batch awareness**: proxies report batch executions and their sizes, which lets you assert that batching is actually happening rather than assuming it. ## Making the assertion meaningful **Assert equality, not `<=`.** An upper bound silently tolerates the whole budget being consumed and hides improvements. An exact count forces every change in query shape to be acknowledged in the diff, which is the point. **Control the persistence context.** If the entity you are about to read is already managed, no statement is issued and the count is a lie about production behaviour. Flush and clear (or use a fresh session) before the measured block. Similarly, clear the second-level cache between runs, or the second execution of the same test measures a warm cache. **Use realistic data.** The fixture must contain enough child rows that a per-row pattern shows up as a different number than a joined fetch. Two parents with three children each is usually enough to separate 1 statement from 3. **Use the real database.** An in-memory database with a different dialect can generate different SQL, support different batching, and behave differently for identity generation — and identity generation in particular disables JDBC insert batching, so a count that passes on one dialect can fail on another for reasons unrelated to the code under test. Containerised real databases remove that whole class of false signal. **Keep it single-threaded and isolated.** Both instrumentation strategies aggregate globally. Parallel test execution against a shared factory or DataSource will produce flaky counts. ## Where to put the assertions Do not sprinkle them everywhere: a count assertion on every test makes the suite brittle, because every legitimate query change touches dozens of tests. Put them on the handful of paths where fan-out actually hurts — list endpoints, batch jobs, report builders, anything that iterates a collection of aggregates. A small dedicated set of 'query budget' tests is more maintainable and more honest than a global rule. ## Complementary signals Count assertions catch regressions in code you have tests for. Pair them with: `hibernate.use_sql_comments` so production statement logs name the source query; a slow-query threshold so pathological single statements surface; and database-side aggregation (statement-level statistics views) so you can see, in production, which statement text dominates total execution time. Counting in tests is prevention; the production signals are detection for everything the tests do not cover.
- A query-count test passes locally against an in-memory database and fails against the real one. What are the usual causes?Dialect differences change generated SQL and batching. The classic one is identity-column key generation, which forces Hibernate to execute each INSERT immediately and disables JDBC batching, turning one batched statement into N. Sequence or table generators with an allocation size restore batching. Schema differences, vendor-specific pagination and different default fetch behaviour for the same mapping can also shift the count.
- Why assert an exact count rather than a maximum?A maximum tolerates silent drift right up to the budget and never fails when someone makes the path worse but still within bounds, and it never notices an improvement, so the budget rots upward over time. An exact count makes any change in query shape a visible, deliberate line in the diff, which is exactly the review signal you wanted.
saying these in an interview costs you the question
- Asserting counts against a fixture with one child row, where per-row fetching and a join produce the same number
- Measuring without clearing the persistence context, so already-managed entities suppress the statements you meant to count
- Running count assertions in parallel against a shared SessionFactory or DataSource and then calling the test flaky
- Assuming Hibernate's statement counter also sees plain JDBC or another framework's statements
- Putting a count assertion on every test, making ordinary query changes break dozens of unrelated tests