Usage

Prerequisites

Your test context must supply:

  • A DataSource bean (JDBC connection to your database).
  • A JPA EntityManagerFactory (required by the JPA audit family).
  • Spring Boot test infrastructure (typically @SpringBootTest).
  • A schema-qualified JDBC URL with preferQueryMode=simple appended when targeting PostgreSQL (required by the runtime audit family — see PostgreSQL JDBC requirement: preferQueryMode=simple below).

Enabling the audits

Add @Import(DatabaseAuditTestConfiguration.class) to your test base class. This registers every audit bean, their collaborators, and the SQL-capturing StatementInspector with Hibernate in one step.

@SpringBootTest
@Import(DatabaseAuditTestConfiguration.class)
public abstract class AbstractDatabaseAuditIT {

    @Value("${database.datasource.schema-name}")
    protected String schema;

    @Autowired
    protected DatabaseAuditAssertions audits;
}

Audit families

For a full description of what each audit detects, finding format, and exclusion types, see the core audit reference.

Catalog audits

Catalog audits query database metadata (information schema / system catalogs). They run on every supported platform and need no special JDBC configuration.

Call via the facade:

audits.assertCatalogClean(schema);

Or with exclusions:

audits.assertCatalogClean(schema, DatabaseAuditExcludes.builder()
    .primaryKeyTables(Set.of("some_legacy_table"))
    .foreignKeyIndexConstraints(Set.of("fk_known_unindexed"))
    .build());

JPA audit

The JPA audit validates that Hibernate’s entity model matches the live database schema, reporting every mismatch (missing table, missing column, incompatible column type) in a single run. Run under the default ddl-auto=none: the audit performs the validation itself rather than relying on Hibernate’s fail-fast ddl-auto=validate startup check, which aborts context startup on the first mismatch.

audits.assertJpaClean();

Runtime audits

Runtime audits intercept every SQL statement executed during the test via Hibernate’s StatementInspector. The plan-based audits (WhereClauseIndexAudit, OrderByIndexAudit, JoinIndexAudit, UnusedIndexAudit) additionally analyze each statement via EXPLAIN and are PostgreSQL 16+ only. The token-scan audits (UnconditionalMutationAudit, OffsetPaginationAudit, RepeatedStatementAudit) scan the captured SQL text directly and run on every supported platform.

PostgreSQL JDBC requirement: preferQueryMode=simple

The plan-based audits call EXPLAIN (GENERIC_PLAN, FORMAT JSON) on each captured parameterized statement ($1, $2, …), which only works over PostgreSQL’s simple query protocol. The driver’s default extended protocol sends parameters separately, so PostgreSQL skips every parameterized statement — the audit then fails its vacuous-run guard with IllegalStateException.

Add preferQueryMode=simple to the test datasource JDBC URL. Never add it to a production URL.

Static JDBC URL

When the database host and port are fixed (local database, fixed Docker container):

# src/test/resources/application.properties (or application-test.properties)
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb?preferQueryMode=simple

Testcontainers DynamicPropertyRegistrar (recommended for CI)

Configure the parameter on the container before it starts (Testcontainers assigns a random port, so the URL cannot be hardcoded):

private static final PostgreSQLContainer<?> POSTGRES =
    new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"));
static {
    POSTGRES.withUrlParam("preferQueryMode", "simple");  // must be before start()
    POSTGRES.start();
}

@Bean
DynamicPropertyRegistrar postgresProperties() {
    return registry -> registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
}

Or append it manually when you do not control the container setup:

registry.add("spring.datasource.url", () -> POSTGRES.getJdbcUrl() + "?preferQueryMode=simple");

docker-maven-plugin with dynamic port

When using the fabric8 docker-maven-plugin with a randomly assigned host port, bind the container port to a Maven property and pass the assembled URL as a Failsafe system property:

<!-- docker-maven-plugin: bind random host port to ${pg.port} -->
<image>
    <name>postgres:16</name>
    <run>
        <ports>
            <port>pg.port:5432</port>
        </ports>
    </run>
</image>

<!-- maven-failsafe-plugin: pass the assembled URL as a JVM system property -->
<systemPropertyVariables>
    <spring.datasource.url>
        jdbc:postgresql://localhost:${pg.port}/mydb?preferQueryMode=simple
    </spring.datasource.url>
</systemPropertyVariables>

Spring Boot reads spring.datasource.url from JVM system properties, so no application.properties entry is needed. With a fixed port mapping (<port>5432:5432</port>), use the static JDBC URL approach instead.

Negative impacts

Switching to the simple query protocol has trade-offs that are acceptable in test contexts but not in production:

Impact Notes
No server-side plan caching PostgreSQL re-parses and re-plans every query on each execution. Negligible under test load.
Text wire format Simple protocol transfers result rows as text, not binary — slightly larger payloads and slower deserialization for numeric and date types. Not measurable in tests.
No statement pipelining The extended protocol can send multiple queries in-flight; simple cannot. Only matters at high throughput, not in unit/integration tests.

SQL capture ordering

The runtime audits read SQL that was captured during the test run. To ensure all repository calls are captured before the audits check them, run the workload test first using JUnit’s @Order:

// Runs first — exercises your repositories to populate the SQL capture buffer.
@Order(Integer.MIN_VALUE)
class RepositoryWorkloadIT extends AbstractDatabaseAuditIT {

    @Autowired
    private MyRepository myRepository;

    @Test
    void primeRepositoryWorkload() {
        myRepository.findAll();
        myRepository.findById(1L);
        // ... other calls to exercise
    }
}

// Runs last — checks the captured SQL.
@Order(Integer.MAX_VALUE)
class WhereClauseIndexAuditIT extends AbstractDatabaseAuditIT {

    @Test
    void assertWhereClauseIndexClean() {
        audits.assertRuntimeClean();
    }
}

Enable ClassOrderer$OrderAnnotation in junit-platform.properties:

junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$OrderAnnotation

Use JUnit’s @Order (org.junit.jupiter.api.Order), not Spring’s.

Running all families at once

assertAllClean(schema) runs all three families in one call and aggregates any failures into a single AssertionError rather than stopping at the first:

// Runs last — after the workload test primes the SQL capture.
@Order(Integer.MAX_VALUE)
class AllAuditsIT extends AbstractDatabaseAuditIT {

    @Test
    void assertAllClean() {
        audits.assertAllClean(schema);
    }
}

With exclusions:

audits.assertAllClean(schema, DatabaseAuditExcludes.builder()
    .primaryKeyTables(Set.of("legacy_table_no_pk"))
    .planRelations(Set.of("small_lookup_table"))
    .build());

Since assertAllClean includes the runtime audits, the ordering setup from [_sql_capture_ordering] applies.

Using individual assertion beans

Each audit has a dedicated assertion bean you can @Autowired directly for fine-grained control:

@Autowired
private ForeignKeyIndexAuditAssertion foreignKeyIndexAuditAssertion;

@Autowired
private WhereClauseIndexAuditAssertion whereClauseIndexAuditAssertion;

@Test
void assertForeignKeyIndexClean() {
    foreignKeyIndexAuditAssertion.assertClean(schema, Set.of("fk_known_unindexed"));
}

@Test
@Order(Integer.MAX_VALUE)
void assertWhereClauseIndexClean() {
    whereClauseIndexAuditAssertion.assertClean(
        Set.of("large_lookup_table"),
        List.of("SELECT * FROM audit_log")
    );
}

See Exclusions for the full exclusion API reference.

Multiple datasources

DatabaseAuditTestConfiguration audits one datasource — resolved by type from the context’s DataSource and EntityManagerFactory. Spring picks the single, @Primary, or conventionally-named (dataSource/entityManagerFactory) candidate, so importing the stock config audits that datasource out of the box.

With several peer datasources that autowiring can’t disambiguate (no @Primary, custom bean names), by-type resolution fails fast with Spring’s "expected single matching bean but found 2". To audit such a datasource, wire it by name instead, one target per generated suite.

In short: DatabaseAuditTestConfiguration audits the primary datasource by type; a per-datasource DatabaseAudit<Name>TestConfiguration audits a named datasource by name. The stock class is not obsolete — it stays the entry point for any single or @Primary datasource; the per-datasource class is what you add for a non-primary peer.

Give the datasource a @TestConfiguration that resolves its beans by @Qualifier, builds a io.github.databaseaudits.spring.boot.DatabaseAuditSuite — a plain factory that wires the whole audit graph for a given (DataSource, EntityManagerFactory) pair — and registers every *AuditAssertion from it. It mirrors the stock config, but by name. Name it after the datasource, e.g. for a Reporting datasource:

@TestConfiguration(proxyBeanMethods = false)
public class DatabaseAuditReportingTestConfiguration {

    @Bean
    SqlCapturingStatementInspector reportingSqlCapturer() {
        return new SqlCapturingStatementInspector();
    }

    @Bean  // sets reportingSqlCapturer as reportingEntityManagerFactory's StatementInspector before it is built
    static SqlCapturerRegisteringPostProcessor reportingSqlCapturerRegistrar() {
        return new SqlCapturerRegisteringPostProcessor("reportingEntityManagerFactory", "reportingSqlCapturer");
    }

    @Bean
    DatabaseAuditSuite reportingDatabaseAuditSuite(
            @Qualifier("reportingDataSource") DataSource dataSource,
            @Qualifier("reportingEntityManagerFactory") EntityManagerFactory entityManagerFactory,
            @Qualifier("reportingSqlCapturer") SqlCapturingStatementInspector reportingSqlCapturer) {
        return new DatabaseAuditSuite(dataSource, entityManagerFactory, reportingSqlCapturer);
    }

    @Bean
    AuditAssertionRegistrar reportingAuditAssertionRegistrar(
            @Qualifier("reportingDatabaseAuditSuite") DatabaseAuditSuite suite) {
        return new AuditAssertionRegistrar(suite, "reporting", false);
    }
}

One AuditAssertionRegistrar publishes every *AuditAssertion the suite wires — and the DatabaseAuditAssertions facade — as a bean, so this config stays in step with the audit roster automatically; there is no longer a @Bean method per audit to keep in sync. The "reporting" prefix names those beans reportingForeignKeyIndexAuditAssertion, reportingDatabaseAuditAssertions, and so on, so they never collide with the primary datasource’s audit beans; false marks them non-primary, so an unqualified by-type injection still resolves to the primary datasource’s assertions. Your ITs still @Autowired each assertion by type. This is exactly what the archetype-generated DatabaseAudit<Name>TestConfiguration does.

Import that config once on your test base class — not on each IT. A per-test @Import layered on a base that already declares its context via @ContextConfiguration breaks Spring’s context resolution; putting it on the base avoids that. Every audit IT extends the base and injects its assertion bean, exactly like the single-datasource suite:

@SpringBootTest
@Import(DatabaseAuditReportingTestConfiguration.class)
public abstract class AbstractDatabaseAuditIT {
}

class ForeignKeyIndexAuditIT extends AbstractDatabaseAuditIT {

    @Autowired
    private ForeignKeyIndexAuditAssertion foreignKeyIndexAuditAssertion;

    @Value("${database.datasource.schema-name}")
    private String schema;

    @Test
    void everyForeignKeyHasSupportingIndex() {
        foreignKeyIndexAuditAssertion.assertClean(schema);
    }
}

The archetype generates all of this — the DatabaseAudit<Name>TestConfiguration (its capturer, the qualified suite, and every *AuditAssertion bean) and the audit ITs that @Import it — when you pass -DdataSourceName=Reporting -DdataSourceBeanName=reportingDataSource -DentityManagerFactoryBeanName=reportingEntityManagerFactory. To audit several datasources, generate once per datasource into its own package; each target audits its own -DschemaPropertyName, so datasources with different schemas are handled naturally.

What each family needs per datasource

  • Catalog and JPA audits work as soon as you supply the qualified DataSource and EntityManagerFactory — no special Hibernate settings. The JPA audit walks that factory’s entity mappings against the live schema itself under the default ddl-auto=none; it does not need hibernate.hbm2ddl.auto=validate (it deliberately avoids Hibernate’s fail-fast validate, which aborts on the first mismatch instead of reporting them all in one run).
  • Runtime audits (PostgreSQL-only) additionally need that datasource’s SQL captured — its EntityManagerFactory must use this suite’s capturer (reportingSqlCapturer) as its Hibernate StatementInspector. The config above wires this automatically with a SqlCapturerRegisteringPostProcessor, which sets the capturer on the named factory before Hibernate builds it — so your own (production) datasource configuration is untouched. Also connect that datasource with preferQueryMode=simple (see PostgreSQL JDBC requirement: preferQueryMode=simple); otherwise run only the catalog and JPA audits against that datasource.

Consolidated findings report

Every audit failure is also collected into a single report file, written automatically at the end of the test run by a JUnit TestExecutionListener shipped in the integration jar and auto-registered via META-INF/services — no setup and no console scraping. The listener is inert when the run is clean: it writes nothing unless there are findings, so unrelated test runs are unaffected.

The report groups findings by audit family and audit, reproducing each finding’s message, and can additionally emit a remediation for each finding — raw SQL DDL or a Liquibase changeset.

The report is populated from per-audit assertion failures — the per-audit ITs the archetype generates, or any test that calls an individual *AuditAssertion bean. The DatabaseAuditAssertions facade (assertAllClean / assertCatalogClean / …) aggregates its failures into one combined AssertionError, which the report does not itemize; prefer the per-audit assertions when you want the consolidated report.

Configuration

Set these JUnit Platform configuration parameters in src/test/resources/junit-platform.properties (each is also honored as a same-named system property):

Key Default Description
database-audits.report.enabled true Master switch. false disables the report entirely.
database-audits.report.format asciidoc Report format: asciidoc, markdown, or text.
database-audits.report.fix-format liquibase-xml Per-finding remediation: liquibase-xml (a databaseChangeLog fragment), sql (raw DDL), or none (findings only).
database-audits.report.fix-placement both Where fixes appear: both (default — inline under each audit and in a consolidated section), inline (only under each audit), or section (only the consolidated block).
database-audits.report.output-file target/database-audit-report.<ext> Where the report is written; the default extension follows the format (md / adoc / txt).
# src/test/resources/junit-platform.properties
database-audits.report.format=asciidoc
database-audits.report.fix-format=liquibase-xml
database-audits.report.fix-placement=both

Fix fidelity

Generated fixes are labeled by how faithfully they remediate the finding, because not every finding carries enough information for an exact fix:

  • precise — executable, dialect-correct DDL that fully fixes the finding (e.g. CREATE INDEX for an unindexed foreign key, DROP INDEX for a redundant index, ALTER COLUMN … TYPE for a foreign-key type mismatch).
  • template — DDL with a TODO you must complete (e.g. a missing primary key’s key columns cannot be inferred from the catalog).
  • best-effort — an index suggestion whose relation is parsed from the EXPLAIN plan; verify the columns.
  • advisory — no DDL; a full-table UPDATE/DELETE needs a WHERE clause in code, not a schema change.

In liquibase-xml mode only precise fixes become runnable <changeSet> elements — built from Liquibase’s database-agnostic change types (<createIndex>, <dropIndex>, <modifyDataType>, <addColumn>, <addNotNullConstraint>), so Liquibase renders the dialect-correct SQL for your database rather than this module hand-writing it. The rest are emitted as XML comments, so the embedded databaseChangeLog stays well-formed and applies cleanly. Change-set ids are derived from the finding, so regenerating over the same findings is stable.

With the default both placement, each audit’s fix is shown inline beneath its findings and gathered in the consolidated section; for liquibase-xml the inline blocks are <changeSet> fragments to read alongside the audit, while the consolidated section is the single, applicable <databaseChangeLog>. Set fix-placement to inline to drop the consolidated section, or section for the consolidated block only.

The archetype seeds database-audits.report.format, database-audits.report.fix-format, and database-audits.report.fix-placement into the generated junit-platform.properties from its -DreportFormat, -DfixFormat, and -DfixPlacement parameters — see Archetype.