A Reusable Hibernate TestBase for Integration Testing
This post shares a fully-featured TestBase
for Hibernate that allows you to do Integration Testing with a Real Database. This TestBase
approach has been successfully used in production and development projects, so it is very well tested. This specific code was written specifically for this post and is free to copy.
As mentioned in the previous post, this approach uses a real database that is assumed to already be migrated. It sets up a transaction for each test that is rolled back at the end to ensure as little interference as possible.
Prerequisites
Before following this guide, you should have the following set up:
- Java 17+ – The examples use modern Java features like
var
andrecord
. The code can be adapted to earlier versions, but it was tested on Java 21. - Maven or Gradle – To manage project dependencies.
- Hibernate ORM – Version 6.x was used to construct the code examples.
- JUnit 5+ – Required for the Extensions that allow the
TestBase
to be reused. - PostgreSQL database – Required for running the tests. You can use your local development database or spin up a separate copy for testing.
- Basic understanding of transactions and EntityManager – Familiarity with how JPA/Hibernate handles transactions will help you understand the
DatabaseTestBase
setup.
Optional:
- Spring Data JPA – Used in the examples for repository setup, but it can be removed if not needed.
Setting up an EntityManagerFactory
The first ingredient is setting up an EntityManagerFactory
, which is responsible for setting up a Database Connection Pool and building a metamodel of the Entity
s in the system. This is done with the HibernateTestSetup
class. This specific example will connect to a local PostgreSQL test database and return the EntityManagerFactory
.
The org.reflections:reflections
package is used for finding the Entity
s. This will only take the currently visible Entity
s into account, which is good for running tests in, for example, a multi-module Gradle setup. If you run the tests in a module with only a few Entity
s, it will only load them and not the thousands of other Entity
s that might be available in other modules.
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.cfg.Configuration;
import org.reflections.Reflections;
import java.util.Set;
public class HibernateTestSetup {
public static EntityManagerFactory buildEntityManagerFactory() {
final var config = new Configuration()
.setProperty("hibernate.connection.driver_class", "org.postgresql.Driver")
.setProperty("hibernate.connection.url", "jdbc:postgresql://localhost:5432/testdb")
.setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect")
.setProperty("hibernate.connection.username", "<user>")
.setProperty("hibernate.connection.password", "<password>")
.setProperty("hibernate.hbm2ddl.auto", "none");
findAllEntities().forEach(config::addAddedClass);
final var sessionFactory = config.buildSessionFactory();
return sessionFactory.unwrap(EntityManagerFactory.class);
}
private static Set<Class<?>> findAllEntities() {
final var reflections = new Reflections("<package>");
return reflections.getTypesAnnotatedWith(Entity.class);
}
}
JUnit Extensions to reuse the EntityManagerFactory
The next step is using the EntityManagerFactory
in an actual test. This is done by injecting it with the JUnit 5 Extensions. The DatabaseExtension
class allows tests to inject the EntityManagerFactory
, and it also supports lifecycle management, which shuts down the connections after the test is over. The important things to know about this class are:
- Injection is done by implementing the
ParameterResolver
and returning it insupportsParameter
andresolveParameter
. - Concurrent and parallel execution is supported by wrapping the
ensureEntityManagerFactory
method in a synchronized block. CloseableEntityManagerFactory
wraps it to allow for closing it using the implementation ofCloseableResource
. This ensures that the database connections are closed properly after all tests are over.- The entity manager is saved in a
Store
to allow it to be reused by any test in the same JUnit instance.
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
public class DatabaseExtension implements BeforeAllCallback, ParameterResolver {
private static final String ENTITY_MANAGER_FACTORY = "ENTITY_MANAGER_FACTORY";
@Override
public void beforeAll(ExtensionContext context) {
synchronized (ENTITY_MANAGER_FACTORY) {
ensureEntityManagerFactory(context);
}
}
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return parameterContext.getParameter().getType() == EntityManagerFactory.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return getStore(extensionContext).get(ENTITY_MANAGER_FACTORY, CloseableEntityManagerFactory.class).entityManagerFactory;
}
private void ensureEntityManagerFactory(ExtensionContext context) {
getStore(context).getOrComputeIfAbsent(
ENTITY_MANAGER_FACTORY,
_ -> new CloseableEntityManagerFactory(HibernateTestSetup.buildEntityManagerFactory()),
CloseableEntityManagerFactory.class
);
}
private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL);
}
private record CloseableEntityManagerFactory(EntityManagerFactory entityManagerFactory) implements ExtensionContext.Store.CloseableResource {
@Override
public void close() {
entityManagerFactory.close();
}
}
}
DatabaseTestBase and features
With the infrastructure of the EntityManagerFactory
set up, it is time to set up an actual EntityManager
used for setting up transactions. This is done with the DatabaseTestBase
, which has the following features:
@ExtendWith(DatabaseExtension.class)
allows for injecting theEntityManagerFactory
, which is done statically in the@BeforeAll init
method.@TestInstance(TestInstance.Lifecycle.PER_METHOD)
specifies that a new instance of the test class will be created for each test method, while the static fields will be shared across the class. This ensures that the local instance fields are wiped after each test has executed.- Setting the
EntityManager
in the empty constructor allows derived classes to use it directly in their fields. - The
@BeforeEach
will set up a new transaction and the@AfterEach
will roll it back and shut down theEntityManager
. - The
sync
method is used to flush and clear the Hibernate cache. This needs to be called in cases where the structure of the JPA entities might change when they are being rehydrated from the database. This happens often and the method should usually be called before any assertions. jpaRepository
sets up the repositories from Spring Data JPA.
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
@ExtendWith(DatabaseExtension.class)
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
public abstract class DatabaseTestBase {
protected static EntityManagerFactory entityManagerFactory;
protected EntityManager entityManager;
private JpaRepositoryFactory jpaRepositoryFactory;
@BeforeAll
public static void init(EntityManagerFactory entityManagerFactory) {
DatabaseTestBase.entityManagerFactory = entityManagerFactory;
}
public DatabaseTestBase() {
entityManager = entityManagerFactory.createEntityManager();
}
@BeforeEach
void setUpEntityManager() {
entityManager.getTransaction().begin();
}
@AfterEach
void tearDownEntityManagerFactory() {
final var transaction = entityManager.getTransaction();
if (transaction.isActive()) {
transaction.rollback();
}
if (entityManager.isOpen()) {
entityManager.close();
}
}
public <T> T jpaRepository(Class<T> repositoryClass) {
if (jpaRepositoryFactory == null) {
jpaRepositoryFactory = new JpaRepositoryFactory(entityManager);
}
return jpaRepositoryFactory.getRepository(repositoryClass);
}
protected void sync() {
entityManager.flush();
entityManager.clear();
}
}
Example usage
An example of how to use this DatabaseTestBase
is included in ExampleRepositoryTest
. Please note:
- The dependencies can be set up directly in the fields.
- For more complex dependency setups, it is recommended to use a TestFactory to share the dependency logic.
- Manual setup helps identify high coupling early. If test setup grows too large, you might have a coupled system.
sync
is used before comparing the original object and the one from the database. This ensures the database is actually involved by clearing the Hibernate cache.- The
Example
domain objects use the@EqualsAndHashCode
annotation from Lombok to allow for simple comparison.
- The
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ExampleRepositoryTest extends DatabaseTestBase {
ExampleJpaRepository exampleJpaRepository = jpaRepository(ExampleJpaRepository.class);
ExampleRepository exampleRepository = new ExampleRepository(exampleJpaRepository);
@Test
void canSaveAndRehydrate() {
final var example = ExampleTestData.sample();
final var id = exampleRepository.add(example);
sync();
final var retrieved = exampleRepository.get(id);
assertEquals(example, retrieved);
}
}
If your system uses a lot of shared data in the tests — for example, if you have an entity that all your tests change — problems can sometimes occur with database-level transaction deadlocks. The way to solve these is by creating custom data for each test that experiences the deadlock, so that it is no longer needing the same resources and it removes the possibility of a deadlock.
Conclusion
In short, this DatabaseTestBase
makes Hibernate integration testing simple, reliable, and reusable. With just a few lines of setup, you get fully isolated tests, automatic transaction rollback, and easy access to repositories. Plug it into your project, and your integration tests will be cleaner, safer, and much easier to maintain.