QueryDSL로 작성한 커서 기반 페이지네이션 쿼리가 있다. 유닛 테스트로는 이 쿼리가 DB에서 진짜 의도대로 동작하는지 알 수 없다. @DataJpaTestJPA 관련 빈만 띄우고 내장 DB를 연결해서 쿼리를 직접 검증할 수 있게 해준다.

@DataJpaTest가 해주는 일

이 어노테이션 하나로 Spring이 알아서 처리하는 것들이 있다.

  • JPA 관련 빈만 로드한다. Service, Controller는 올리지 않는다.
  • 내장 H2 DB를 자동으로 띄운다.
  • 각 테스트 메서드를 @Transactional로 감싸서 끝나면 자동 롤백한다.
  • TestEntityManager를 제공한다.

전체 컨텍스트를 올리는 @SpringBootTest보다 훨씬 빠르다. Repository 레이어만 검증할 때는 이걸 쓰면 된다.

flowchart TD A(["@DataJpaTest 시작"]) --> B["JPA 빈만 로드"] B --> C["내장 H2 DB 연결"] C --> D["@Transactional 자동 적용"] D --> E["테스트 실행"] E --> F["자동 롤백"] style A fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style B fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style C fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style D fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style F fill:#FFF3E0,stroke:#FF9800,stroke-width:2px

로드 범위가 좁기 때문에 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();

이 세 단계가 왜 필요한지 흐름으로 보면 명확하다.

sequenceDiagram autonumber participant Test as 테스트 코드 participant PC as 영속성 컨텍스트 participant DB as 내장 H2 rect rgb(232, 248, 232) Note over Test, DB: 데이터 준비 Test->>PC: persist(entity) Note right of PC: 1차 캐시에 저장 Test->>PC: flush() PC->>DB: INSERT SQL 실행 end rect rgb(255, 243, 224) Note over Test, PC: 캐시 초기화 Test->>PC: clear() Note right of PC: 1차 캐시 비움 end rect rgb(240, 248, 255) Note over Test, DB: 쿼리 검증 Test->>DB: getByCursor(request) DB-->>Test: 결과 반환 end

초록 영역에서 데이터를 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가 된다. 쿼리의 orderBycreatedAt DESC, id DESC를 함께 넣어야 한다.