- 1편 — @DataJpaTest로 쿼리 검증하기 ← 현재
QueryDSL로 작성한 커서 기반 페이지네이션 쿼리가 있다. 유닛 테스트로는 이 쿼리가 DB에서 진짜 의도대로 동작하는지 알 수 없다. @DataJpaTest는 JPA 관련 빈만 띄우고 내장 DB를 연결해서 쿼리를 직접 검증할 수 있게 해준다.
@DataJpaTest가 해주는 일
이 어노테이션 하나로 Spring이 알아서 처리하는 것들이 있다.
- JPA 관련 빈만 로드한다. Service, Controller는 올리지 않는다.
- 내장 H2 DB를 자동으로 띄운다.
- 각 테스트 메서드를
@Transactional로 감싸서 끝나면 자동 롤백한다. TestEntityManager를 제공한다.
전체 컨텍스트를 올리는 @SpringBootTest보다 훨씬 빠르다. Repository 레이어만 검증할 때는 이걸 쓰면 된다.
로드 범위가 좁기 때문에 Service나 Controller에서 쓰는 빈은 포함되지 않는다. 그래서 JPAQueryFactory 같은 커스텀 설정도 직접 가져와야 한다.
기본 셋업
@Import로 QueryDSL 설정 가져오기
@DataJpaTest는 @Configuration 클래스를 자동으로 스캔하지 않는다. QueryDSL 커스텀 레포지토리를 테스트하려면 JPAQueryFactory 빈이 필요한데, @Import로 직접 가져와야 한다.
@DataJpaTest
@Import(QueryDslConfig.class)
class MessageRepositoryCustomImplTest {
@Autowired
private MessageRepository messageRepository;
@Autowired
private TestEntityManager em;
}
@Import를 빼면 NoSuchBeanDefinitionException: JPAQueryFactory가 뜬다.
@Import 대상 확인프로젝트의 QueryDslConfig 클래스에 JPAQueryFactory 빈이 정의되어 있어야 한다. 없다면 테스트 전용 @TestConfiguration으로 만들어도 된다.
TestEntityManager로 데이터 넣기
TestEntityManager는 JPA의 EntityManager를 테스트에 맞게 감싼 것이다. persist, flush, clear를 체이닝해서 쓴다.
User sender = em.persist(createUser("sender"));
User receiver = em.persist(createUser("receiver"));
em.persist(Message.create(sender, receiver, "hello"));
em.flush();
em.clear();
이 세 단계가 왜 필요한지 흐름으로 보면 명확하다.
초록 영역에서 데이터를 DB에 밀어넣고, 주황 영역에서 1차 캐시를 비운다. 파란 영역에서 실제 쿼리를 검증하는데, 이때 캐시가 비어있으니 진짜 DB 쿼리가 실행된다.
clear() 빠뜨리면 생기는 일1차 캐시에 엔티티가 남아있으면 Repository 메서드가 DB를 조회하지 않고 캐시에서 꺼내온다. 쿼리에 버그가 있어도 테스트가 통과해버린다. 의미 없는 테스트가 되는 것이다.
커서 기반 페이지네이션 테스트
실전에서 MessageRepositoryCustomImpl의 커서 조회를 테스트하는 방법이다.
첫 페이지 조회
커서가 없으면 가장 최신 데이터부터 조회한다. limit + 1개를 요청하는 것이 핵심이다.
@Test
void 커서_없이_첫_페이지를_조회한다() {
// given
User sender = em.persist(createUser("sender"));
User receiver = em.persist(createUser("receiver"));
for (int i = 0; i < 5; i++) {
em.persist(Message.create(sender, receiver, "msg" + i));
}
em.flush();
em.clear();
MessageGetRequest request = new MessageGetRequest(
sender.getId(), null, null, 3);
// when
List<Message> result = messageRepository.getByCursor(request);
// then
assertThat(result).hasSize(4); // limit(3) + 1
}
limit + 1개가 돌아오면 다음 페이지가 있다는 뜻이다. 정확히 limit개 이하면 마지막 페이지다. 이 트릭은 서비스 레이어에서 hasNext를 판정하는 데 쓰인다.
다음 페이지 조회
첫 페이지의 마지막 항목을 커서로 넘기면 그 이후 데이터를 가져온다.
@Test
void 커서를_이용해_다음_페이지를_조회한다() {
// given
User sender = em.persist(createUser("sender"));
User receiver = em.persist(createUser("receiver"));
List<Message> saved = new ArrayList<>();
for (int i = 0; i < 5; i++) {
saved.add(em.persist(Message.create(sender, receiver, "msg" + i)));
}
em.flush();
em.clear();
// 첫 페이지 조회
MessageGetRequest firstPage = new MessageGetRequest(
sender.getId(), null, null, 2);
List<Message> firstResult = messageRepository.getByCursor(firstPage);
// 첫 페이지 마지막 항목을 커서로
Message lastOfFirst = firstResult.get(1);
MessageGetRequest secondPage = new MessageGetRequest(
sender.getId(),
lastOfFirst.getCreatedAt(),
lastOfFirst.getId(),
2);
// when
List<Message> secondResult = messageRepository.getByCursor(secondPage);
// then
assertThat(secondResult).hasSize(3); // 남은 3개 중 limit+1=3
}
커서 조건은 (createdAt, id) 쌍으로 동작한다. createdAt이 같은 데이터가 있을 수 있으니 id로 tie-breaking을 한다.
where 조건 검증
DM 조회라면 특정 사용자가 참여한 대화만 필터해야 한다. 다른 사용자의 메시지가 섞이지 않는지 검증한다.
@Test
void 해당_사용자의_메시지만_조회한다() {
// given
User userA = em.persist(createUser("A"));
User userB = em.persist(createUser("B"));
User userC = em.persist(createUser("C"));
em.persist(Message.create(userA, userB, "A→B"));
em.persist(Message.create(userB, userA, "B→A"));
em.persist(Message.create(userB, userC, "B→C")); // 무관한 대화
em.flush();
em.clear();
MessageGetRequest request = new MessageGetRequest(
userA.getId(), null, null, 10);
// when
List<Message> result = messageRepository.getByCursor(request);
// then
assertThat(result).hasSize(2); // A가 참여한 대화만
assertThat(result).allMatch(m ->
m.getSender().getId().equals(userA.getId()) ||
m.getReceiver().getId().equals(userA.getId()));
}
이 테스트가 실패한다면 쿼리에 userId 필터가 빠져있다는 뜻이다. 테스트를 먼저 작성하고 구현을 고치면 TDD 방식으로 버그를 잡을 수 있다.
Fixture 메서드 패턴
테스트마다 User, Message를 직접 생성하면 코드가 길어진다. Fixture 메서드로 분리하면 깔끔하다.
private User createUser(String name) {
User user = User.create(name + "@test.com", "password123", name);
return user;
}
private Message createMessage(User sender, User receiver, String content) {
return Message.create(sender, receiver, content);
}
엔티티의 정적 팩터리 메서드를 그대로 활용한다. Fixture 메서드는 테스트에 필요한 최소한의 파라미터만 받도록 설계한다. 나머지는 적당한 기본값을 넣는다.
프로젝트에서 Instancio를 도입했다면 Fixture 메서드 대신 Instancio.create(User.class)로 대체할 수 있다. 필드별 커스터마이징이 필요하면 Instancio.of(User.class).set(field("name"), "sender").create()를 쓴다.
자주 하는 실수
@Import(QueryDslConfig.class) 누락@DataJpaTest는 JPA 관련 빈만 올리기 때문에 JPAQueryFactory가 자동 등록되지 않는다. 커스텀 레포지토리를 테스트하면 NoSuchBeanDefinitionException이 뜬다. @Import로 설정 클래스를 직접 가져와야 한다.
[!DANGER] em.flush() 없이 em.clear()만 호출
flush() 없이 clear()를 하면 INSERT SQL이 DB로 안 나간 채 캐시만 비워진다. 데이터가 없는 상태에서 쿼리를 검증하게 되어 항상 빈 결과가 돌아온다.
[!DANGER] 커서 테스트에서 데이터 순서 가정
createdAt이 밀리초 단위로 같을 수 있다. 정렬에 id를 tie-breaking으로 넣지 않으면 테스트가 때때로 실패하는 Flaky Test가 된다. 쿼리의 orderBy에 createdAt DESC, id DESC를 함께 넣어야 한다.