Spring Test Slice란?
Spring Test Slice는 애플리케이션의 특정 계층만 로드하여 테스트하는 전략입니다. 전체 컨텍스트를 올리는 @SpringBootTest는 느리고 무겁지만, @WebMvcTest, @DataJpaTest, @JsonTest 같은 슬라이스 어노테이션은 필요한 빈만 로드하여 빠르고 집중적인 테스트를 가능하게 합니다.
이 글에서는 주요 Test Slice별 사용법, MockBean vs TestConfiguration, 커스텀 슬라이스 생성, 슬라이스 간 조합, 실전 테스트 아키텍처까지 다룹니다.
@WebMvcTest: 컨트롤러 계층 테스트
컨트롤러, Filter, ControllerAdvice, Converter만 로드합니다. Service, Repository는 로드하지 않습니다.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@MockBean
private OrderMapper orderMapper;
@Test
void shouldCreateOrder() throws Exception {
// given
CreateOrderRequest request = new CreateOrderRequest("prod-1", 3);
OrderResponse response = new OrderResponse("order-1", "PENDING", 89.97);
when(orderService.create(any())).thenReturn(response);
// when & then
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": "prod-1", "quantity": 3}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("order-1"))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.totalAmount").value(89.97));
verify(orderService).create(argThat(cmd ->
cmd.productId().equals("prod-1") && cmd.quantity() == 3
));
}
@Test
void shouldReturn400ForInvalidRequest() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": "", "quantity": -1}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("productId", "quantity")));
}
@Test
void shouldReturn401WithoutAuthentication() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAccessAdminEndpoint() throws Exception {
when(orderService.findAll(any())).thenReturn(List.of());
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void shouldDenyNonAdminAccess() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden());
}
}
커스텀 Security 설정 포함
// Security 설정도 테스트하려면 Import 추가
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, CurrentUserArgumentResolver.class})
class OrderControllerSecurityTest {
@Autowired private MockMvc mockMvc;
@MockBean private OrderService orderService;
@MockBean private UserRepository userRepository;
@Test
void shouldInjectCurrentUserFromJwt() throws Exception {
when(userRepository.findById(42L))
.thenReturn(Optional.of(testUser()));
when(orderService.findByUserId(42L))
.thenReturn(List.of());
mockMvc.perform(get("/api/orders")
.with(jwt().jwt(builder -> builder
.subject("42")
.claim("roles", List.of("USER"))
)))
.andExpect(status().isOk());
verify(orderService).findByUserId(42L);
}
}
@DataJpaTest: 리포지토리 계층 테스트
JPA 관련 빈(EntityManager, Repository, Flyway/Liquibase)만 로드합니다. 기본적으로 인메모리 H2와 트랜잭션 롤백이 적용됩니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // 실제 DB 사용
@Import(QueryDslConfig.class) // QueryDSL 설정 포함
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager em;
@Test
void shouldFindOrdersByCustomerAndStatus() {
// given
User user = em.persist(new User("test@test.com", "Test User"));
em.persist(new Order(user, OrderStatus.PENDING, BigDecimal.TEN));
em.persist(new Order(user, OrderStatus.CONFIRMED, BigDecimal.ONE));
em.persist(new Order(user, OrderStatus.PENDING, BigDecimal.valueOf(20)));
em.flush();
// when
List<Order> results = orderRepository.findByUserIdAndStatus(
user.getId(), OrderStatus.PENDING);
// then
assertThat(results).hasSize(2);
assertThat(results).allMatch(o ->
o.getStatus() == OrderStatus.PENDING);
}
@Test
void shouldCalculateRevenueByPeriod() {
// given
User user = em.persist(testUser());
LocalDateTime now = LocalDateTime.now();
em.persist(order(user, "100.00", now.minusDays(5)));
em.persist(order(user, "200.00", now.minusDays(3)));
em.persist(order(user, "50.00", now.minusDays(40))); // 범위 밖
em.flush();
// when
BigDecimal revenue = orderRepository.calculateRevenue(
now.minusDays(30), now);
// then
assertThat(revenue).isEqualByComparingTo("300.00");
}
@Test
void shouldApplyPessimisticLock() {
Order order = em.persist(new Order(testUser(), OrderStatus.PENDING, BigDecimal.TEN));
em.flush();
em.clear();
// @Lock(LockModeType.PESSIMISTIC_WRITE) 검증
Order locked = orderRepository.findByIdForUpdate(order.getId());
assertThat(locked).isNotNull();
assertThat(locked.getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
Testcontainers 연동
// 실제 PostgreSQL에서 테스트
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
class OrderRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldUseNativePostgresFeatures() {
// PostgreSQL 전용 쿼리 테스트 (jsonb, array 등)
// H2에서는 불가능한 테스트를 실제 DB에서 실행
}
}
@JsonTest: 직렬화/역직렬화 테스트
@JsonTest
class OrderResponseJsonTest {
@Autowired
private JacksonTester<OrderResponse> json;
@Test
void shouldSerialize() throws Exception {
OrderResponse response = new OrderResponse(
"order-1", "PENDING", BigDecimal.valueOf(89.97),
LocalDateTime.of(2026, 3, 13, 0, 0));
assertThat(json.write(response))
.hasJsonPathValue("$.id", "order-1")
.hasJsonPathValue("$.status", "PENDING")
.hasJsonPathValue("$.totalAmount", 89.97)
// snake_case 변환 확인
.hasJsonPathValue("$.total_amount")
// 날짜 포맷 확인
.hasJsonPathStringValue("$.createdAt",
"2026-03-13T00:00:00");
}
@Test
void shouldDeserialize() throws Exception {
String content = """
{
"id": "order-1",
"status": "PENDING",
"total_amount": 89.97
}
""";
OrderResponse result = json.parseObject(content);
assertThat(result.totalAmount())
.isEqualByComparingTo(BigDecimal.valueOf(89.97));
}
@Test
void shouldIgnoreUnknownProperties() throws Exception {
String content = """
{"id": "order-1", "status": "PENDING",
"total_amount": 10, "unknown_field": "ignored"}
""";
assertThatCode(() -> json.parseObject(content))
.doesNotThrowAnyException();
}
}
기타 슬라이스 어노테이션
| 어노테이션 | 로드 범위 | 용도 |
|---|---|---|
@WebMvcTest |
Controller, Filter, Advice | REST API 요청/응답 검증 |
@DataJpaTest |
JPA Repository, EntityManager | 쿼리, 매핑 검증 |
@JsonTest |
Jackson ObjectMapper | JSON 직렬화/역직렬화 |
@RestClientTest |
RestTemplate, RestClient | 외부 API 클라이언트 검증 |
@DataRedisTest |
Redis Repository, Template | Redis 연산 검증 |
@DataMongoTest |
Mongo Repository, Template | MongoDB 쿼리 검증 |
@JdbcTest |
JdbcTemplate, DataSource | 순수 JDBC 쿼리 검증 |
@JooqTest |
DSLContext | jOOQ 쿼리 검증 |
커스텀 Test Slice 생성
// 도메인 서비스만 로드하는 커스텀 슬라이스
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DomainServiceExcludeFilter.class)
@ImportAutoConfiguration({
CacheAutoConfiguration.class,
ValidationAutoConfiguration.class,
})
public @interface DomainServiceTest {
@AliasFor(annotation = SpringBootTest.class, attribute = "classes")
Class<?>[] value() default {};
}
// 사용
@DomainServiceTest({OrderService.class, InventoryService.class})
class OrderServiceTest {
@Autowired private OrderService orderService;
@MockBean private OrderRepository orderRepository;
@MockBean private InventoryService inventoryService;
@Test
void shouldCalculateOrderTotal() {
// Service 계층 로직만 테스트
// Controller, JPA 없이 빠르게 실행
}
}
테스트 아키텍처 전략
Spring @Async 비동기 처리에서 다뤘듯이, 각 계층은 독립적으로 테스트 가능해야 합니다. Spring Testcontainers 통합 테스트와 Test Slice를 결합한 테스트 피라미드를 구성합니다.
// 테스트 피라미드 구성
//
// ┌─────────────────┐
// │ E2E Test (소수) │ @SpringBootTest + Testcontainers
// ├─────────────────┤
// │ Integration (중) │ @DataJpaTest, @WebMvcTest
// ├─────────────────┤
// │ Unit Test (다수) │ 순수 단위 테스트 (Mockito)
// └─────────────────┘
// 공통 테스트 설정 추상 클래스
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
abstract class BaseRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("schema.sql");
@Autowired
protected TestEntityManager em;
protected User createTestUser(String email) {
return em.persist(new User(email, "Test"));
}
}
// 상속하여 사용
class OrderRepositoryTest extends BaseRepositoryTest {
@Autowired private OrderRepository orderRepository;
@Test
void shouldFindByUserId() {
User user = createTestUser("test@test.com");
// ...
}
}
class ProductRepositoryTest extends BaseRepositoryTest {
@Autowired private ProductRepository productRepository;
// ...
}
@MockBean vs @TestConfiguration
// @MockBean: 기존 빈을 Mock으로 대체
// 장점: 간편
// 단점: 컨텍스트 캐시 무효화 → 테스트 느려짐
@WebMvcTest
class SlowTest {
@MockBean private ServiceA serviceA; // 캐시 키 변경!
@MockBean private ServiceB serviceB;
}
// @TestConfiguration: 테스트 전용 빈 정의
// 장점: 컨텍스트 캐시 유지, 세밀한 제어
// 단점: 코드가 더 많음
@WebMvcTest(OrderController.class)
@Import(OrderControllerTest.TestConfig.class)
class OrderControllerTest {
@TestConfiguration
static class TestConfig {
@Bean
public OrderService orderService() {
return mock(OrderService.class);
}
}
}
// 최적 전략: 공통 MockBean은 상위 클래스에 모으기
@WebMvcTest
abstract class BaseControllerTest {
@MockBean protected OrderService orderService;
@MockBean protected UserService userService;
// 같은 MockBean 조합 → 컨텍스트 캐시 공유
}
마무리
Spring Test Slice는 계층별 격리 테스트로 빠른 피드백과 정확한 검증을 동시에 달성합니다. @WebMvcTest로 API 계약, @DataJpaTest로 쿼리 정확성, @JsonTest로 직렬화를 각각 검증하고, 핵심 플로우만 @SpringBootTest로 통합 테스트하는 것이 효율적인 테스트 피라미드의 핵심입니다.