테스트 — 빠른 단위에서 느린 통합까지
좋은 테스트는 빠르고 많을수록 좋고, 느린 통합 테스트는 꼭 필요한 만큼만 둔다. Spring은 이 균형을 필요한 조각만 띄우는 방식으로 돕는데, 그 이야기를 따라가다 보면 02편에서 "생성자 주입을 쓰라"고 했던 이유가 여기서 보상받는 걸 보게 된다. 그리고 왜 슬라이스가 빠른지, 왜 어떤 테스트 스위트는 점점 느려지는지를 컨텍스트 캐싱 수준까지 들여다본다.
왜 피라미드 모양인가
테스트를 쌓는 이상적인 모양은 피라미드다. 아래쪽에 빠른 단위 테스트를 잔뜩 깔고, 위로 갈수록 느리고 비싼 테스트를 적게 둔다.
왜 이 모양이어야 할까? 위쪽 테스트는 전체를 띄우니 느리고, 여러 부분이 얽혀 있어 작은 변화에도 잘 깨진다. 그래서 모든 경우를 느린 통합 테스트로 검증하려 들면, 테스트 스위트가 느리고 부서지기 쉬운 역피라미드가 되어 오히려 손이 안 가게 된다. 대부분의 검증은 아래쪽 빠른 테스트에서 끝내고, 위쪽은 핵심 흐름에만 남기는 게 핵심이다.
컨테이너 없이 도는 단위 테스트
가장 빠른 테스트는 Spring 컨테이너를 아예 띄우지 않는다.
@Test
void calculatesTotal() {
var service = new OrderService(new FakeRepository()); // 그냥 new
assertThat(service.total(...)).isEqualTo(...);
}
바로 이 지점에서 02편의 권장이 빛을 본다. 의존을 생성자로 받게 해 뒀기 때문에, 테스트에서 가짜(mock·fake)를 직접 만들어 넣고 new로 객체를 찍어낼 수 있다 — Spring 컨테이너를 띄울 필요가 없으니 밀리초 단위로 끝난다. 만약 필드 주입을 썼다면 의존을 꽂으려고 리플렉션이나 컨테이너가 필요했을 것이다. "생성자 주입을 쓰라"던 그 조언이, 여기서 테스트 속도라는 보상으로 돌아온 셈이다.
필요한 조각만 띄우는 슬라이스 테스트 — 내부
하지만 모든 걸 new로 검증할 순 없다. 컨트롤러의 HTTP 매핑이나 JPA 쿼리는 Spring의 일부가 동작해야 확인된다. 그렇다고 전체 컨텍스트를 띄우면 느리다. 그래서 슬라이스 테스트는 전체 자동 설정을 끄고 그 계층에 필요한 빈만 로드한다.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service; // 서비스는 가짜로
@Test void getsOrder() throws Exception {
mvc.perform(get("/orders/1")).andExpect(status().isOk());
}
}
@WebMvcTest는 전체 자동 설정을 끄고 MVC 관련 빈(@Controller·@ControllerAdvice·Filter·WebMvcConfigurer 등)만 올린다 — 지정한 컨트롤러만 만들고 그 의존(서비스)은 만들지 않으므로 @MockitoBean(구 @MockBean)으로 가짜를 꽂아야 한다. 그리고 MockMvc를 자동 구성해 서블릿 환경을 흉내(서버·포트 없음) 내어 HTTP 요청·응답·검증을 빠르게 돌린다. 비슷하게 @DataJpaTest는 @Entity를 스캔하고 리포지토리와 임베디드 DB(@AutoConfigureTestDatabase)만 구성하며(일반 @Component는 안 올린다), 각 테스트를 트랜잭션으로 감싸 끝나면 자동 롤백해 DB를 깨끗이 둔다. (출처: Spring Boot — Testing.)
왜 어떤 스위트는 점점 느려지나 — 컨텍스트 캐싱
여기서 실무에서 자주 놓치는 사실이 하나 있다. Spring은 테스트마다 컨텍스트를 새로 만들지 않고, 설정이 같은(동일한 슬라이스·프로퍼티·MockBean 조합) 테스트끼리 ApplicationContext를 캐시해 재사용한다 — 그래서 첫 테스트만 느리고 나머지는 빠르다. 문제는 설정이 조금씩 다른 컨텍스트가 많아질수록(@MockitoBean 조합이 제각각, @TestPropertySource가 테스트마다 다름 등) 캐시가 안 먹어 컨텍스트를 매번 새로 띄운다 — 이게 테스트 스위트가 점점 느려지는 흔한 원인이다. 그래서 컨텍스트 설정을 몇 가지로 표준화해 캐시 적중률을 높이는 게 중요하다.
진짜에 가깝게 — 통합 테스트와 Testcontainers
그 "아껴 두는" 게 **@SpringBootTest**다. 전체 애플리케이션 컨텍스트를 올려 실제와 가깝게 검증하니 정확하지만, 빈을 다 로드하느라 느리다. 그래서 핵심 시나리오에만 쓴다. 그런데 통합 테스트에는 미묘한 함정이 있다 — 흔히 빠르라고 임베디드 H2로 테스트하는데, H2와 운영 PostgreSQL은 SQL 방언·기능이 달라 H2에서 통과한 쿼리가 운영에서 깨지기도 한다(테스트는 초록불인데 배포하면 터진다). 이걸 막는 게 Testcontainers다 — 진짜 PostgreSQL·Kafka·Redis를 Docker로 띄워 "테스트 환경 = 운영 환경"에 가깝게 만든다. Spring Boot 3.1+의 **@ServiceConnection**을 쓰면 JDBC URL·계정을 손으로 엮을 필요 없이 컨테이너를 자동으로 연결해 주고(@DynamicPropertySource를 대체), *컨테이너 재사용(reuse)*으로 매번 새로 띄우는 비용도 줄인다. (출처: Improved Testcontainers Support in Spring Boot 3.1.)
실무 — 결정적이고 격리된 테스트
마지막으로 테스트를 믿을 수 있게 만드는 습관이다. @Transactional을 붙인 테스트는 끝나면 자동 롤백돼 DB를 더럽히지 않는다(다만 진짜 커밋 시 동작하는 트리거·AFTER_COMMIT 리스너는 롤백 테스트에서 안 도니 주의). 테스트는 결정적이어야 한다 — 시간에 의존하면 Clock을 주입해 고정하고(jcode의 그 패턴), 난수는 고정 시드를 써 언제 돌려도 같은 결과가 나오게 한다. 외부 API·메시지 브로커처럼 느리고 불안정한 의존은 @MockitoBean으로 격리한다. 이런 통제가 없으면 어쩌다 깨지는 flaky 테스트가 생겨 — 팀이 빨간불을 무시하기 시작하는 가장 위험한 상태로 간다. 거꾸로 단위 테스트가 느리거나 자주 깨진다면 그건 검증이 통합 쪽으로 잘못 쏠렸다는 신호다.
정리하면, 테스트는 빠른 단위를 많이, 느린 통합을 적게 쌓는 피라미드가 이상적이다. 생성자 주입 덕에 단위 테스트는 컨테이너 없이 밀리초에 돌고, 컨트롤러·쿼리는 *슬라이스(@WebMvcTest+MockMvc·@DataJpaTest+롤백)*로 그 계층만 빠르게 검증하되 컨텍스트 설정을 표준화해 캐시를 살린다. 전체 흐름은 @SpringBootTest로(필요하면 Testcontainers+@ServiceConnection으로 운영에 가깝게) 아껴 확인하고 — 무엇을 검증하든 결정적이고(Clock·시드) 격리된(@MockitoBean) 테스트를 지향한다.