15 KiB
Executable File
09 — In-memory databases for testing: H2, HSQLDB and Derby, run side by side and verified
← Previous: 08 — Stored procedures | Next: 10 — Mocking JNDI datasources →
Backs ankurm.com: configuring in-memory databases for bulletproof unit testing.
Companion code: TestDbWidget (one entity,
run against all three engines) and src/test/java/com/ankurm/hibernatedemo/testdb/ (six test
classes, 16 tests, all green together -- docs/output/testdb-*.txt). Environment: Hibernate ORM
7.4.5.Final, JDK 25, H2 2.4.240, HSQLDB 2.7.3, Apache Derby 10.16.1.1. All three bootstrapped
directly through plain Hibernate (StandardServiceRegistryBuilder/MetadataSources), not
through Spring Boot's single-datasource autoconfiguration, specifically so all three could be
stood up side by side against the exact same mapping without needing three separate Spring
contexts.
Headline correction: Derby has no dialect in Hibernate 7 without an extra dependency
This is the biggest surprise in this chapter, and it isn't in the article being replaced at all. Wiring up Derby against Hibernate 7.4.5.Final the "obvious" way fails, twice, in sequence:
- With no
hibernate.dialectset (which works fine for both H2 and HSQLDB, relying on JDBC metadata auto-detection):Unable to determine Dialect for Apache Derby 10.16 (please set 'hibernate.dialect' or register a Dialect resolver). The JDBC connection itself is fine -- the product name and version ("Apache Derby 10.16") were read successfully. Hibernate's dialect resolver chain simply no longer recognizes it. - The "obvious" fix -- set
hibernate.dialect=org.hibernate.dialect.DerbyDialect, the FQCN every existing blog post and Stack Overflow answer uses -- fails too:ClassNotFoundException: Could not load requested class: org.hibernate.dialect.DerbyDialect. That class does not exist anywhere inhibernate-core-7.4.5.Final.jar(confirmed:unzip -l ... | grep -i derby-- zero matches).
Hibernate 6.2+ moved a set of less-common dialects out of hibernate-core into a separate,
opt-in artifact, org.hibernate.orm:hibernate-community-dialects. Derby's dialect now lives
there, under a different package: org.hibernate.community.dialect.DerbyDialect (confirmed
present via unzip -l hibernate-community-dialects-7.4.5.Final.jar | grep -i derby). Both
failures, verbatim, and the working fix are in
docs/output/testdb-derby-dialect-not-found.txt. The dependency this needed is already wired into this
repo's shared pom.xml:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-community-dialects</artifactId>
<scope>runtime</scope>
</dependency>
With that dependency and hibernate.dialect=org.hibernate.community.dialect.DerbyDialect set
explicitly, Derby resolves and works completely normally for everything else in this chapter.
hibernate-jcache on the classpath turns on L2 for everyone, whether you asked or not
This is the second headline finding in this chapter, and it is not scoped to Derby, or even to
testing in-memory databases specifically -- it is a repo-wide gotcha that this chapter is the
right place to document because JCacheOnClasspathAutoEnablesL2Test lives in this package.
Chapter 06 needs hibernate-jcache + ehcache on the classpath to measure @NaturalIdCache
(see chapter 06), and that dependency
is shared across the whole project's single pom.xml -- there's no way to scope it to only the
natural-id tests.
JCacheOnClasspathAutoEnablesL2Test boots a bare SessionFactory with zero cache settings
configured anywhere -- no hibernate.cache.* property, no @Cache/@NaturalIdCache annotation
in sight -- and finds:
second-level cache enabled = true
region factory = org.hibernate.cache.jcache.internal.JCacheRegionFactory
Hibernate 7.4.5 resolves a RegionFactory through the ServiceLoader at boot, and merely
finding one on the classpath is enough for it to turn the second-level cache on by itself.
There is no explicit configuration anywhere that asks for this -- grep -ic cache against
application.yml before this was pinned down returned 0.
This was not a hypothetical risk -- it broke a real, unrelated test. Before
hibernate.cache.use_second_level_cache: false was pinned explicitly in application.yml, a
full mvn test run failed with an order-dependent error: a standalone natural-id-cache test
closed the shared Ehcache CacheManager it had spun up, and the next Spring-context test to
commit a transaction failed with Cache[...] is closed, verbatim in
docs/output/testdb-jcache-classpath-pollution.txt:
[ERROR] ImmutableEntityTest.nativeSqlUpdate_onImmutableEntity_alwaysWorks:184 » Rollback Error while committing the transaction [Unable to perform afterTransactionCompletion callback: Cache[com.ank
The fix is the explicit pin in application.yml:
hibernate:
cache:
use_second_level_cache: false
Not "leave it unset and hope no cache provider ever lands on the classpath" -- an unset value in
this codebase resolves to true the moment hibernate-jcache is present, regardless of intent.
Any project that adds hibernate-jcache (or any other JCache/Ehcache/Infinispan provider) for
one narrow use case should assume it just turned L2 on globally unless it pins the setting back
down explicitly, the same way this repo does.
Dialect auto-selection, confirmed for all three (DialectAndDdlTest)
| Database | Resolved Dialect class |
|---|---|
| H2 (plain) | org.hibernate.dialect.H2Dialect |
H2 MODE=PostgreSQL |
org.hibernate.dialect.H2Dialect |
H2 MODE=Oracle |
org.hibernate.dialect.H2Dialect |
| HSQLDB | org.hibernate.dialect.HSQLDialect |
| Derby | org.hibernate.community.dialect.DerbyDialect (explicit setting required, see above) |
The MODE= question, settled
This is the single most misunderstood H2 feature, and the answer is unambiguous once you
actually build a SessionFactory against each URL and read off the resolved Dialect class
(DialectAndDdlTest.h2WithPostgresModeInTheUrl_... / ...OracleMode..., both green): MODE=
changes what SQL H2 itself will parse and accept -- it does not change which Hibernate Dialect
class gets selected. Hibernate's dialect resolution reads the JDBC driver's own
DatabaseMetaData.getDatabaseProductName(), and H2 reports itself as H2 regardless of the
MODE= parameter in the URL. H2Dialect is what generates DDL and SQL in all three cases; the
resulting create table statements in docs/output/testdb-create-table-ddl.txt are
byte-for-byte identical across plain H2, MODE=PostgreSQL and MODE=Oracle. If you need
Hibernate to actually emit PostgreSQL- or Oracle-flavoured SQL, you set hibernate.dialect
yourself; MODE= alone will not do it, and testing against H2 in a given MODE= is not the
same thing as testing against PostgreSQLDialect.
Generated DDL, and where it turned out to be identical, not different
The same TestDbWidget mapping (GenerationType.AUTO id, varchar(5) SKU, an @Lob clob
description, a boolean, and a reserved-word-shaped column defensively quoted as "order")
produces textually identical create table and create sequence statements across H2,
HSQLDB and Derby (docs/output/testdb-create-table-ddl.txt):
create sequence TestDbWidget_SEQ start with 1 increment by 50
create table TestDbWidget (active boolean not null, "order" integer, sku varchar(5), id bigint not null, description clob, primary key (id))
This corrects an assumption going in: the expectation was to find varchar vs character varying, or different boolean/clob keyword choices, across these three specific engines.
They didn't diverge -- all three dialects happen to accept the same ANSI-shaped keywords
(boolean, varchar(n), clob) for this particular mapping. The real divergence, it turned
out, is not in the DDL keywords Hibernate chooses, but in which identifiers each engine's
parser accepts unquoted -- see below.
GenerationType.AUTO: identical across all three here
AUTO resolved to a native SEQUENCE (TestDbWidget_SEQ, allocation size 50) on all three
engines -- H2, HSQLDB and Derby all support native sequences, so Hibernate 7 has no reason to
fall back to TABLE or IDENTITY for any of them. This is worth stating plainly because AUTO's
reputation for being unpredictable comes from databases like MySQL that lack native sequences
entirely; for this specific trio, AUTO does not actually diverge.
The concrete cross-database failure case, proven with real SQL (CrossDatabaseBehaviorTest)
The brief for this chapter specifically asked for a genuine "passes on X, fails on Y" case, not a folklore restatement. Two were found, one confirmed and one corrected:
Reserved-word divergence -- real, and NOT the word you'd expect. The obvious first guess,
an unquoted order column, turned out to be the wrong probe: H2, HSQLDB and Derby all reject it
identically (docs/output/testdb-reserved-word-survey.txt, a 22-word survey run to find one
that actually diverges). The word that genuinely splits the three: value. H2 rejects an
unquoted value column outright:
JdbcSQLSyntaxErrorException: Syntax error in SQL statement "create table reserved_word_test (id integer, [*]value integer)"; expected "identifier"
HSQLDB and Derby both accept it without complaint. An entity field named value with no
@Column(name = "\"value\"") escaping builds a working schema on two of these three engines and
throws a SQL syntax error, specifically on H2 -- the reverse of what most people would guess,
since H2 has a reputation as the "permissive" one.
CHAR padding -- real, but NOT Derby-exclusive, contrary to widely-repeated folklore. The
brief called this "a classic Derby thing." Measured directly (CHAR(10) holding 'AB', read
back via ResultSet.getString()): H2, HSQLDB and Derby all return "AB " (length
10, space-padded) -- not just Derby. A naive "AB".equals(value) fails against all three
engines for a CHAR column; only VARCHAR avoids the padding. The trap is real and worth
keeping in the article; attributing it to Derby specifically is not.
Schema isolation: pollution proven, then the fix proven (SchemaIsolationTest)
Four ordered tests against one shared SessionFactory, deliberately built to show both failure
and fix in the same run:
testAinserts a row and does nothing to clean up.testB-- which inserts nothing itself -- immediately seescount >= 1. The leftover row fromtestAis visible because nothing isolated the two tests from each other.testCinserts a row inside a transaction, then rolls back instead of committing.testDlooks specifically fortestC's row by its unique SKU and finds zero -- rollback isolated it completely.
create-drop (used to build the shared SessionFactory here) only isolates at the
SessionFactory level -- one schema for the whole test class's lifetime, dropped and recreated
once. It does nothing to isolate individual tests from each other; that isolation has to come
from something else -- a transaction rolled back per test (what testC/testD show, and what
Spring's @Transactional does for you automatically in @SpringBootTest classes), or a full
create-drop re-run per test method (correct, but noticeably slower since it re-creates the
whole schema every time), or a fresh @DirtiesContext-forced new ApplicationContext (correct,
but the heaviest option of the three since it rebuilds the entire Spring context, not just the
schema).
DB_CLOSE_DELAY=-1, proven with a before/after (DbCloseDelayTest)
Two tests, same shape, one difference in the URL. Without DB_CLOSE_DELAY=-1: create a table,
insert a row, close the only open connection, reconnect with a fresh connection to the same
URL --
JdbcSQLSyntaxErrorException: Table "T" not found (this database is empty)
H2 tore the entire in-memory database down the instant the last connection closed; the
"reconnect" actually created a brand-new, empty database that happens to share a name. With
DB_CLOSE_DELAY=-1 on the same URL, the identical close-then-reconnect sequence sees the row
that was inserted before the close. This is exactly why every entry in TestDbSupport.Db's H2
variants carries this flag, and why forgetting it produces intermittent, connection-timing-
dependent test failures rather than a clean, consistent error.
Startup time (indicative only -- shared sandbox container)
Three SessionFactory builds per engine, System.nanoTime(), in this specific shared sandbox
(docs/output/testdb-startup-timing.txt):
H2: 1628 ms, 83 ms, 62 ms
HSQLDB: 238 ms, 79 ms, 74 ms
DERBY: 637 ms, 176 ms, 125 ms
The first run of whichever engine happens to go first pays a one-time JVM/driver class-loading
cost (1.6s for H2 here) that has nothing to do with that database specifically -- note HSQLDB's
first run, second in sequence, only cost 238ms. Steady-state, all three build a SessionFactory
against an empty in-memory schema in well under 200ms. Treat the specific numbers as
order-of-magnitude only; this container is shared and not isolated for benchmarking.
Testcontainers: not runnable here, kept honest and short
Testcontainers 2.0.5 is the current GA and is where real cross-database parity testing belongs
once H2/HSQLDB/Derby's dialect emulation gaps (MODE= not actually changing SQL dialect, Derby's
now-separate community dialect, the CHAR-padding and reserved-word divergences documented above)
matter enough to require the real database. Docker was not available in this sandbox (no
docker binary, no /var/run/docker.sock), so nothing Testcontainers-based was attempted or
run here -- this is stated plainly rather than faked. The recommendation, unchanged from
standard practice: use the in-memory engines for fast unit-level ORM tests in the everyday CI
loop, and Testcontainers for a slower, separate integration stage that exercises the real
production database engine.
← Previous: 08 — Stored procedures | Next: 10 — Mocking JNDI datasources →