@DataJpaTest

Test entities, repositories and queries in a JPA-only slice that rolls back after each test, against an embedded database or a real one.

Syntax@DataJpaTest class RepositoryTest { @Autowired TestEntityManager em; @Autowired MyRepository repository; // each test rolls back } @AutoConfigureTestDatabase(replace = Replace.NONE) // keep the configured DataSource

Repository bugs are rarely in Spring Data itself. They are in your mappings, your derived query names, your JPQL and the constraints your schema enforces. @DataJpaTest (from org.springframework.boot.data.jpa.test.autoconfigure in Spring Boot 4) starts just enough to test those: a DataSource, Hibernate, Spring Data repositories, schema migrations, and a TestEntityManager. Controllers and services are not loaded.

Rollback by default

The annotation is meta-annotated with @Transactional, so each test runs in a transaction that is rolled back at the end. Tests do not see each other's rows and need no clean-up code. The flip side: nothing is ever committed, so commit-time behaviour (deferred constraints, @TransactionalEventListener after commit) is not exercised here.

Which database

By default the slice replaces the application's DataSource with an embedded one (H2, HSQLDB or Derby, whichever is on the test classpath). The default mode, Replace.NON_TEST, keeps a database that the test itself supplies, such as a Testcontainers PostgreSQL container with @ServiceConnection. To use the configured database as it is, add @AutoConfigureTestDatabase(replace = Replace.NONE).

An embedded database is fine for simple mappings. As soon as you use native SQL, PostgreSQL types such as jsonb, or database-specific locking, test against the real engine. H2's compatibility modes are close, not identical.

The persistence context hides bugs

Within one transaction Hibernate returns the same Java object you just saved, straight from its first-level cache, without reading the database. A test that saves and then finds can pass even when a column mapping is wrong. Flush the changes and clear the context before reading back:

PurchaseOrder saved = em.persistFlushFind(order);   // insert, flush, detach, select
em.flush();
em.clear();                                       // or do it by hand

Flushing also makes the database check its constraints. A unique index violation only appears when the INSERT is actually sent. With sequence-generated IDs Hibernate holds it back until a flush or the commit, and in a rolled-back test the commit never comes.

What to test

  • Custom queries: @Query, derived queries with several conditions, projections, sorting and paging.
  • Mappings that can go wrong: enums stored as strings, embedded values, relationships and cascades.
  • Constraints you rely on for correctness, such as a unique email.

Do not test save and findById on a plain entity. Spring Data already does, and the test adds run time without catching anything.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

enum OrderStatus { NEW, PAID, SHIPPED }

@Entity
@Table(name = "purchase_orders")
class PurchaseOrder {

    @Id
    @GeneratedValue
    private Long id;

    private String customerEmail;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private BigDecimal total;

    private Instant createdAt;

    protected PurchaseOrder() {
    }

    PurchaseOrder(String customerEmail, OrderStatus status, BigDecimal total, Instant createdAt) {
        this.customerEmail = customerEmail;
        this.status = status;
        this.total = total;
        this.createdAt = createdAt;
    }

    Long getId() {
        return id;
    }

    OrderStatus getStatus() {
        return status;
    }

    BigDecimal getTotal() {
        return total;
    }
}

interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    List<PurchaseOrder> findByStatusOrderByCreatedAtAsc(OrderStatus status);

    @Query("select coalesce(sum(o.total), 0) from PurchaseOrder o "
            + "where o.customerEmail = :email and o.status <> :excluded")
    BigDecimal totalSpentBy(@Param("email") String email, @Param("excluded") OrderStatus excluded);
}

@DataJpaTest
class PurchaseOrderRepositoryTest {

    @Autowired
    TestEntityManager em;

    @Autowired
    PurchaseOrderRepository orders;

    @Test
    void findsOrdersByStatusOldestFirst() {
        em.persist(new PurchaseOrder("[email protected]", OrderStatus.PAID, BigDecimal.TEN, Instant.parse("2026-01-02T00:00:00Z")));
        em.persist(new PurchaseOrder("[email protected]", OrderStatus.PAID, BigDecimal.ONE, Instant.parse("2026-01-01T00:00:00Z")));
        em.persist(new PurchaseOrder("[email protected]", OrderStatus.NEW, BigDecimal.ONE, Instant.parse("2026-01-03T00:00:00Z")));
        em.flush();
        em.clear();

        List<PurchaseOrder> paid = orders.findByStatusOrderByCreatedAtAsc(OrderStatus.PAID);

        assertThat(paid).extracting(PurchaseOrder::getTotal)
                .usingElementComparator(BigDecimal::compareTo)
                .containsExactly(BigDecimal.ONE, BigDecimal.TEN);
    }

    @Test
    void totalSpentIgnoresExcludedOrdersAndIsZeroForNewCustomers() {
        em.persist(new PurchaseOrder("[email protected]", OrderStatus.PAID, new BigDecimal("20.00"), Instant.now()));
        em.persist(new PurchaseOrder("[email protected]", OrderStatus.NEW, new BigDecimal("5.00"), Instant.now()));
        em.flush();

        assertThat(orders.totalSpentBy("[email protected]", OrderStatus.NEW)).isEqualByComparingTo("20.00");
        assertThat(orders.totalSpentBy("[email protected]", OrderStatus.NEW)).isEqualByComparingTo("0");
    }

    @Test
    void storesTheStatusAsText() {
        PurchaseOrder saved = em.persistFlushFind(
                new PurchaseOrder("[email protected]", OrderStatus.SHIPPED, BigDecimal.ONE, Instant.now()));

        assertThat(saved.getStatus()).isEqualTo(OrderStatus.SHIPPED);
    }
}

When to use it

  • Check that a revenue query excludes cancelled orders and returns zero, not null, for a customer with no orders.
  • Prove that the unique constraint on customer email is really in the schema before relying on it for sign-up.
  • Run a native PostgreSQL search query against a real PostgreSQL container instead of H2.

More examples

Checking a unique constraint

<code>saveAndFlush</code> sends the <code>INSERT</code> immediately, so the database checks the unique index inside the test. With a plain <code>save</code> the second row would wait for a commit that never comes, and the test would pass while proving nothing. Spring translates the driver's error into <code>DataIntegrityViolationException</code>, which is also what your service or error handler should catch.

Example · java
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.jpa.repository.JpaRepository;

@Entity
class Customer {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    protected Customer() {
    }

    Customer(String email) {
        this.email = email;
    }
}

interface CustomerRepository extends JpaRepository<Customer, Long> {
}

@DataJpaTest
class CustomerRepositoryTest {

    @Autowired
    CustomerRepository customers;

    @Test
    void rejectsADuplicateEmail() {
        customers.saveAndFlush(new Customer("[email protected]"));

        assertThatThrownBy(() -> customers.saveAndFlush(new Customer("[email protected]")))
                .isInstanceOf(DataIntegrityViolationException.class);
    }
}

Running the slice against PostgreSQL

<code>ilike</code> is PostgreSQL syntax, so this query cannot be trusted on an embedded database. Because the container is a <code>@ServiceConnection</code>, the default <code>Replace.NON_TEST</code> mode keeps it instead of swapping in an embedded database. The static field means one container for the whole class; the transaction still rolls back after each test. Binding <code>:term</code> by name without <code>@Param</code> relies on the <code>-parameters</code> compiler flag, which the Spring Boot parent turns on. Hibernate only creates tables by itself for an embedded database; against PostgreSQL <code>ddl-auto</code> defaults to <code>none</code>, so this example sets it for the slice. In a project with Flyway migrations, drop that property and let the migrations build the schema.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Entity
class Article {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

    protected Article() {
    }

    Article(String title) {
        this.title = title;
    }
}

interface ArticleRepository extends JpaRepository<Article, Long> {

    @Query(value = "select title from article where title ilike concat('%', :term, '%') order by title",
            nativeQuery = true)
    List<String> searchTitles(String term);
}

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Testcontainers
class ArticleRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine");

    @Autowired
    ArticleRepository articles;

    @Test
    void searchesTitlesCaseInsensitively() {
        articles.saveAllAndFlush(List.of(new Article("Spring Boot Testing"), new Article("Gardening")));

        assertThat(articles.searchTitles("BOOT")).containsExactly("Spring Boot Testing");
    }
}

Logging the SQL a test runs

<code>@DataJpaTest</code> already turns on <code>spring.jpa.show-sql</code> through its <code>showSql</code> attribute, which prints statements to standard output. Logging through the <code>org.hibernate.SQL</code> category goes to your normal log output instead, and the bind logger shows the actual parameter values, which is what you need when a derived query matches nothing. Set <code>@DataJpaTest(showSql = false)</code> if you use the loggers, so each statement is not printed twice. Put these in a profile file rather than <code>src/test/resources/application.properties</code>: a file with that name hides the main one on the classpath, so all of its settings vanish from the tests.

Example · properties
# src/test/resources/application-test.properties, used with @ActiveProfiles("test")
# Show the bind values next to each statement while you work on a query.
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace

# Format statements across several lines.
spring.jpa.properties.hibernate.format_sql=true

Discussion

  • Be the first to comment on this lesson.