Skip to content

Annotations Guide

Auditing annotations register QueryAuditExtension automatically. @QueryAuditExclude is the opt-out marker; it does not activate auditing on its own. Start with Lock a Fixed Path to choose between a budget and a contract. This reference documents the published 0.7.2 annotation API.


Quick Reference

Annotation Target Purpose Test Failure
@QueryAudit Class / Method Run the configured detection profile Yes (configurable)
@EnableQueryInspector Class Report findings without enforcing them No finding failure; separate budget annotations still assert
@ExpectQueries Method Assert query budgets, or exact counts, per type and in total Yes (on budget exceeded, or any difference with exact)
@DetectNPlusOne Class / Method Deprecated since 0.7.0; use @QueryAudit(failOn = N_PLUS_ONE) Yes (on N+1 only)
@ExpectMaxQueryCount Method Deprecated since 0.7.0; use @ExpectQueries(total = n) Yes (on count exceeded)
@QueryAuditExclude Class / Method Opt a test out of auditing (the mode: all escape hatch) No

Annotation Attributes at a Glance

Attribute Annotation Type Default Description
failOnDetection @QueryAudit BooleanOverride INHERIT (yml default: true) Fail the test on confirmed issues
nPlusOneThreshold @QueryAudit int -1 (use yml/default: 3) Override N+1 threshold
suppress @QueryAudit String[] {} Issue codes to suppress
failOn @QueryAudit IssueType[] {} (all confirmed) Only fail on specific issue types
failOnKinds @QueryAudit String[] {} Select built-in/custom kind codes; union with failOn
baselinePath @QueryAudit String "" (default path) Path to baseline file
autoOpenReport @QueryAudit BooleanOverride INHERIT (yml default: true) Open HTML report in browser after tests
includeSetupQueries @QueryAudit boolean false Include @BeforeEach/@AfterEach queries in analysis
threshold @DetectNPlusOne int 3 Repeated query count to consider N+1
value @ExpectMaxQueryCount int (required) Maximum number of queries allowed
select @ExpectQueries int -1 (not verified) Maximum SELECT queries allowed
insert @ExpectQueries int -1 (not verified) Maximum INSERT queries allowed
update @ExpectQueries int -1 (not verified) Maximum UPDATE queries allowed
delete @ExpectQueries int -1 (not verified) Maximum DELETE queries allowed
total @ExpectQueries int -1 (not verified) Maximum captured statements of any type
exact @ExpectQueries boolean false Since 0.7.2: require each declared count exactly

Choose how findings are treated with one class-level annotation: @QueryAudit fails the test on confirmed findings, and @EnableQueryInspector reports them without failing. Put query budgets on the method with @ExpectQueries. Under the default profile, the only built-in finding is a call-site N+1.

Why BooleanOverride instead of boolean?

Java annotation attributes cannot distinguish between "explicitly set to default" and "not specified" with a boolean type. BooleanOverride is a tri-state enum (INHERIT/TRUE/FALSE). An unspecified value on the selected method-level or class-level annotation falls back to application.yml or built-in defaults. A method-level annotation replaces the class declaration, as described below. Use BooleanOverride.TRUE or BooleanOverride.FALSE to explicitly override.


@QueryAudit

The primary annotation. Enables query analysis using the configured profile across SELECT, INSERT, UPDATE, and DELETE statements.

@SpringBootTest
@QueryAudit
class OrderServiceTest {

    @Test
    void findOrders() {
        // Analyze SELECT/INSERT/UPDATE/DELETE captured through the audited DataSource.
        // Test fails if any confirmed issue (ERROR or WARNING) is detected.
    }
}

Attributes

Attribute Type Default Description
failOnDetection BooleanOverride INHERIT (yml default: true) Fail the test on confirmed issues. Use BooleanOverride.TRUE/FALSE to override.
nPlusOneThreshold int -1 (use yml/default: 3) Override N+1 threshold
suppress String[] {} Issue codes to suppress
failOn IssueType[] {} (all confirmed) Only fail on specific issue types
failOnKinds String[] {} Select namespaced custom or built-in kinds; both selection arrays empty means all confirmed findings
baselinePath String "" (default path) Path to baseline file
autoOpenReport BooleanOverride INHERIT (yml default: true) Open HTML report in browser after tests
includeSetupQueries boolean false Include queries from @BeforeEach/@AfterEach lifecycle methods in analysis. Default analyzes only @Test body queries.

Usage Patterns

@QueryAudit
@SpringBootTest
class OrderServiceTest {
    // Fails on any confirmed issue
}
@QueryAudit(failOnDetection = BooleanOverride.FALSE)
@SpringBootTest
class OrderServiceTest {
    // Findings are advisory; explicit budgets and contracts still apply.
}
@QueryAudit(nPlusOneThreshold = 5)
@SpringBootTest
class BatchJobTest {
    // Higher threshold for batch operations
}
@QueryAudit(failOn = {IssueType.N_PLUS_ONE, IssueType.UPDATE_WITHOUT_WHERE})
@SpringBootTest
class OrderServiceTest {
    // Only fails on N+1 and WHERE-less UPDATE/DELETE
}
@QueryAudit(suppress = {"select-all", "offset-pagination"})
@SpringBootTest
class LegacyServiceTest {
    // Known issues suppressed while migrating
}

Class-Level vs Method-Level

When both class-level and method-level annotations are present, the method-level annotation takes precedence and replaces (not merges) the class-level settings.

@QueryAudit(nPlusOneThreshold = 5)  // Class-level: applies to all tests
@SpringBootTest
class OrderServiceTest {

    @Test
    void findOrders() {
        // Uses class-level config: threshold=5, failOnDetection=true
    }

    @QueryAudit(failOnDetection = BooleanOverride.FALSE)  // Method-level: overrides entire class config
    @Test
    void exportAll() {
        // Report only for this specific test.
        // nPlusOneThreshold reverts to default (3), NOT the class-level 5,
        // because method-level replaces (not merges) the class-level settings.
    }

    @QueryAudit(nPlusOneThreshold = 2, suppress = {"select-all"})
    @Test
    void findTopOrders() {
        // Method-level: strict threshold, select-all suppressed.
        // Class-level threshold of 5 is ignored.
    }
}

Configuration priority

method-level > class-level > application.yml > built-in defaults

When a method-level @QueryAudit is present, it completely replaces the class-level annotation. Attributes not set on the method-level annotation fall back to application.yml or built-in defaults -- not to the class-level values.


@EnableQueryInspector

Lightweight report-only mode. Equivalent to @QueryAudit(failOnDetection = BooleanOverride.FALSE).

@SpringBootTest
@EnableQueryInspector
class OrderServiceTest {

    @Test
    void findOrders() {
        // Reports detected findings without making those findings fatal.
        // Explicit budgets and contracts still apply.
    }
}

Use this for gradual adoption

Start with @EnableQueryInspector to review findings before making them fatal. Explicit budgets, contracts, and capture or reporting failures can still fail the run. Switch to @QueryAudit when reviewed findings should fail the test too.


@QueryAuditExclude

Opts a test class or method out of auditing.

Built for mode: all suites (see Audit Coverage Mode), where every test is audited by default and this is the escape hatch for tests that intentionally violate a rule — load-shape fixtures, migration replays, bulk seed helpers.

@QueryAuditExclude   // intentionally replays thousands of single-row inserts
class SeedDataReplayTest { ... }

Also honored in the default annotated mode: a method carrying @QueryAuditExclude is skipped even when its class is annotated with @QueryAudit.

@QueryAudit
class OrderServiceTest {

    @Test
    void findOrders() { ... }          // audited

    @QueryAuditExclude
    @Test
    void bulkFixtureReplay() { ... }   // skipped
}

@DetectNPlusOne

Deprecated since 0.7.0

N+1 is the default rule, so @QueryAudit(failOn = IssueType.N_PLUS_ONE, nPlusOneThreshold = 2) does the same. @DetectNPlusOne keeps working.

Focused annotation that only fails on N+1 patterns. All other detection rules still run and report, but won't cause a test failure.

Class-Level Example

@SpringBootTest
@DetectNPlusOne
class OrderServiceTest {

    @Test
    void findOrdersWithItems() {
        List<Order> orders = orderService.findAll();
        for (Order order : orders) {
            order.getItems().size();  // N+1! Test will fail.
        }
    }

    @Test
    void findSingleOrder() {
        orderService.findById(1L);
        // No N+1 here, test passes.
        // Other issues (e.g., SELECT *) are reported but don't fail.
    }
}

Method-Level Example

@SpringBootTest
@QueryAudit  // Full analysis for most tests
class OrderServiceTest {

    @DetectNPlusOne(threshold = 2)  // Only fail on N+1 for this specific test
    @Test
    void findOrdersWithItems() {
        List<Order> orders = orderService.findAll();
        for (Order order : orders) {
            order.getItems().size();
        }
    }
}

Attributes

Attribute Type Default Description
threshold int 3 Repeated query count to consider N+1

How N+1 Detection Works

QueryAudit detects N+1 at two levels:

  1. SQL-level (all environments): Normalizes each query (SELECT * FROM items WHERE id = ?), groups by pattern, and counts executions. If the same pattern appears >= threshold times from the same call site, it's flagged.

  2. Hibernate-level (when Hibernate is on classpath): Registers as a Hibernate event listener for INIT_COLLECTION and POST_LOAD events. This catches:

    • @OneToMany / @ManyToMany lazy collection loading
    • @ManyToOne / @OneToOne proxy resolution

Hibernate event findings use ERROR severity. Review them with actual SQL counts and fetch settings; they do not establish the cost of the operation or exclude false positives.

Test Code
    |
    v
orderService.findAll()  --- SQL-level: "SELECT * FROM orders" (1 execution)
    |
    v
for (order : orders)
    order.getItems()    --- SQL-level: "SELECT * FROM items WHERE order_id = ?" (N executions)
                        --- Hibernate-level: INIT_COLLECTION event for Order.items (N loads)
                        --- Both detect N+1: pattern repeated N times

@ExpectMaxQueryCount

Deprecated since 0.7.0

Use @ExpectQueries(total = 5), which reports the total with the other budgets. @ExpectMaxQueryCount keeps working.

Asserts that a test method does not exceed a specific number of total queries. All query types (SELECT, INSERT, UPDATE, DELETE) are counted.

@SpringBootTest
@QueryAudit
class OrderServiceTest {

    @Test
    @ExpectMaxQueryCount(5)
    void createOrder() {
        orderService.createOrder(request);
        // Fails if more than 5 total queries are executed
    }

    @Test
    @ExpectMaxQueryCount(3)
    void findOrderById() {
        orderService.findById(1L);
        // Ensures a simple lookup stays efficient
    }
}

Attributes

Attribute Type Default Description
value int (required) Maximum number of queries allowed

Failure Message

When exceeded, you get:

QueryAudit: createOrder executed 8 queries, expected at most 5.
Tip: Check the Query Patterns section in the report above to identify which queries to optimize.

Counts ALL queries

@ExpectMaxQueryCount counts all query types, including INSERTs from test data setup. If you use @BeforeEach to seed data, those INSERTs are included in the count. Consider using a higher limit or moving setup to @BeforeAll.


@ExpectQueries

Asserts per-type query budgets for a test method. Each attribute limits one query type (SELECT / INSERT / UPDATE / DELETE) independently; attributes left at -1 are not verified.

@SpringBootTest
@QueryAudit
class OrderServiceTest {

    @Test
    @ExpectQueries(select = 2, insert = 1)
    void createOrder() {
        orderService.createOrder(request);
        // Fails if more than 2 SELECTs or more than 1 INSERT are executed
    }

    @Test
    @ExpectQueries(insert = 0, update = 0, delete = 0)
    void findOrderById() {
        orderService.findById(1L);
        // A captured INSERT, UPDATE, or DELETE fails this budget.
    }
}

Attributes

Attribute Type Default Description
select int -1 (not verified) Maximum SELECT queries allowed
insert int -1 (not verified) Maximum INSERT queries allowed
update int -1 (not verified) Maximum UPDATE queries allowed
delete int -1 (not verified) Maximum DELETE queries allowed
total int -1 (not verified) Maximum captured statements of any type
exact boolean false Since 0.7.2: require each declared count exactly instead of as a maximum

Exact counts

Since 0.7.2, exact = true makes every declared attribute an exact count. The test also fails when fewer statements run, which an upper bound cannot catch: a cache that starts hiding a lookup, or a capture that silently stopped seeing the DataSource.

@Test
@ExpectQueries(select = 2, insert = 0, exact = true)
void findOrderWithCustomer() {
    orderService.findWithCustomer(1L);
    // Fails on one SELECT as well as on three. UPDATE and DELETE stay unchecked.
}
QueryAudit: findOrderWithCustomer() did not match its exact query counts.
SELECT: executed 1, expected exactly 2.
  select * from orders where id = ?
    at com.example.OrderService.findWithCustomer:31

Attributes left at -1 stay unchecked. To pin every statement type of a test, a request, or a job without writing the numbers by hand, record a contract instead.

Failure Message

When a budget is exceeded, the diagnostic lists captured statements of the violated type and available source information. The console's first frame can be a JDBC proxy; inspect the JSON stackTrace for application frames. Example excerpt:

QueryAudit: createOrder() exceeded its query budget.
SELECT: executed 3, expected at most 2.
  select * from orders where customer_id = ?
    at com.example.OrderService.createOrder:42
  select * from members where id = ?
    at com.example.OrderService.loadCustomer:57
  ...

Use 0 to forbid a query type

@ExpectQueries(insert = 0, update = 0, delete = 0) turns a test into a read-only contract -- useful for guarding query-only endpoints against accidental writes.

Counts ALL queries

Budgets count queries from the whole test lifecycle, including INSERTs from @BeforeEach data setup. To count one request or job only, use a scoped contract.

total caps every statement while the type attributes constrain individual types; both can be set on one annotation.


Combining Annotations

Annotations can be combined for fine-grained control:

@SpringBootTest
@QueryAudit(nPlusOneThreshold = 2)  // Fail on N+1 from the second repetition
class OrderServiceTest {

    @Test
    @ExpectQueries(total = 10, update = 0)  // Also enforce budgets
    void createOrder() {
        orderService.createOrder(request);
    }

    @Test
    void findOrders() {
        // Only the class-level @QueryAudit applies here
        orderService.findAll();
    }
}

Composed and Inherited Annotations

QueryAudit finds its annotations where JUnit finds the extension, so a shared test annotation or an audited base class turns auditing on for every test that uses it:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@QueryAudit
public @interface AuditedIntegrationTest {}

@AuditedIntegrationTest
class OrderServiceTest { ... }

@QueryAudit
abstract class AuditedTestBase { ... }

class PaymentServiceTest extends AuditedTestBase { ... }
Declared on Applies
The test method, directly or through a composed annotation First
The test class, directly, through a composed annotation, or through an implemented interface Next
A superclass, nearest first Next
An enclosing class, then its superclasses Last

The nearest declaration wins, so @QueryAudit(nPlusOneThreshold = 4) on a subclass replaces the settings of an audited base class. @QueryAuditExclude is the exception: on the method, the class, a superclass, or an enclosing class, it excludes the test even when a nearer declaration enables auditing. @ExpectQueries and @ExpectMaxQueryCount target methods only, so put them on each test method.


Without Spring Boot

All annotations work without Spring Boot. Expose the ProxyDataSource used by the repository as a static field so QueryAudit can discover it and attach its listener:

@QueryAudit
class OrderRepositoryTest {

    static DataSource dataSource =
            ProxyDataSourceBuilder.create(new HikariDataSource(hikariConfig()))
                    .name("query-audit")
                    .build();

    @Test
    void findByStatus() {
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement ps = conn.prepareStatement(
                "SELECT * FROM orders WHERE status = ?");
            ps.setString(1, "PENDING");
            ResultSet rs = ps.executeQuery();
            // This connection comes from the discovered proxy.
        }
    }

    private static HikariConfig hikariConfig() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/test");
        config.setUsername("root");
        config.setPassword("password");
        return config;
    }
}

Add net.ttddyy:datasource-proxy:1.10 to the test compile classpath. The database module already provides the QueryAudit JUnit integration. See Plain JUnit installation for Gradle and Maven examples.

In QueryAudit 0.6, the extension can also replace a mutable static field declared as javax.sql.DataSource with its recording proxy for the duration of the test class. The manual proxy shown above remains compatible with both 0.5 and 0.6.


See Also