Spring Data JPA Testing: @DataJpaTest, Custom Queries, and TestEntityManager
@DataJpaTest loads only the JPA layer — no controllers, no services, no security. Pair it with TestEntityManager for precise fixture control, and Testcontainers PostgreSQL when H2 is not enough. This post covers all the patterns you need for a solid repository test suite.
The repository layer is where your application talks to the database. Bugs here are some of the nastiest to debug — wrong query results, missing cascades, broken pagination, audit timestamps not updating. Testing this layer before it reaches production saves hours of production debugging.
Spring Boot's @DataJpaTest gives you a focused slice: JPA entities, repositories, DataSource, JPA configuration, and Spring Data. Nothing else is loaded. Fast startup, transactional rollback between tests, and TestEntityManager for fixture management.
The @DataJpaTest Slice
By default, @DataJpaTest configures an H2 in-memory database and wraps each test in a transaction that rolls back automatically. No manual cleanup needed.
@DataJpaTest
class ArticleRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ArticleRepository articleRepository;
@Test
void findBySlug_returnsMatchingArticle() {
var article = new Article();
article.setTitle("Getting Started with Spring Boot");
article.setSlug("getting-started-spring-boot");
article.setStatus(ArticleStatus.PUBLISHED);
entityManager.persistAndFlush(article);
var found = articleRepository.findBySlug("getting-started-spring-boot");
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Getting Started with Spring Boot");
}
@Test
void findBySlug_returnsEmptyForUnknownSlug() {
var found = articleRepository.findBySlug("does-not-exist");
assertThat(found).isEmpty();
}
}The automatic rollback means each test starts with a clean state. No @AfterEach cleanup, no TRUNCATE scripts.
TestEntityManager: Controlling Fixtures
TestEntityManager is a test wrapper around JPA's EntityManager. It exposes the operations you actually need for test setup without the full EntityManager complexity.
@DataJpaTest
class CommentRepositoryTest {
@Autowired
private TestEntityManager em;
@Autowired
private CommentRepository commentRepository;
@Test
void findByArticleId_returnsOnlyCommentsForThatArticle() {
var author = em.persist(new Author("alice", "alice@example.com"));
var article1 = em.persist(new Article("Article One", author));
var article2 = em.persist(new Article("Article Two", author));
em.persist(new Comment("Great post!", article1, author));
em.persist(new Comment("Very helpful!", article1, author));
em.persist(new Comment("Unrelated", article2, author));
em.flush();
var comments = commentRepository.findByArticleId(article1.getId());
assertThat(comments).hasSize(2);
assertThat(comments).extracting(Comment::getText)
.containsExactlyInAnyOrder("Great post!", "Very helpful!");
}
}The flush-and-clear Pattern
The most important TestEntityManager pattern: call flush() then clear() before asserting on repository reads.
@Test
void findById_readsFromDatabase() {
var author = em.persistAndFlush(new Author("bob", "bob@example.com"));
Long id = author.getId();
// Without clear(), findById() returns the cached in-memory instance,
// not what's actually in the database.
em.clear();
var found = authorRepository.findById(id);
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("bob@example.com");
}flush() sends pending SQL to the database. clear() evicts the first-level cache so the next read issues a real SQL SELECT. Without this, you might be asserting on Hibernate's in-memory object rather than what's actually stored — and lazy-loaded fields, computed columns, and database-level defaults will not reflect correctly.
Testing Custom JPQL Queries
Derived query method names are safe by convention. Custom @Query annotations are where mistakes hide. Test them with representative data:
@DataJpaTest
class ArticleRepositoryQueryTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@BeforeEach
void setUp() {
var alice = em.persist(new Author("alice", "alice@example.com"));
var bob = em.persist(new Author("bob", "bob@example.com"));
em.persist(new Article("Spring Boot Basics", alice,
ArticleStatus.PUBLISHED, LocalDateTime.now().minusDays(5)));
em.persist(new Article("Advanced Spring", alice,
ArticleStatus.PUBLISHED, LocalDateTime.now().minusDays(2)));
em.persist(new Article("Draft Post", alice,
ArticleStatus.DRAFT, LocalDateTime.now()));
em.persist(new Article("Bob's Article", bob,
ArticleStatus.PUBLISHED, LocalDateTime.now().minusDays(1)));
em.flush();
}
@Test
void findPublishedByAuthor_excludesDrafts() {
var articles = articleRepository.findPublishedByAuthorEmail("alice@example.com");
assertThat(articles).hasSize(2);
assertThat(articles).noneMatch(a -> a.getStatus() == ArticleStatus.DRAFT);
}
@Test
void findPublishedByAuthor_orderedByPublishedDateDesc() {
var articles = articleRepository.findPublishedByAuthorEmail("alice@example.com");
assertThat(articles.get(0).getTitle()).isEqualTo("Advanced Spring");
assertThat(articles.get(1).getTitle()).isEqualTo("Spring Boot Basics");
}
@Test
void findPublishedByAuthor_doesNotReturnOtherAuthorsArticles() {
var articles = articleRepository.findPublishedByAuthorEmail("alice@example.com");
assertThat(articles).noneMatch(a -> a.getAuthor().getEmail().equals("bob@example.com"));
}
}Each test verifies one aspect of the query behavior. Merging them into one test makes it harder to diagnose which condition is broken when the test fails.
Testing Specifications
Specification implementations need direct testing — testing them only through the controller layer hides which condition actually failed.
@DataJpaTest
class ArticleSpecificationTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository; // extends JpaSpecificationExecutor<Article>
@BeforeEach
void setUp() {
em.persist(new Article("Java Testing", "java,testing", ArticleStatus.PUBLISHED, 150));
em.persist(new Article("Spring Security", "spring,security", ArticleStatus.PUBLISHED, 300));
em.persist(new Article("Kotlin Coroutines", "kotlin", ArticleStatus.DRAFT, 75));
em.flush();
}
@Test
void statusSpec_filtersCorrectly() {
var spec = ArticleSpecification.hasStatus(ArticleStatus.PUBLISHED);
var results = articleRepository.findAll(spec);
assertThat(results).hasSize(2);
assertThat(results).allMatch(a -> a.getStatus() == ArticleStatus.PUBLISHED);
}
@Test
void combinedSpec_appliesBothConditions() {
var spec = ArticleSpecification.hasStatus(ArticleStatus.PUBLISHED)
.and(ArticleSpecification.hasTagContaining("spring"));
var results = articleRepository.findAll(spec);
assertThat(results).hasSize(1);
assertThat(results.get(0).getTitle()).isEqualTo("Spring Security");
}
@Test
void minReadTimeSpec_excludesShortArticles() {
var spec = ArticleSpecification.minReadTimeSeconds(200);
var results = articleRepository.findAll(spec);
assertThat(results).hasSize(1);
assertThat(results.get(0).getTitle()).isEqualTo("Spring Security");
}
}Testing Pagination and Sorting
Pagination bugs — wrong page size, reversed sort order, incorrect total counts — are common and subtle:
@DataJpaTest
class ArticlePaginationTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@BeforeEach
void setUp() {
IntStream.rangeClosed(1, 25).forEach(i -> {
var article = new Article("Article " + i, ArticleStatus.PUBLISHED);
article.setViewCount(i * 100);
em.persist(article);
});
em.flush();
}
@Test
void firstPage_returnsCorrectSizeAndMetadata() {
var pageable = PageRequest.of(0, 10, Sort.by("viewCount").descending());
var page = articleRepository.findAll(pageable);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.isFirst()).isTrue();
assertThat(page.isLast()).isFalse();
}
@Test
void lastPage_containsRemainingElements() {
var pageable = PageRequest.of(2, 10);
var page = articleRepository.findAll(pageable);
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
@Test
void sortByViewCountDescending_highestViewsFirst() {
var pageable = PageRequest.of(0, 5, Sort.by("viewCount").descending());
var page = articleRepository.findAll(pageable);
var viewCounts = page.getContent().stream()
.map(Article::getViewCount)
.toList();
assertThat(viewCounts).isSortedAccordingTo(Comparator.reverseOrder());
assertThat(viewCounts.get(0)).isEqualTo(2500);
}
}Switching to Real PostgreSQL with Testcontainers
H2 is convenient but not PostgreSQL. SQL dialects differ, constraint handling varies, and PostgreSQL-specific features — JSONB, arrays, full-text search, window functions — do not work in H2 at all.
Use @AutoConfigureTestDatabase(replace = NONE) with a PostgreSQLContainer to run against real Postgres:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ArticleRepositoryPostgresTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@Test
void postgresFullTextSearch_findsMatchingArticles() {
em.persist(new Article("Spring Boot Testing Guide",
"A comprehensive guide to Spring Boot testing."));
em.persist(new Article("Docker in Production",
"Deploying containers to production environments."));
em.flush();
var results = articleRepository.fullTextSearch("Spring Boot");
assertThat(results).hasSize(1);
assertThat(results.get(0).getTitle()).isEqualTo("Spring Boot Testing Guide");
}
@Test
void jsonbAttributeQuery_worksWithPostgres() {
var article = new Article("Feature Article");
article.setMetadata(Map.of("readTime", 5, "difficulty", "intermediate"));
em.persistAndFlush(article);
var results = articleRepository.findByMetadataField("difficulty", "intermediate");
assertThat(results).hasSize(1);
}
}For sharing the container across multiple test classes, use a static base class:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
abstract class PostgresRepositoryTestBase {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withReuse(true);
POSTGRES.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}Testing Audit Fields
Spring Data auditing (@CreatedDate, @LastModifiedDate) requires the auditing infrastructure to be active. Import your @EnableJpaAuditing configuration explicitly in the test:
@DataJpaTest
@Import(JpaAuditingConfig.class)
class ArticleAuditingTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@Test
void createdDate_isSetOnPersist() {
var article = articleRepository.save(new Article("Audit Test Article"));
em.flush();
em.clear();
var reloaded = articleRepository.findById(article.getId()).orElseThrow();
assertThat(reloaded.getCreatedAt()).isNotNull();
assertThat(reloaded.getCreatedAt()).isBefore(LocalDateTime.now().plusSeconds(1));
}
@Test
void lastModifiedDate_updatesOnSave() throws InterruptedException {
var article = articleRepository.save(new Article("Original Title"));
em.flush();
var createdAt = article.getCreatedAt();
Thread.sleep(50); // ensure timestamp changes
article.setTitle("Updated Title");
articleRepository.save(article);
em.flush();
em.clear();
var reloaded = articleRepository.findById(article.getId()).orElseThrow();
assertThat(reloaded.getLastModifiedAt()).isAfter(createdAt);
assertThat(reloaded.getCreatedAt()).isEqualTo(createdAt); // must not change
}
}Testing Cascade and OrphanRemoval
Cascade settings and orphanRemoval are a classic source of bugs that only show up in integration tests:
@DataJpaTest
class CascadeRelationshipTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@Autowired
private CommentRepository commentRepository;
@Test
void deletingArticle_cascadesToComments() {
var author = em.persist(new Author("alice", "alice@example.com"));
var article = em.persist(new Article("Parent Article", author));
em.persist(new Comment("First comment", article, author));
em.persist(new Comment("Second comment", article, author));
em.flush();
var articleId = article.getId();
articleRepository.deleteById(articleId);
em.flush();
em.clear();
assertThat(articleRepository.findById(articleId)).isEmpty();
assertThat(commentRepository.findByArticleId(articleId)).isEmpty();
}
@Test
void removingTagFromCollection_triggersOrphanRemoval() {
var article = new Article("Tagged Article");
article.getTags().add(new Tag("spring"));
article.getTags().add(new Tag("testing"));
var saved = articleRepository.save(article);
em.flush();
saved.getTags().removeIf(t -> t.getName().equals("testing"));
articleRepository.save(saved);
em.flush();
em.clear();
var reloaded = articleRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getTags()).hasSize(1);
assertThat(reloaded.getTags().get(0).getName()).isEqualTo("spring");
}
}What to Test vs What to Skip
Test these explicitly:
- Custom
@Querymethods — every query condition and ordering rule Specificationimplementations — each condition in isolation and in combination- Cascade behavior — both delete cascade and orphanRemoval
- Audit fields — that
createdAtis set andupdatedAtchanges correctly - Pagination — page sizes, total counts, sort direction
Skip these:
- Derived query method names like
findBySlug— Spring Data generates these; trust the framework - Basic CRUD —
save(),findById(),deleteById()are framework methods, not your code - Getter/setter behavior on entities
The principle: test your code, not the framework. Repository tests are for your custom queries, your cascade configurations, your specifications — the code you wrote, where bugs can actually hide.