Spring Data JPA Testing: @DataJpaTest, TestEntityManager, and Testcontainers PostgreSQL

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.

Read more