- 개요 — 슬라이스 테스트와 통합 테스트 ← 현재
유닛 테스트는 Mockito로 의존성을 가짜로 바꿔치기해서 로직만 검증한다. 그런데 쿼리가 진짜 DB에서 잘 도는지, 컨트롤러가 HTTP 요청을 제대로 파싱하는지는 유닛 테스트로 알 수 없다. 이때 Spring 컨텍스트를 활용하는 테스트가 필요하다.
테스트 계층 구분
Spring 테스트는 어디까지 띄우느냐로 나뉜다.
- 유닛 테스트 : Spring을 안 띄운다.
@ExtendWith(MockitoExtension.class)만 쓴다. - 슬라이스 테스트 : Spring 컨텍스트에서 특정 레이어만 잘라서 띄운다. 빠르다.
- 통합 테스트 : 전체 컨텍스트를 띄운다. 레이어 간 연동을 검증한다.
슬라이스 테스트라는 이름은 케이크를 자르듯 애플리케이션의 한 층만 잘라낸다는 뜻이다.
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
}
}
핵심 포인트
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());
}
}
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로 올린다. 넓은 테스트는 느리고, 실패 원인을 특정하기도 어렵다.
가장 빠름"] -->|"쿼리 검증 필요"| 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에서 안 된다"는 상황이 생긴다.