Usage
Prerequisites
Your test context must supply:
- A
DataSourcebean (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=simpleappended when targeting PostgreSQL (required by the runtime audit family — see PostgreSQL JDBC requirement:preferQueryMode=simplebelow).
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=simpleTestcontainers 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$OrderAnnotationUse 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
DataSourceandEntityManagerFactory— no special Hibernate settings. The JPA audit walks that factory’s entity mappings against the live schema itself under the defaultddl-auto=none; it does not needhibernate.hbm2ddl.auto=validate(it deliberately avoids Hibernate’s fail-fastvalidate, 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
EntityManagerFactorymust use this suite’s capturer (reportingSqlCapturer) as its HibernateStatementInspector. The config above wires this automatically with aSqlCapturerRegisteringPostProcessor, which sets the capturer on the named factory before Hibernate builds it — so your own (production) datasource configuration is untouched. Also connect that datasource withpreferQueryMode=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=bothFix 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 INDEXfor an unindexed foreign key,DROP INDEXfor a redundant index,ALTER COLUMN … TYPEfor a foreign-key type mismatch). - template — DDL with a
TODOyou 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
EXPLAINplan; verify the columns. - advisory — no DDL; a full-table
UPDATE/DELETEneeds aWHEREclause 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.

