테스트 전략 — 단위, 통합, 인수 테스트를 스프링에서 나누는 기준
테스트 코드를 작성하는데, 이건 단위 테스트인지 통합 테스트인지 매번 헷갈립니다. 어떤 기준으로 나눠야 할까요?
테스트의 종류와 범위를 명확히 구분하지 않으면 "느린 단위 테스트"나 "불안정한 통합 테스트"가 만들어집니다. Spring에서 제공하는 테스트 도구를 이해하고, 각 계층에 맞는 테스트 전략을 세우는 것이 중요합니다.
테스트 피라미드
테스트 피라미드는 테스트를 세 계층으로 나누는 모델입니다. 아래로 갈수록 빠르고, 위로 갈수록 현실에 가깝습니다.
/ E2E \ ← 적게, 느리지만 현실적
/ 통합 \
/ 단위 \ ← 많이, 빠르고 저렴
──────────────
| 계층 | 속도 | 범위 | 목적 |
|---|---|---|---|
| 단위 테스트 | 밀리초 | 클래스/메서드 하나 | 로직의 정확성 |
| 통합 테스트 | 초 | 여러 컴포넌트 협력 | 연동의 정확성 |
| E2E / 인수 테스트 | 분 | 전체 시스템 | 사용자 시나리오 |
단위 테스트 — Spring 없이 빠르게
단위 테스트는 Spring 컨텍스트를 띄우지 않습니다. 테스트 대상 클래스만 인스턴스화하고, 의존성은 Mock으로 대체합니다.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
given-when-then 패턴으로 Mock의 동작을 설정하고, 실행 결과와 호출 여부를 검증합니다.
@Test
void 주문_생성_성공() {
// given
OrderCreateRequest request = new OrderCreateRequest(1L, 2);
given(paymentService.validate(any())).willReturn(true);
given(orderRepository.save(any())).willAnswer(invocation -> {
Order order = invocation.getArgument(0);
ReflectionTestUtils.setField(order, "id", 1L);
return order;
});
// when
Order result = orderService.create(request);
// then
assertThat(result.getId()).isNotNull();
then(orderRepository).should().save(any(Order.class));
then(paymentService).should().validate(any());
}
실패 케이스도 검증해야 합니다. 결제 검증이 실패하면 예외가 발생하는지 확인합니다.
@Test
void 결제_검증_실패시_예외() {
// given
given(paymentService.validate(any())).willReturn(false);
// when & then
assertThatThrownBy(() -> orderService.create(new OrderCreateRequest(1L, 2)))
.isInstanceOf(PaymentException.class);
}
}
단위 테스트가 적합한 대상:
- 서비스 계층의 비즈니스 로직
- 유틸리티 클래스
- 도메인 객체의 메서드
- 검증 로직, 변환 로직
슬라이스 테스트 — 계층별 격리
Spring Boot는 각 계층에 맞는 슬라이스 테스트 어노테이션을 제공합니다.
| 어노테이션 | 대상 계층 | 로드되는 빈 |
|---|---|---|
@WebMvcTest | 웹 (MVC) | Controller, Filter, ControllerAdvice |
@WebFluxTest | 웹 (WebFlux) | Controller, WebFilter |
@DataJpaTest | 데이터 (JPA) | Repository, EntityManager |
@DataMongoTest | 데이터 (MongoDB) | MongoRepository |
@JsonTest | JSON 직렬화 | ObjectMapper, JsonComponent |
// 컨트롤러 슬라이스 테스트
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mockMvc;
@MockBean ProductService productService;
// 웹 계층만 테스트, 서비스는 Mock
}
// 리포지토리 슬라이스 테스트
@DataJpaTest
class ProductRepositoryTest {
@Autowired ProductRepository repository;
@Autowired TestEntityManager em;
// JPA 계층만 테스트, 서비스/컨트롤러 없음
}
슬라이스 테스트는 단위 테스트와 통합 테스트의 중간에 위치합니다. Spring 컨텍스트를 사용하지만 필요한 빈만 로드하여 빠릅니다.
통합 테스트 — @SpringBootTest
전체 ApplicationContext를 로드하여 여러 컴포넌트의 연동을 테스트합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OrderIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private OrderRepository orderRepository;
통합 테스트에서는 API 호출 후 실제 DB에 데이터가 저장되었는지까지 검증합니다.
@Test
void 주문_생성부터_조회까지_전체_흐름() {
// 주문 생성
OrderCreateRequest request = new OrderCreateRequest(1L, 2);
webTestClient.post()
.uri("/api/orders")
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.id").isNotEmpty();
// DB에 저장 확인
List<Order> orders = orderRepository.findAll();
assertThat(orders).hasSize(1);
}
}
** 통합 테스트가 적합한 대상:**
- 여러 계층을 관통하는 API 흐름
- 트랜잭션이 올바르게 동작하는지 확인
- 실제 DB, 캐시, 메시지 브로커 연동
- 보안 필터 체인이 올바르게 동작하는지 확인
인수 테스트 — 사용자 관점
인수 테스트는 비즈니스 요구사항을 사용자 시나리오 관점에서 검증합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OrderAcceptanceTest extends IntegrationTestBase {
@Autowired
private WebTestClient webTestClient;
@Test
void 사용자가_상품을_주문하고_주문_내역을_확인한다() {
// 1. 회원가입
String token = 회원가입_후_로그인("user@test.com", "password");
// 2. 상품 조회
Long productId = 상품_등록("스프링 책", 30000);
// 3. 주문
Long orderId = 주문_생성(token, productId, 2);
주문 생성 후 API로 주문 내역을 조회하여 전체 시나리오가 정상 동작하는지 검증합니다.
// 4. 주문 내역 확인
webTestClient.get()
.uri("/api/orders/{id}", orderId)
.header("Authorization", "Bearer " + token)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.productName").isEqualTo("스프링 책")
.jsonPath("$.quantity").isEqualTo(2)
.jsonPath("$.totalPrice").isEqualTo(60000);
}
// 헬퍼 메서드들
private String 회원가입_후_로그인(String email, String password) { ... }
private Long 상품_등록(String name, int price) { ... }
private Long 주문_생성(String token, Long productId, int qty) { ... }
}
테스트 프로파일과 설정 분리
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
hibernate:
ddl-auto: create-drop
data:
redis:
host: localhost
port: 6379
# 외부 API는 Mock 서버로 대체
external:
payment-api:
base-url: http://localhost:${wiremock.server.port}
@SpringBootTest
@ActiveProfiles("test")
class MyTest {
// application-test.yml 설정이 적용됨
}
테스트 픽스처 관리
테스트 데이터를 체계적으로 관리하는 패턴입니다.
빌더 패턴
public class OrderFixture {
public static Order createDefault() {
return Order.builder()
.productId(1L)
.quantity(1)
.price(10000)
.status(OrderStatus.CREATED)
.build();
}
public static Order createWithPrice(int price) {
return Order.builder()
.productId(1L)
.quantity(1)
.price(price)
.status(OrderStatus.CREATED)
.build();
}
}
@Sql로 테스트 데이터 준비
@Test
@Sql("/sql/test-orders.sql") // 테스트 전에 SQL 실행
@Sql(value = "/sql/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 테스트 후 정리
void 주문_목록_조회() {
// test-orders.sql에서 준비한 데이터로 테스트
}
실무 테스트 전략 가이드
계층별 테스트 배분
Controller → @WebMvcTest (슬라이스)
Service → Mockito (단위 테스트)
Repository → @DataJpaTest (슬라이스)
전체 흐름 → @SpringBootTest (통합 테스트)
어떤 테스트를 먼저 작성할까
- ** 서비스 계층 단위 테스트 **: 비즈니스 로직의 정확성 확인 (가장 중요)
- ** 리포지토리 슬라이스 테스트 **: 커스텀 쿼리의 정확성 확인
- ** 컨트롤러 슬라이스 테스트 **: 요청/응답 형식, 유효성 검증 확인
- ** 통합 테스트 **: 핵심 비즈니스 시나리오의 전체 흐름 확인
테스트 속도 최적화
@MockBean사용을 ** 최소화 **하세요 (컨텍스트 캐시 무효화 원인)- 같은
@MockBean조합을 사용하는 테스트를 ** 하나의 클래스에** 모으세요 - 통합 테스트 클래스는 ** 추상 클래스를 상속 **하여 컨텍스트를 공유하세요
- CI에서는 ** 단위 테스트를 먼저** 실행하고, 통합 테스트는 별도 단계에서 실행하세요
// 통합 테스트 추상 클래스 — 컨텍스트 공유
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class IntegrationTestBase {
@Autowired
protected WebTestClient webTestClient;
}
// 개별 테스트 클래스
class OrderTest extends IntegrationTestBase { ... }
class ProductTest extends IntegrationTestBase { ... }
// → 같은 ApplicationContext를 재사용하여 빠름
주의할 점
1. @MockBean을 남용하면 테스트가 점점 느려진다
@MockBean은 Spring ApplicationContext의 빈을 교체하므로 컨텍스트 캐시가 무효화됩니다. 테스트 클래스마다 다른 @MockBean 조합을 사용하면 매번 컨텍스트가 새로 생성되어 전체 테스트 실행 시간이 크게 늘어납니다. 같은 Mock 조합을 공유하는 테스트를 하나의 클래스에 모으거나, 순수 Mockito(@Mock)를 사용하세요.
2. 단위 테스트에서 Mock만 사용하면 실제 연동 문제를 놓칠 수 있다
서비스 계층을 Mock으로 테스트하면 비즈니스 로직의 정확성은 확인할 수 있지만, 실제 DB 쿼리 오류, 트랜잭션 롤백 동작, 직렬화 문제 등은 발견하지 못합니다. 테스트 피라미드의 비율을 지키되, 핵심 시나리오에 대한 통합 테스트를 반드시 포함해야 합니다.
3. 테스트 간 데이터 격리를 하지 않으면 실행 순서에 따라 결과가 달라진다
@SpringBootTest에서 @Transactional 없이 테스트를 작성하면 한 테스트에서 삽입한 데이터가 다른 테스트에 영향을 줍니다. 테스트 실행 순서가 바뀌면 갑자기 실패하는 "플래키 테스트(flaky test)"의 주요 원인이 됩니다. @Transactional로 자동 롤백하거나, @AfterEach에서 데이터를 정리하세요.
정리
- ** 단위 테스트 **는 Spring 없이 Mockito로, ** 슬라이스 테스트 **는 계층별 어노테이션으로, ** 통합 테스트 **는
@SpringBootTest로 작성합니다 - 테스트 피라미드를 따라 ** 단위 테스트를 가장 많이 **, 통합/E2E 테스트는 핵심 시나리오 위주로 작성합니다
@ActiveProfiles("test")로 ** 테스트 전용 설정 **을 분리하고, 픽스처로 테스트 데이터를 관리합니다@MockBean최소화 와 컨텍스트 공유 로 테스트 속도를 최적화합니다