유닛 테스트는 Mockito로 의존성을 가짜로 바꿔치기해서 로직만 검증한다. 그런데 쿼리가 진짜 DB에서 잘 도는지, 컨트롤러가 HTTP 요청을 제대로 파싱하는지는 유닛 테스트로 알 수 없다. 이때 Spring 컨텍스트를 활용하는 테스트가 필요하다.

테스트 계층 구분

Spring 테스트는 어디까지 띄우느냐로 나뉜다.

  • 유닛 테스트 : Spring을 안 띄운다. @ExtendWith(MockitoExtension.class)만 쓴다.
  • 슬라이스 테스트 : Spring 컨텍스트에서 특정 레이어만 잘라서 띄운다. 빠르다.
  • 통합 테스트 : 전체 컨텍스트를 띄운다. 레이어 간 연동을 검증한다.

슬라이스 테스트라는 이름은 케이크를 자르듯 애플리케이션의 한 층만 잘라낸다는 뜻이다.

graph TB subgraph "유닛 테스트" UT_Service["Service 로직"] UT_Mock["Mock 객체"] UT_Service --> UT_Mock end subgraph "슬라이스 테스트" direction TB ST_Controller["@WebMvcTest
Controller만"] ST_Repo["@DataJpaTest
Repository만"] ST_MockMvc["MockMvc"] ST_H2["내장 H2 DB"] ST_Controller --> ST_MockMvc ST_Repo --> ST_H2 end subgraph "통합 테스트" IT_Controller["Controller"] IT_Service["Service"] IT_Repo["Repository"] IT_DB["DB"] IT_Controller --> IT_Service --> IT_Repo --> IT_DB end style UT_Service fill:#e1f5fe style ST_Controller fill:#fff3e0 style ST_Repo fill:#fff3e0 style IT_Controller fill:#fce4ec style IT_Service fill:#fce4ec style IT_Repo fill:#fce4ec

@DataJpaTest — Repository 슬라이스

JPA 관련 빈만 로드하고 내장 H2 DB를 띄운다. 쿼리가 의도대로 동작하는지 검증하는 용도다. QueryDSL 커스텀 레포지토리 테스트에 딱 맞다.

@DataJpaTest
class MessageRepositoryCustomImplTest {

    @Autowired
    private MessageRepository messageRepository;

    @Autowired
    private TestEntityManager em;

    @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 + 1
    }
}
sequenceDiagram participant Test as 테스트 코드 participant EM as TestEntityManager participant DB as 내장 H2 DB participant Repo as MessageRepository Test->>EM: persist(entity) EM->>DB: INSERT Test->>EM: flush() EM->>DB: SQL 실행 강제 Test->>EM: clear() Note over EM: 1차 캐시 비움 Test->>Repo: getByCursor(request) Repo->>DB: SELECT 쿼리 실행 DB-->>Repo: 결과 반환 Repo-->>Test: List

핵심 포인트

  • TestEntityManager로 테스트 데이터를 직접 넣는다. em.persist()em.flush()em.clear() 순서다.
  • em.clear()를 해야 영속성 컨텍스트가 비워지고, 실제 DB 쿼리가 나간다. 안 하면 1차 캐시에서 꺼내서 쿼리 검증이 안 된다.
  • 테스트마다 트랜잭션이 자동 롤백된다. 다른 테스트에 영향을 주지 않는다.

QueryDSL 빈 등록

@DataJpaTest는 JPA 빈만 로드하기 때문에 JPAQueryFactory가 자동 등록되지 않는다. 테스트에서 직접 설정을 추가해야 한다.

@DataJpaTest
@Import(QueryDslConfig.class)  // JPAQueryFactory 빈이 정의된 설정 클래스
class MessageRepositoryCustomImplTest {
    // ...
}

프로젝트에 이미 QueryDslConfig가 있으니 @Import로 가져오면 된다.

@Import 없이 실행하면

NoSuchBeanDefinitionException: JPAQueryFactory 에러가 뜬다. @DataJpaTest가 로드하는 범위에 @Configuration 클래스는 포함되지 않기 때문이다.

@WebMvcTest — Controller 슬라이스

컨트롤러 레이어만 띄운다. Service는 @MockBean으로 가짜를 넣는다. HTTP 요청 파싱, validation, 응답 형식, 상태 코드를 검증하는 용도다.

@WebMvcTest(MessageController.class)
class MessageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MessageService messageService;

    @Test
    void DM_조회에_성공한다() throws Exception {
        // given
        MessageGetResponse response = new MessageGetResponse(
                List.of(), null, null, false, 0,
                SortBy.createdAt, SortDirection.DESCENDING);
        given(messageService.getByCursor(any()))
                .willReturn(response);

        // when & then
        mockMvc.perform(get("/api/direct-messages")
                        .param("userId", UUID.randomUUID().toString())
                        .param("limit", "20"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.hasNext").value(false));
    }

    @Test
    void limit이_0이면_400을_반환한다() throws Exception {
        mockMvc.perform(get("/api/direct-messages")
                        .param("userId", UUID.randomUUID().toString())
                        .param("limit", "0"))
                .andExpect(status().isBadRequest());
    }
}
graph LR A["MockMvc
HTTP 요청 시뮬레이션"] --> B["MessageController
실제 빈"] B --> C["MessageService
@MockBean (가짜)"] style A fill:#e8f5e9 style B fill:#fff3e0 style C fill:#ffebee,stroke-dasharray: 5 5

핵심 포인트

  • MockMvc가 실제 HTTP 서버 없이 요청을 시뮬레이션한다.
  • @MockBean은 Spring 컨텍스트에 가짜 빈을 등록한다. Mockito의 @Mock과 비슷하지만, Spring 빈으로 주입된다는 점이 다르다.
  • validation 테스트가 여기서 진짜 힘을 발휘한다. @NotNull, @Min 같은 어노테이션이 실제로 동작하는지 확인할 수 있다.

Security 설정이 있으면 @WebMvcTest에서 403이 뜰 수 있다. 이 프로젝트에 SecurityConfig가 있으니 테스트에 .with(csrf())를 추가하거나 @AutoConfigureMockMvc(addFilters = false)로 필터를 끌 수 있다.

@SpringBootTest — 통합 테스트

전체 Spring 컨텍스트를 띄운다. Controller → Service → Repository → DB까지 실제 흐름 그대로 동작한다.

@SpringBootTest
@Transactional
class MessageIntegrationTest {

    @Autowired
    private MessageService messageService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ProfileRepository profileRepository;

    @Autowired
    private MessageRepository messageRepository;

    @Test
    void DM_조회_전체_흐름을_검증한다() {
        // given
        User sender = userRepository.save(createUser("sender"));
        profileRepository.save(createProfile(sender));
        User receiver = userRepository.save(createUser("receiver"));
        profileRepository.save(createProfile(receiver));

        for (int i = 0; i < 3; i++) {
            messageRepository.save(
                    Message.create(sender, receiver, "msg" + i));
        }

        MessageGetRequest request = new MessageGetRequest(
                sender.getId(), null, null, 10);

        // when
        MessageGetResponse response = messageService.getByCursor(request);

        // then
        assertThat(response.data()).hasSize(3);
        assertThat(response.data().get(0).sender().userId())
                .isEqualTo(sender.getId());
    }
}

핵심 포인트

  • @Transactional을 붙이면 테스트 끝나고 자동 롤백된다. 안 붙이면 데이터가 남아서 다른 테스트에 영향을 준다.
  • 유닛이나 슬라이스에서 잡지 못하는 문제를 잡는다. N+1 쿼리, 트랜잭션 전파, 빈 주입 실패 같은 것들이다.
  • 전체 컨텍스트를 올리니까 느리다. 꼭 필요한 시나리오만 작성한다.
@SpringBootTest + @Transactional 주의점

@Transactional이 테스트를 하나의 트랜잭션으로 감싸기 때문에, 서비스에서 @Transactional(propagation = REQUIRES_NEW)를 쓰는 경우 실제 동작과 다르게 흘러갈 수 있다. Lazy Loading도 테스트 트랜잭션 안에서는 항상 성공하므로 LazyInitializationException을 놓칠 수 있다.

판단 기준

뭘 검증하고 싶은가쓸 어노테이션
QueryDSL 쿼리, 커서 조건, where절@DataJpaTest
요청 파라미터 바인딩, validation, 응답 JSON@WebMvcTest
레이어 간 연동, N+1, 트랜잭션@SpringBootTest
순수 비즈니스 로직 (if/else 분기)@ExtendWith(MockitoExtension.class)

테스트를 작성할 때 가장 좁은 범위부터 시작한다. 유닛으로 충분하면 유닛으로, 쿼리 검증이 필요하면 @DataJpaTest로, 전체 흐름이 필요할 때만 @SpringBootTest로 올린다. 넓은 테스트는 느리고, 실패 원인을 특정하기도 어렵다.

graph LR A["유닛
가장 빠름"] -->|"쿼리 검증 필요"| B["@DataJpaTest
@WebMvcTest"] B -->|"레이어 간 연동 필요"| C["@SpringBootTest
가장 느림"] style A fill:#c8e6c9 style B fill:#fff9c4 style C fill:#ffcdd2

자주 하는 실수

@DataJpaTest에서 em.clear() 빼먹기

데이터를 persist한 뒤 clear를 안 하면 영속성 컨텍스트의 1차 캐시에서 바로 꺼내온다. 실제 DB 쿼리가 실행되지 않아서 잘못된 쿼리도 통과해버린다. persist → flush → clear는 세트로 기억한다.

[!DANGER] @MockBean@Mock 혼동

@Mock은 Mockito가 관리하는 가짜 객체다. @MockBean은 Spring 컨텍스트에 가짜 빈을 등록한다. @WebMvcTest에서 서비스를 @Mock으로 선언하면 컨트롤러에 주입되지 않아서 NPE가 난다. Spring이 빈을 주입하는 테스트에서는 반드시 @MockBean을 써야 한다.

[!DANGER] @SpringBootTest@Transactional 안 붙이기

테스트가 DB에 데이터를 쓰고 롤백하지 않으면, 테스트 실행 순서에 따라 다른 테스트가 깨진다. 특히 CI에서 랜덤 순서로 돌 때 "로컬에서는 되는데 CI에서 안 된다"는 상황이 생긴다.