“How do you test a Spring Data repository?” Interviewers ask to find out whether your tests prove anything about the database. A common answer is “@DataJpaTest with H2”, which is a fine starting point, but strong candidates add the caveats: the test runs inside a transaction that rolls back, the first-level cache can answer queries without SQL, and H2 is not PostgreSQL. Each caveat has caused real production bugs that the test suite approved.
Before you start
You should know how a Spring Data JPA repository interface works, what a transaction and a flush are, and the basics of JUnit 5. Docker must be available to run the Testcontainers examples. Examples target Spring Boot 3.1+ (for @ServiceConnection) with Hibernate 6.
The short answer
@DataJpaTest is a test slice: it starts only JPA-related auto-configuration (entities, repositories, DataSource, Flyway or Liquibase, TestEntityManager) and not your services or web layer. Each test runs in a transaction that rolls back afterwards. By default it replaces your DataSource with an embedded database if one is on the classpath. For realistic tests, use @AutoConfigureTestDatabase(replace = NONE) with a Testcontainers database exposed through @ServiceConnection, run the same migrations as production, and flush and clear the persistence context so assertions read from the database rather than from memory.
How it works
@DataJpaTest is a composed annotation. Among other things, it includes @Transactional, @AutoConfigureTestDatabase and a type-exclusion filter that skips @Service, @Controller and @Component beans.
@DataJpaTest
class CustomerRepositoryTest {
@Autowired CustomerRepository customers;
@Autowired TestEntityManager em;
@Test
void findsByEmailIgnoringCase() {
em.persist(new Customer("Ada", "ADA@example.com"));
em.flush();
em.clear();
assertThat(customers.findByEmailIgnoreCase("ada@example.com")).isPresent();
}
}Three behaviours matter:
- Rollback. Data written in one test disappears before the next, which keeps tests independent. It also means the commit never happens, so anything that only runs at commit, such as deferred constraint checks or
@TransactionalEventListener(phase = AFTER_COMMIT), never runs. - First-level cache. Within one transaction,
findByIdfor an entity you just persisted returns the same Java object from the persistence context without SQL. Withoutflush()theINSERTmay never be sent, so constraint violations go unseen. - Database replacement. With H2 on the test classpath, Boot swaps your configured database for it. Your PostgreSQL-specific SQL, column types and constraint behaviour are no longer tested.
Step-by-step walkthrough
Step 1: Start with the slice
The slice starts in a second or two because it skips the web server, security and your services. Use it for repository queries, entity mappings and constraints. Anything else the repository needs, such as an auditing configuration, must be imported explicitly with @Import.
Step 2: Run against the real database engine
Add spring-boot-testcontainers and the Testcontainers PostgreSQL module, then:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CustomerRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired CustomerRepository customers;
@Autowired TestEntityManager em;
}@ServiceConnection makes Boot create the connection details from the container, so no spring.datasource.url property is needed. replace = NONE stops Boot from swapping in an embedded database. The static container starts once per class.
Step 3: Use production migrations
Let Flyway or Liquibase build the schema, and set spring.jpa.hibernate.ddl-auto=validate (Boot’s test default for embedded databases is create-drop). Then the tests verify that your entities match the schema your migrations produce, which is the schema production has.
# src/test/resources/application.properties
spring.jpa.hibernate.ddl-auto=validateStep 4: Flush, clear, then assert
Make the database do the work:
@Test
void rejectsDuplicateEmail() {
em.persist(new Customer("Ada", "ada@example.com"));
em.persist(new Customer("Ada Two", "ada@example.com"));
assertThatThrownBy(() -> em.flush())
.isInstanceOf(ConstraintViolationException.class); // org.hibernate.exception
}Without the flush, the test ends, rolls back, and passes whether or not the unique constraint exists. Note that TestEntityManager.flush() goes straight to Hibernate, so you see Hibernate’s exception rather than Spring’s translated DataIntegrityViolationException; calling customers.saveAndFlush(...) through the repository proxy gives the translated one.
Worked scenario
A repository has a native query to find customers by a tag stored in a PostgreSQL jsonb column, and a derived query for case-insensitive email lookup. Tests use @DataJpaTest with H2, and the native query test is marked @Disabled because H2 does not understand jsonb. Everything else passes.
In production, two bugs appear in the same week. First, the native query has a typo in a JSON operator that nobody had run. Second, a unique index on lower(email) exists in the Flyway migration, but H2 tests used create-drop from entity annotations, which created a plain case-sensitive unique constraint. A signup flow that tests “Ada@x.com” and “ada@x.com” passed in tests and failed with a constraint violation in production.
The team switches the repository tests to the Testcontainers setup from Step 2, runs Flyway migrations, sets ddl-auto=validate and re-enables the native query test. Both bugs reproduce in the suite immediately. Test time grows from 4 to 11 seconds for the module, with the container shared across classes through a common base configuration.
Common mistake
- Asserting without flushing. You test Hibernate’s in-memory state, not the database.
- Trusting H2 compatibility modes.
MODE=PostgreSQLcovers some syntax, not types such asjsonb, partial indexes or locking behaviour. - Expecting service beans in
@DataJpaTest. They are excluded. Use@SpringBootTestfor service-plus-repository tests, or construct the service with the repository manually. - Testing after-commit behaviour in a rolled-back test. Use
@Transactional(propagation = NOT_SUPPORTED)on the test, orTestTransaction.flagForCommit()andTestTransaction.end(), and clean up yourself. - Letting
ddl-auto=create-dropbuild the schema so migrations are never exercised.
Verify the behavior
Prove the slice really hits the database by checking a database-specific feature:
@Test
void uniqueIndexIsCaseInsensitive() {
customers.saveAndFlush(new Customer("Ada", "Ada@example.com"));
assertThatThrownBy(() -> customers.saveAndFlush(new Customer("Ada", "ada@example.com")))
.isInstanceOf(DataIntegrityViolationException.class);
}Against H2 with create-drop this test fails (the second save succeeds); against PostgreSQL with the Flyway lower(email) index it passes. That difference is the whole argument for database fidelity. Run ./mvnw test -Dtest=CustomerRepositoryPostgresTest and look for the Testcontainers startup log and Flyway’s “Successfully applied N migrations” line.
Follow-up questions
What other test slices exist? @WebMvcTest, @WebFluxTest, @JsonTest, @RestClientTest, @JdbcTest, @DataMongoTest and more; each loads only the relevant auto-configuration.
How do you share a container across test classes? Declare it in a base class or interface with a static field, or use a @TestConfiguration that defines the container as a @Bean with @ServiceConnection; Spring’s context cache then reuses it.
Why might a @DataJpaTest miss your custom repository fragment’s dependencies? Fragments that inject non-JPA beans need those beans imported, since the slice excludes ordinary components.
When should a test commit? When testing commit-time behaviour: deferred constraints, after-commit listeners, or optimistic locking across transactions.
Interview exercise
A teammate’s repository test persists an order with a null customer_id, calls findById, and asserts the order is found. The column is NOT NULL in the migration. The test passes. Explain why and fix the test.
Answer and reasoning
The persist only schedules an INSERT in the persistence context. findById in the same transaction returns the managed instance from the first-level cache without SQL, so the NOT NULL constraint is never checked. At the end, the test transaction rolls back and the insert is discarded unsent. Fix: call em.flush() after persisting (expect a constraint violation if the null is wrong), then em.clear() before reading so the read issues a real SELECT. Also make sure the test runs against the migration-built schema, ideally on the production engine through Testcontainers, so the constraint actually exists. The reasoning: a repository test must force the database to execute the SQL it claims to test.