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
@DataJpaTeststarts only JPA components — no full application context, fast startupTestEntityManager.persistAndFlush()sets up test data;clear()ensures DB reads happen- Each test runs in a rolled-back transaction — no test data pollution
- Test custom
@Querymethods — derived queries work without tests, custom ones don’t - Use
@AutoConfigureTestDatabase(replace = NONE)+ Testcontainers for PostgreSQL-specific features @Sqlloads test data from SQL files — good for complex scenarios with many rows
Next: Article 31 — Testing the Web Layer with @WebMvcTest and MockMvc.