Spring Test Slice 계층별 테스트

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로 통합 테스트하는 것이 효율적인 테스트 피라미드의 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux