Integration Testing with Hibernate

This section documents Hibernate ORM 7.4.x (User Guide, Introduction, Query Language Guide, Data Repositories Guide), Jakarta Persistence 3.2, Hibernate Search 8.4.x, and the Hibernate Validator / Hibernate Reactive references — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production.

Three older reference books were consulted as bibliography only while preparing these pages and are not the primary or main source for any page. All three predate Jakarta Persistence 3.2 and Hibernate ORM 6/7 (the javax.persistencejakarta.persistence namespace change, the ORM 6 query-engine rewrite, the Hibernate Search 6+ Elasticsearch backend), so the official documentation above wins on any discrepancy.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every page so far in this section assumes Hibernate is used on its own terms, independent of any framework — this page is no exception. It covers testing a Hibernate mapping/query against a real database engine without Spring: bootstrapping a SessionFactory directly, the trade-offs of in-memory databases, running the same test suite against several database engines with a parameterized test, and standing up real engines with Testcontainers. If the application under test is a Spring Boot application, prefer the Spring-aware shortcuts (@DataJpaTest, @ServiceConnection) on Unit & Integration Testing instead — the patterns below are what those shortcuts are themselves built on.

Bootstrapping a test SessionFactory

Outside a framework, a test suite builds its own EntityManagerFactory/SessionFactory once per test class (it is expensive to create — see Architecture) and reuses it across test methods, wiping and re-seeding data between them instead of rebuilding it every time:

class BookRepositoryIT {

    static EntityManagerFactory emf;
    EntityManager em;

    @BeforeAll
    static void bootstrap() {
        // reads META-INF/persistence.xml (a "test" persistence unit),
        // or build one programmatically with PersistenceUnitInfo for full control over JDBC URL and dialect
        emf = Persistence.createEntityManagerFactory("test");
    }

    @BeforeEach
    void openSession() {
        em = emf.createEntityManager();
    }

    @AfterEach
    void closeSession() {
        em.close();
    }

    @AfterAll
    static void shutdown() {
        emf.close();
    }

    @Test
    void findByIsbn_returnsMatchingBook() {
        em.getTransaction().begin();
        em.persist(new Book("978-1", "Effective Java"));
        em.getTransaction().commit();

        Book found = em.createQuery("SELECT b FROM Book b WHERE b.isbn = :isbn", Book.class)
                .setParameter("isbn", "978-1")
                .getSingleResult();

        assertThat(found.getTitle()).isEqualTo("Effective Java");
    }
}

hibernate.hbm2ddl.auto=create-drop (test-only — never in production, see Schema Generation and Tooling) rebuilds the schema for every test run so tests never depend on leftover state from a previous run.

In-memory databases — fast, but not the production dialect

An embedded, in-process database — H2 (the most common choice with Hibernate), HSQLDB, or Derby — starts in milliseconds with no external process, which is why it is the default choice for a fast unit-level test suite. Add it as a test-scoped dependency — no separate server or driver install needed, the JDBC driver ships in the same artifact:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.4.240</version>
    <scope>test</scope>
</dependency>
hibernate.connection.driver_class=org.h2.Driver
hibernate.connection.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
hibernate.dialect=org.hibernate.dialect.H2Dialect

The trade-off is real, not theoretical: an in-memory engine is a different database product from PostgreSQL/MySQL/Oracle in production, with its own SQL dialect, its own function library, its own locking and isolation implementation, and its own handling of vendor-specific column types. A query that works against H2 can fail, or silently behave differently, against the production engine — window functions, RETURNING clauses, JSON column functions, and case-sensitivity rules are common places this shows up. Treat an in-memory database as a fast smoke test for mapping/query shape, not as a substitute for testing against the real engine before trusting a query in production — the same principle Unit & Integration Testing's "Choosing the right level" section already states for the Spring Boot stack.

Testing multiple database engines with parameterized tests

When a codebase must run correctly against more than one database engine (a library, or an application that supports customer choice of database), a single hard-coded SessionFactory cannot catch dialect-specific divergence. JUnit 5’s @ParameterizedTest runs the same test body once per supplied database configuration, each rebuilding the SessionFactory against a different JDBC URL and Hibernate dialect:

class BookRepositoryDialectIT {

    record DbConfig(String name, String jdbcUrl, String username, String password, String dialect) {
        @Override
        public String toString() {
            return name;                       // shown as the parameterized test's display name
        }
    }

    static Stream<DbConfig> databases() {
        return Stream.of(
                new DbConfig("H2", "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "sa", "",
                        "org.hibernate.dialect.H2Dialect"),
                new DbConfig("PostgreSQL", postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword(),
                        "org.hibernate.dialect.PostgreSQLDialect"),
                new DbConfig("MySQL", mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword(),
                        "org.hibernate.dialect.MySQLDialect"));
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("databases")
    void findByIsbn_returnsMatchingBook(DbConfig db) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("test", Map.of(
                "hibernate.connection.url", db.jdbcUrl(),
                "hibernate.connection.username", db.username(),
                "hibernate.connection.password", db.password(),
                "hibernate.dialect", db.dialect(),
                "hibernate.hbm2ddl.auto", "create-drop"));

        try (emf) {
            // same test body as the single-engine version above
        }
    }
}

Overriding hibernate.connection.*/hibernate.dialect as a properties map when building the EntityManagerFactory (rather than editing persistence.xml per engine) is what makes the same persistence unit reusable across parameters. This pattern is what surfaces dialect divergence (a query that only works on one engine) in CI, in exactly the case an in-memory-only suite would miss per the previous section.

Testcontainers with Docker images

For any engine that has no credible in-memory equivalent — or to remove the in-memory/production dialect gap entirely — Testcontainers starts the real engine as a disposable Docker container for the duration of the test run:

@Testcontainers
class BookRepositoryPostgresIT {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    static EntityManagerFactory emf;

    @BeforeAll
    static void bootstrap() {
        emf = Persistence.createEntityManagerFactory("test", Map.of(
                "hibernate.connection.url", postgres.getJdbcUrl(),
                "hibernate.connection.username", postgres.getUsername(),
                "hibernate.connection.password", postgres.getPassword(),
                "hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect",
                "hibernate.hbm2ddl.auto", "create-drop"));
    }

    // test methods as above
}

The container starts once (a static field, shared across the class’s test methods via the @Testcontainers JUnit 5 extension) and is torn down automatically when the JVM exits. Combine this with the parameterized pattern above — one PostgreSQLContainer/MySQLContainer/other module per engine under test, each contributing one DbConfig — to run the full suite against every supported engine in CI without maintaining separate database servers. Without Spring Boot’s @ServiceConnection (Spring-specific, see Unit & Integration Testing), the container’s JDBC URL/username/password are read directly off the container instance and passed into the EntityManagerFactory properties, as shown above.