Testing the Repository Layer with @DataJpaTest

Repository tests verify your queries work correctly against a real database. Spring Boot’s @DataJpaTest starts a minimal slice — only JPA components — making tests fast while still catching real SQL issues.

@DataJpaTest — What It Loads

@DataJpaTest is a test slice annotation:

@DataJpaTest
class OrderRepositoryTest {
    // Spring loads:
    //   - Your @Entity classes
    //   - Your @Repository interfaces
    //   - JPA infrastructure (EntityManager, transactions)
    //   - An in-memory H2 database (by default)
    //
    // Spring does NOT load:
    //   - @Service, @Controller, @Component classes
    //   - Security configuration
    //   - The full ApplicationContext
}

Each test method runs in a transaction that’s rolled back at the end — no data pollution between tests.

Basic Repository Test

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindOrdersByCustomerId() {
        // Arrange — use TestEntityManager to set up test data
        UUID customerId = UUID.randomUUID();

        Order order1 = new Order();
        order1.setCustomerId(customerId);
        order1.setOrderNumber("ORD-001");
        order1.setStatus(OrderStatus.PENDING);
        entityManager.persistAndFlush(order1);

        Order order2 = new Order();
        order2.setCustomerId(customerId);
        order2.setOrderNumber("ORD-002");
        order2.setStatus(OrderStatus.CONFIRMED);
        entityManager.persistAndFlush(order2);

        // Different customer's order — should NOT be returned
        Order otherOrder = new Order();
        otherOrder.setCustomerId(UUID.randomUUID());
        otherOrder.setOrderNumber("ORD-003");
        entityManager.persistAndFlush(otherOrder);

        entityManager.clear();  // clear cache — force actual DB reads

        // Act
        List<Order> result = orderRepository.findByCustomerId(customerId);

        // Assert
        assertThat(result).hasSize(2);
        assertThat(result).extracting(Order::getOrderNumber)
            .containsExactlyInAnyOrder("ORD-001", "ORD-002");
    }

    @Test
    void shouldCountOrdersByStatus() {
        entityManager.persistAndFlush(orderWithStatus(OrderStatus.PENDING));
        entityManager.persistAndFlush(orderWithStatus(OrderStatus.PENDING));
        entityManager.persistAndFlush(orderWithStatus(OrderStatus.CONFIRMED));

        long pendingCount = orderRepository.countByStatus(OrderStatus.PENDING);

        assertThat(pendingCount).isEqualTo(2);
    }

    private Order orderWithStatus(OrderStatus status) {
        Order o = new Order();
        o.setCustomerId(UUID.randomUUID());
        o.setOrderNumber("ORD-" + UUID.randomUUID());
        o.setStatus(status);
        return o;
    }
}

TestEntityManager vs Repository

Use TestEntityManager for setup — it bypasses your repository logic and directly manipulates the JPA layer:

// Setup test data (direct JPA, bypasses business logic)
entityManager.persist(new Order(...));
entityManager.flush();  // write to DB
entityManager.clear();  // clear first-level cache

// Then test repository methods
List<Order> result = orderRepository.findByStatus(OrderStatus.PENDING);

Use your repository methods for assertions when you want to verify the full round-trip:

@Test
void shouldPersistAndRetrieveOrder() {
    Order order = new Order();
    order.setCustomerId(customerId);
    order.setStatus(OrderStatus.PENDING);

    Order saved = orderRepository.save(order);  // test the repository itself
    entityManager.clear();

    Optional<Order> found = orderRepository.findById(saved.getId());
    assertThat(found).isPresent();
    assertThat(found.get().getStatus()).isEqualTo(OrderStatus.PENDING);
}

Testing Custom @Query Methods

The main reason to write repository tests — verify your JPQL and native queries:

@Test
void shouldFindLargeOrders() {
    // Setup
    entityManager.persist(orderWithAmount(BigDecimal.valueOf(150)));
    entityManager.persist(orderWithAmount(BigDecimal.valueOf(50)));
    entityManager.persist(orderWithAmount(BigDecimal.valueOf(200)));
    entityManager.flush();
    entityManager.clear();

    // Test
    BigDecimal threshold = BigDecimal.valueOf(100);
    List<Order> largeOrders = orderRepository.findByTotalAmountGreaterThan(threshold);

    assertThat(largeOrders).hasSize(2);
    assertThat(largeOrders)
        .extracting(Order::getTotalAmount)
        .allSatisfy(amount -> assertThat(amount).isGreaterThan(threshold));
}

@Test
void shouldReturnOrderSummaryProjection() {
    UUID customerId = UUID.randomUUID();
    entityManager.persist(orderForCustomer(customerId, OrderStatus.PENDING, BigDecimal.TEN));
    entityManager.persist(orderForCustomer(customerId, OrderStatus.CONFIRMED, BigDecimal.valueOf(20)));
    entityManager.flush();
    entityManager.clear();

    List<OrderSummary> summaries = orderRepository.findSummariesByCustomer(customerId);

    assertThat(summaries).hasSize(2);
    assertThat(summaries).extracting(OrderSummary::getStatus)
        .containsExactlyInAnyOrder(OrderStatus.PENDING, OrderStatus.CONFIRMED);
}

@Test
void shouldBulkUpdateStatus() {
    entityManager.persist(orderWithStatus(OrderStatus.PENDING));
    entityManager.persist(orderWithStatus(OrderStatus.PENDING));
    entityManager.persist(orderWithStatus(OrderStatus.CONFIRMED));
    entityManager.flush();
    entityManager.clear();

    int updated = orderRepository.updateStatusBulk(OrderStatus.PENDING, OrderStatus.CANCELLED);

    assertThat(updated).isEqualTo(2);

    List<Order> cancelled = orderRepository.findByStatus(OrderStatus.CANCELLED);
    assertThat(cancelled).hasSize(2);
}

Using Real PostgreSQL with Testcontainers

H2 doesn’t support JSONB, arrays, or PostgreSQL-specific SQL. Use Testcontainers for a real database:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)  // don't replace with H2
@Testcontainers
class OrderRepositoryPostgresTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .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);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");  // or use Flyway
    }

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldSearchByJsonbMetadata() {
        Order order = new Order();
        order.setMetadata(new OrderMetadata("web", "REF123", List.of("vip"), Map.of()));
        entityManager.persistAndFlush(order);
        entityManager.clear();

        List<Order> results = orderRepository.findByChannel("web");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getMetadata().channel()).isEqualTo("web");
    }
}

Reusing the Container Across Tests

Starting a container for each test class is slow. Share one container:

// Shared base class
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
public abstract class PostgresRepositoryTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16")
        .withReuse(true);  // reuse between test class runs

    @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 it
class OrderRepositoryTest extends PostgresRepositoryTest {

    @Autowired OrderRepository orderRepository;

    @Test
    void shouldFindByStatus() { ... }
}

Testing Specifications

@Test
void shouldFilterByMultipleCriteria() {
    UUID customerId = UUID.randomUUID();

    entityManager.persist(orderForCustomer(customerId, OrderStatus.PENDING, BigDecimal.valueOf(150)));
    entityManager.persist(orderForCustomer(customerId, OrderStatus.CONFIRMED, BigDecimal.valueOf(200)));
    entityManager.persist(orderForCustomer(UUID.randomUUID(), OrderStatus.PENDING, BigDecimal.valueOf(50)));
    entityManager.flush();
    entityManager.clear();

    Specification<Order> spec = Specification
        .where(OrderSpecifications.hasCustomer(customerId))
        .and(OrderSpecifications.hasStatus(OrderStatus.PENDING));

    List<Order> result = orderRepository.findAll(spec);

    assertThat(result).hasSize(1);
    assertThat(result.get(0).getCustomerId()).isEqualTo(customerId);
    assertThat(result.get(0).getStatus()).isEqualTo(OrderStatus.PENDING);
}

Testing Pagination

@Test
void shouldReturnCorrectPage() {
    // Persist 5 orders
    for (int i = 0; i < 5; i++) {
        entityManager.persist(orderWithStatus(OrderStatus.PENDING));
    }
    entityManager.flush();
    entityManager.clear();

    PageRequest pageable = PageRequest.of(0, 3, Sort.by("createdAt").descending());
    Page<Order> page = orderRepository.findByStatus(OrderStatus.PENDING, pageable);

    assertThat(page.getContent()).hasSize(3);
    assertThat(page.getTotalElements()).isEqualTo(5);
    assertThat(page.getTotalPages()).isEqualTo(2);
    assertThat(page.hasNext()).isTrue();
}

Verifying Cascade Behavior

@Test
void shouldCascadeDeleteItemsWhenOrderIsDeleted() {
    Order order = new Order();
    order.setCustomerId(UUID.randomUUID());

    OrderItem item1 = new OrderItem();
    item1.setProductId(UUID.randomUUID());
    item1.setQuantity(2);
    order.addItem(item1);

    OrderItem item2 = new OrderItem();
    item2.setProductId(UUID.randomUUID());
    item2.setQuantity(1);
    order.addItem(item2);

    orderRepository.save(order);
    entityManager.flush();

    UUID orderId = order.getId();
    long itemsBefore = entityManager.getEntityManager()
        .createQuery("SELECT COUNT(i) FROM OrderItem i WHERE i.order.id = :id", Long.class)
        .setParameter("id", orderId)
        .getSingleResult();
    assertThat(itemsBefore).isEqualTo(2);

    orderRepository.deleteById(orderId);
    entityManager.flush();
    entityManager.clear();

    long itemsAfter = entityManager.getEntityManager()
        .createQuery("SELECT COUNT(i) FROM OrderItem i WHERE i.order.id = :id", Long.class)
        .setParameter("id", orderId)
        .getSingleResult();
    assertThat(itemsAfter).isEqualTo(0);  // cascade delete worked
}

@Sql — Load Test Data from Files

@Test
@Sql("/test-data/orders.sql")        // runs before test
@Sql(value = "/cleanup.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)  // runs after test
void shouldFindOrdersFromSqlFile() {
    List<Order> orders = orderRepository.findAll();
    assertThat(orders).hasSize(3);
}
-- src/test/resources/test-data/orders.sql
INSERT INTO orders (id, customer_id, order_number, status, total_amount)
VALUES
    ('550e8400-e29b-41d4-a716-446655440001', '550e8400-e29b-41d4-a716-446655440010', 'ORD-001', 'PENDING', 99.99),
    ('550e8400-e29b-41d4-a716-446655440002', '550e8400-e29b-41d4-a716-446655440010', 'ORD-002', 'CONFIRMED', 149.99),
    ('550e8400-e29b-41d4-a716-446655440003', '550e8400-e29b-41d4-a716-446655440011', 'ORD-003', 'PENDING', 49.99);

What You’ve Learned

  • @DataJpaTest starts only JPA components — no full application context, fast startup
  • TestEntityManager.persistAndFlush() sets up test data; clear() ensures DB reads happen
  • Each test runs in a rolled-back transaction — no test data pollution
  • Test custom @Query methods — derived queries work without tests, custom ones don’t
  • Use @AutoConfigureTestDatabase(replace = NONE) + Testcontainers for PostgreSQL-specific features
  • @Sql loads test data from SQL files — good for complex scenarios with many rows

Next: Article 31 — Testing the Web Layer with @WebMvcTest and MockMvc.