Spring Data JPA Testing: @DataJpaTest, TestEntityManager, and Testcontainers PostgreSQL
The repository layer is where your application talks to the database, and bugs here are some of the nastiest to debug in production — wrong query results, missing cascades, pagination off by one, audit timestamps not set. Testing this layer thoroughly before it reaches production is not optional; it's essential.
Spring Boot's @DataJpaTest slice annotation gives you a fast, focused test environment that loads only what's needed for repository tests: JPA entities, repositories, the DataSource, JPA configuration, and Spring Data. Everything else — controllers, services, security — is excluded. This guide covers the full toolkit for testing your JPA layer, from basic repository tests with TestEntityManager through real PostgreSQL integration with Testcontainers.
The @DataJpaTest Slice
By default, @DataJpaTest configures an in-memory H2 database, wraps each test in a transaction that rolls back automatically, and provides TestEntityManager for managing test entities. Here's a minimal example:
@DataJpaTest
class ArticleRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ArticleRepository articleRepository;
@Test
void findBySlug_returnsMatchingArticle() {
Article article = new Article();
article.setTitle("Getting Started with Spring Boot");
article.setSlug("getting-started-with-spring-boot");
article.setStatus(ArticleStatus.PUBLISHED);
entityManager.persistAndFlush(article);
Optional<Article> found = articleRepository.findBySlug("getting-started-with-spring-boot");
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Getting Started with Spring Boot");
}
@Test
void findBySlug_returnsEmptyForUnknownSlug() {
Optional<Article> found = articleRepository.findBySlug("non-existent-slug");
assertThat(found).isEmpty();
}
}The test rolls back after each method — you get a clean database state for every test without any manual cleanup.
TestEntityManager: Persisting Test Fixtures
TestEntityManager is a test-focused wrapper around EntityManager. It provides convenience methods for the most common fixture operations:
@DataJpaTest
class CommentRepositoryTest {
@Autowired
private TestEntityManager em;
@Autowired
private CommentRepository commentRepository;
@Test
void findByArticleId_returnsOnlyCommentsForThatArticle() {
Author author = em.persist(new Author("alice", "alice@example.com"));
Article article1 = em.persist(new Article("Article One", author));
Article 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 comment", article2, author));
em.flush();
List<Comment> comments = commentRepository.findByArticleId(article1.getId());
assertThat(comments).hasSize(2);
assertThat(comments).extracting(Comment::getText)
.containsExactlyInAnyOrder("Great post!", "Very helpful!");
}
@Test
void findById_returnsPersistedEntity() {
Author author = em.persistAndFlush(new Author("bob", "bob@example.com"));
Long id = author.getId();
// Clear first-level cache to force a real DB read
em.clear();
Optional<Author> found = authorRepository.findById(id);
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("bob@example.com");
}
}The em.clear() call is an important pattern: it evicts all entities from the first-level cache, forcing Hibernate to issue a real SQL SELECT rather than returning the cached instance. Without it, you might be asserting on an in-memory object rather than what's actually in the database.
Testing Custom JPQL and Native Queries
Derived query method names (findBySlugAndStatus) are safe by convention, but custom @Query annotations are where mistakes happen. Test them explicitly:
@DataJpaTest
class ArticleRepositoryQueryTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@BeforeEach
void setUp() {
Author alice = em.persist(new Author("alice", "alice@example.com"));
Author 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() {
List<Article> aliceArticles = articleRepository.findPublishedByAuthorEmail("alice@example.com");
assertThat(aliceArticles).hasSize(2);
assertThat(aliceArticles).noneMatch(a -> a.getStatus() == ArticleStatus.DRAFT);
}
@Test
void findPublishedByAuthor_ordersByPublishedDateDesc() {
List<Article> aliceArticles = articleRepository.findPublishedByAuthorEmail("alice@example.com");
assertThat(aliceArticles.get(0).getTitle()).isEqualTo("Advanced Spring");
assertThat(aliceArticles.get(1).getTitle()).isEqualTo("Spring Boot Basics");
}
@Test
void searchByKeyword_matchesTitleAndContent() {
// Tests a native full-text search query
List<Article> results = articleRepository.searchByKeyword("Spring");
assertThat(results).hasSize(2);
assertThat(results).extracting(Article::getTitle)
.containsExactlyInAnyOrder("Spring Boot Basics", "Advanced Spring");
}
}Testing Specification and Criteria Queries
Specification implementations are notoriously hard to test through the controller layer alone. Test them directly against the repository:
@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() {
Specification<Article> spec = ArticleSpecification.hasStatus(ArticleStatus.PUBLISHED);
List<Article> results = articleRepository.findAll(spec);
assertThat(results).hasSize(2);
assertThat(results).allMatch(a -> a.getStatus() == ArticleStatus.PUBLISHED);
}
@Test
void combinedSpec_appliesBothConditions() {
Specification<Article> spec = ArticleSpecification.hasStatus(ArticleStatus.PUBLISHED)
.and(ArticleSpecification.hasTagContaining("spring"));
List<Article> results = articleRepository.findAll(spec);
assertThat(results).hasSize(1);
assertThat(results.get(0).getTitle()).isEqualTo("Spring Security");
}
@Test
void minReadTimeSpec_filtersShortPosts() {
Specification<Article> spec = ArticleSpecification.minReadTimeSeconds(100);
List<Article> results = articleRepository.findAll(spec);
assertThat(results).hasSize(2);
}
}Testing Pagination and Sorting
Pagination bugs — wrong page size, incorrect sort direction, off-by-one on total counts — are common and hard to spot without explicit tests:
@DataJpaTest
class ArticlePaginationTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@BeforeEach
void setUp() {
for (int i = 1; i <= 25; i++) {
Article article = new Article("Article " + i, ArticleStatus.PUBLISHED);
article.setViewCount(i * 100);
em.persist(article);
}
em.flush();
}
@Test
void firstPageReturnsCorrectSize() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("viewCount").descending());
Page<Article> 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 lastPageContainsRemainingElements() {
Pageable pageable = PageRequest.of(2, 10);
Page<Article> page = articleRepository.findAll(pageable);
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
@Test
void sortByViewCountDescending_returnsHighestFirst() {
Pageable pageable = PageRequest.of(0, 5, Sort.by("viewCount").descending());
Page<Article> page = articleRepository.findAll(pageable);
List<Integer> viewCounts = page.getContent().stream()
.map(Article::getViewCount)
.collect(Collectors.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, full-text search behaves differently, constraint handling varies, and PostgreSQL-specific features like JSONB, arrays, or window functions won't work with H2 at all. Use @AutoConfigureTestDatabase(replace = NONE) combined with Testcontainers to test against a real PostgreSQL instance:
<!-- pom.xml -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>@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."));
em.flush();
List<Article> results = articleRepository.fullTextSearch("Spring Boot");
assertThat(results).hasSize(1);
assertThat(results.get(0).getTitle()).isEqualTo("Spring Boot Testing Guide");
}
@Test
void jsonbAttributeQuery_worksWithPostgres() {
Article article = new Article("Feature Article");
article.setMetadata(Map.of("readTime", 5, "difficulty", "intermediate"));
em.persistAndFlush(article);
List<Article> results = articleRepository.findByMetadataField("difficulty", "intermediate");
assertThat(results).hasSize(1);
}
}Sharing the Container Across Tests
Starting a new PostgreSQL container for each test class is slow. Share it using a base class:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class PostgresIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withReuse(true); // Requires .testcontainers.properties with testcontainers.reuse.enable=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);
}
}
// Extend in all Postgres-specific tests:
class ArticleRepositoryPostgresTest extends PostgresIntegrationTest {
// tests here...
}Testing Audit Fields
Spring Data's auditing (@CreatedDate, @LastModifiedDate) requires the auditing infrastructure to be active. In @DataJpaTest, you need to import the auditing configuration:
@DataJpaTest
@Import(JpaAuditingConfig.class) // Your @EnableJpaAuditing configuration class
class ArticleAuditingTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@Test
void createdDate_isSetOnPersist() {
Article article = new Article("Audit Test Article");
Article saved = articleRepository.save(article);
em.flush();
em.clear();
Article reloaded = articleRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getCreatedAt()).isNotNull();
assertThat(reloaded.getCreatedAt()).isBefore(LocalDateTime.now().plusSeconds(1));
}
@Test
void lastModifiedDate_isUpdatedOnSave() throws InterruptedException {
Article article = articleRepository.save(new Article("Original Title"));
em.flush();
LocalDateTime createdAt = article.getCreatedAt();
Thread.sleep(100); // Ensure timestamp changes
article.setTitle("Updated Title");
articleRepository.save(article);
em.flush();
em.clear();
Article reloaded = articleRepository.findById(article.getId()).orElseThrow();
assertThat(reloaded.getLastModifiedAt()).isAfter(createdAt);
assertThat(reloaded.getCreatedAt()).isEqualTo(createdAt); // createdAt must NOT change
}
}Testing Cascades and Relationships
Cascade and orphanRemoval settings are a classic source of bugs — deleting a parent that should cascade to children, or leaving orphaned records when removing a collection element:
@DataJpaTest
class CascadeRelationshipTest {
@Autowired
private TestEntityManager em;
@Autowired
private ArticleRepository articleRepository;
@Autowired
private CommentRepository commentRepository;
@Test
void deletingArticle_cascadesToComments() {
Author author = em.persist(new Author("alice", "alice@example.com"));
Article 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();
Long articleId = article.getId();
articleRepository.deleteById(articleId);
em.flush();
em.clear();
assertThat(articleRepository.findById(articleId)).isEmpty();
assertThat(commentRepository.findByArticleId(articleId)).isEmpty();
}
@Test
void removingTagFromCollection_triggersOrphanRemoval() {
Article article = new Article("Tagged Article");
article.getTags().add(new Tag("spring"));
article.getTags().add(new Tag("testing"));
Article saved = articleRepository.save(article);
em.flush();
saved.getTags().removeIf(t -> t.getName().equals("testing"));
articleRepository.save(saved);
em.flush();
em.clear();
Article reloaded = articleRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getTags()).hasSize(1);
assertThat(reloaded.getTags().get(0).getName()).isEqualTo("spring");
}
}Best Practices
Always call em.flush() and em.clear() before assertions. Flush sends pending SQL to the database. Clear evicts the cache. Together, they ensure you're reading from the database, not from Hibernate's memory.
Test one behavior per test. Each test method should verify one specific behavior — one query condition, one cascade rule, one sort direction. Mixing concerns makes failures harder to diagnose.
Use @Sql for complex initial data. For elaborate scenarios, SQL scripts loaded with @Sql are clearer than many em.persist() calls.
Move to Testcontainers for PostgreSQL-specific features. Don't rely on H2 for dialect-specific SQL. If your production database is PostgreSQL, your tests should run against PostgreSQL.
Continuous Repository Testing
JPA repositories are a living layer that changes with every migration and schema update. A query that worked last sprint might return wrong results after a column rename or index change.
HelpMeTest integrates with your CI/CD pipeline to run repository tests automatically on every commit, flagging regressions the moment they appear rather than after they've reached production. Pair @DataJpaTest coverage with continuous automated monitoring, and your data layer becomes a verified, reliable foundation your entire application can depend on.