Spring Boot

Spring Boot 테스트 전략 - 단위 테스트부터 통합 테스트까지

백엔드 개발자 김승원 2026. 3. 31. 15:51

들어가며

테스트는 소프트웨어 품질을 보장하는 가장 확실한 방법입니다. 하지만 무작정 테스트를 작성하면 오히려 유지보수 비용만 증가합니다. 어떤 계층에서 어떤 종류의 테스트를 작성해야 하는지, 적절한 도구를 어떻게 활용하는지 아는 것이 중요합니다. 이 글에서는 JUnit 5, Mockito, Spring Boot Test의 다양한 슬라이스 테스트, 그리고 Testcontainers를 활용한 실전 테스트 전략을 다룹니다.

1. 테스트 피라미드

효과적인 테스트 전략은 테스트 피라미드를 따릅니다.

        /  E2E  \           ← 적게, 느리지만 확실한 검증
       /----------\  
      / Integration \       ← 중간, 컴포넌트 간 상호작용 검증
     /----------------\
    /    Unit Tests    \    ← 많이, 빠르고 격리된 검증
   /--------------------\
  • 단위 테스트 (70%): 개별 클래스/메서드의 로직을 검증합니다. 외부 의존성은 Mock으로 대체합니다.
  • 통합 테스트 (20%): 여러 컴포넌트가 함께 동작하는 것을 검증합니다. DB, 외부 API 등과의 연동을 테스트합니다.
  • E2E 테스트 (10%): 전체 시스템이 사용자 시나리오대로 동작하는지 검증합니다.

2. JUnit 5 핵심 기능

JUnit 5는 JUnit Platform, JUnit Jupiter, JUnit Vintage로 구성됩니다. Spring Boot 3.x에서는 기본으로 JUnit 5가 포함됩니다.

기본 어노테이션

class UserServiceTest {

    @BeforeAll
    static void beforeAll() {
        // 모든 테스트 실행 전 한 번 실행
    }

    @BeforeEach
    void setUp() {
        // 각 테스트 실행 전 실행
    }

    @Test
    @DisplayName("사용자 생성 시 이메일이 null이면 예외가 발생한다")
    void createUser_WhenEmailIsNull_ThrowsException() {
        // given - when - then
    }

    @ParameterizedTest
    @ValueSource(strings = {"invalid", "no-at-sign", "@no-local"})
    @DisplayName("잘못된 이메일 형식이면 예외가 발생한다")
    void validateEmail_WithInvalidFormats_ThrowsException(String email) {
        assertThatThrownBy(() -> new Email(email))
                .isInstanceOf(InvalidEmailException.class);
    }

    @ParameterizedTest
    @CsvSource({
        "1000, 10, 100",
        "500, 20, 25",
        "0, 5, 0"
    })
    @DisplayName("총 금액을 수량으로 나누면 단가가 된다")
    void calculateUnitPrice(int totalPrice, int quantity, int expected) {
        assertThat(Calculator.unitPrice(totalPrice, quantity))
                .isEqualTo(expected);
    }

    @Nested
    @DisplayName("회원 등급이")
    class MemberGradeTest {

        @Nested
        @DisplayName("GOLD일 때")
        class WhenGold {
            @Test
            @DisplayName("할인율은 10%이다")
            void discountRate_Is10Percent() {
                assertThat(MemberGrade.GOLD.getDiscountRate())
                        .isEqualTo(0.1);
            }
        }
    }
}

@Nested를 활용하면 테스트를 계층적으로 구성할 수 있어 가독성이 크게 향상됩니다. @ParameterizedTest는 여러 입력값에 대해 동일한 로직을 반복 검증할 때 유용합니다.

3. Mockito를 활용한 단위 테스트

서비스 계층의 단위 테스트에서는 Repository 등 외부 의존성을 Mock으로 대체합니다.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @InjectMocks
    private OrderService orderService;

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private ProductRepository productRepository;

    @Mock
    private EventPublisher eventPublisher;

    @Test
    @DisplayName("주문 생성 시 재고가 차감되고 이벤트가 발행된다")
    void createOrder_DeductsStockAndPublishesEvent() {
        // given
        Product product = Product.builder()
                .id(1L)
                .name("스프링 부트 책")
                .price(30000)
                .stock(10)
                .build();

        CreateOrderRequest request = new CreateOrderRequest(1L, 2);

        given(productRepository.findByIdWithLock(1L))
                .willReturn(Optional.of(product));
        given(orderRepository.save(any(Order.class)))
                .willAnswer(invocation -> {
                    Order order = invocation.getArgument(0);
                    ReflectionTestUtils.setField(order, "id", 100L);
                    return order;
                });

        // when
        OrderResponse response = orderService.createOrder(request);

        // then
        assertThat(response.getOrderId()).isEqualTo(100L);
        assertThat(response.getTotalPrice()).isEqualTo(60000);
        assertThat(product.getStock()).isEqualTo(8);

        then(eventPublisher).should(times(1))
                .publish(any(OrderCreatedEvent.class));
    }

    @Test
    @DisplayName("재고가 부족하면 OutOfStockException이 발생한다")
    void createOrder_WhenInsufficientStock_ThrowsException() {
        // given
        Product product = Product.builder()
                .id(1L).stock(1).build();

        given(productRepository.findByIdWithLock(1L))
                .willReturn(Optional.of(product));

        CreateOrderRequest request = new CreateOrderRequest(1L, 5);

        // when & then
        assertThatThrownBy(() -> orderService.createOrder(request))
                .isInstanceOf(OutOfStockException.class)
                .hasMessageContaining("재고가 부족합니다");

        then(orderRepository).should(never()).save(any());
    }
}

핵심 포인트는 다음과 같습니다.

  • @ExtendWith(MockitoExtension.class): Spring 컨텍스트를 로드하지 않으므로 매우 빠릅니다.
  • given().willReturn(): BDD 스타일의 스텁 설정입니다.
  • then().should(): BDD 스타일로 메서드 호출을 검증합니다.
  • never(): 특정 메서드가 호출되지 않았음을 검증합니다.

4. @WebMvcTest - 컨트롤러 슬라이스 테스트

@WebMvcTest는 웹 계층만 로드하여 컨트롤러의 요청/응답을 테스트합니다. 서비스 계층은 Mock으로 대체됩니다.

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("GET /api/users/{id} - 사용자 조회 성공")
    void getUser_ReturnsUser() throws Exception {
        // given
        UserResponse response = new UserResponse(1L, "김승원", "sw@example.com");
        given(userService.findById(1L)).willReturn(response);

        // when & then
        mockMvc.perform(get("/api/users/{id}", 1L)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("김승원"))
                .andExpect(jsonPath("$.email").value("sw@example.com"))
                .andDo(print());
    }

    @Test
    @DisplayName("POST /api/users - 유효성 검증 실패 시 400 반환")
    void createUser_WithInvalidRequest_Returns400() throws Exception {
        // given
        CreateUserRequest request = new CreateUserRequest("", "invalid-email");

        // when & then
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors").isArray())
                .andExpect(jsonPath("$.errors.length()").value(2));
    }

    @Test
    @DisplayName("존재하지 않는 사용자 조회 시 404 반환")
    void getUser_WhenNotFound_Returns404() throws Exception {
        given(userService.findById(999L))
                .willThrow(new ResourceNotFoundException("사용자를 찾을 수 없습니다"));

        mockMvc.perform(get("/api/users/{id}", 999L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.title").value("리소스를 찾을 수 없습니다"));
    }
}

5. @DataJpaTest - Repository 슬라이스 테스트

@DataJpaTest는 JPA 관련 빈만 로드하여 Repository를 테스트합니다. 기본적으로 내장 H2 데이터베이스를 사용하며, 각 테스트 후 자동으로 롤백됩니다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager em;

    @Test
    @DisplayName("이메일로 사용자를 조회할 수 있다")
    void findByEmail_ReturnsUser() {
        // given
        User user = User.builder()
                .name("김승원")
                .email("sw@example.com")
                .build();
        em.persistAndFlush(user);
        em.clear();

        // when
        Optional<User> found = userRepository.findByEmail("sw@example.com");

        // then
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("김승원");
    }

    @Test
    @DisplayName("특정 날짜 이후 가입한 활성 사용자를 조회할 수 있다")
    void findActiveUsersAfter_ReturnsFilteredUsers() {
        // given
        User active = User.builder()
                .name("활성유저").status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.of(2026, 3, 1, 0, 0)).build();
        User inactive = User.builder()
                .name("비활성유저").status(UserStatus.INACTIVE)
                .createdAt(LocalDateTime.of(2026, 3, 1, 0, 0)).build();
        User old = User.builder()
                .name("과거유저").status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.of(2025, 1, 1, 0, 0)).build();

        em.persist(active);
        em.persist(inactive);
        em.persist(old);
        em.flush();
        em.clear();

        // when
        List<User> result = userRepository.findActiveUsersAfter(
                LocalDateTime.of(2026, 1, 1, 0, 0));

        // then
        assertThat(result).hasSize(1);
        assertThat(result.get(0).getName()).isEqualTo("활성유저");
    }
}

em.clear()를 호출하여 영속성 컨텍스트를 초기화하는 것이 중요합니다. 이를 생략하면 1차 캐시에서 엔티티를 반환하므로, 실제 쿼리가 올바르게 동작하는지 검증할 수 없습니다.

6. Testcontainers - 실제 DB로 테스트하기

H2는 MySQL/PostgreSQL과 문법이 다른 부분이 있어, 프로덕션 환경과 동일한 DB로 테스트하는 것이 안전합니다. Testcontainers는 Docker 컨테이너로 실제 DB를 띄워 테스트할 수 있게 해줍니다.

// build.gradle
testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
testImplementation 'org.testcontainers:mysql:1.20.4'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
// 공통 테스트 설정 클래스
@Testcontainers
@SpringBootTest
public abstract class IntegrationTestBase {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}
// 통합 테스트 예시
class OrderIntegrationTest extends IntegrationTestBase {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    @DisplayName("주문 생성 통합 테스트 - 실제 MySQL에서 실행")
    @Transactional
    void createOrder_IntegrationTest() {
        // given
        Product product = productRepository.save(
                Product.builder()
                        .name("테스트 상품")
                        .price(10000)
                        .stock(50)
                        .build());

        CreateOrderRequest request = new CreateOrderRequest(
                product.getId(), 3);

        // when
        OrderResponse response = orderService.createOrder(request);

        // then
        assertThat(response.getTotalPrice()).isEqualTo(30000);

        Product updated = productRepository.findById(product.getId()).orElseThrow();
        assertThat(updated.getStock()).isEqualTo(47);
    }
}

withReuse(true)는 테스트 간에 컨테이너를 재사용하여 실행 속도를 높입니다. 이를 사용하려면 ~/.testcontainers.propertiestestcontainers.reuse.enable=true를 추가해야 합니다.

7. 테스트 작성 팁

  • 테스트 이름은 한글로: @DisplayName에 한글로 시나리오를 명확히 작성하면 실패 시 원인 파악이 쉽습니다.
  • Given-When-Then 패턴: 모든 테스트를 준비(Given) - 실행(When) - 검증(Then) 구조로 작성합니다.
  • 하나의 테스트에 하나의 검증: 한 테스트에서 너무 많은 것을 검증하면 실패 원인을 찾기 어렵습니다.
  • 테스트 픽스처 분리: 반복되는 테스트 데이터 생성 로직은 Builder나 Factory로 분리합니다.
  • @SpringBootTest는 최소화: 전체 컨텍스트 로드는 느리므로, 슬라이스 테스트(@WebMvcTest, @DataJpaTest)를 우선 사용합니다.

마치며

좋은 테스트 전략은 개발 속도를 높이고 리팩토링에 대한 자신감을 줍니다. 핵심은 각 계층에 맞는 테스트 도구를 선택하는 것입니다. 서비스 로직은 Mockito로 빠르게, 컨트롤러는 @WebMvcTest로 요청/응답을, Repository는 @DataJpaTest로 쿼리를, 그리고 전체 흐름은 Testcontainers로 실제 환경과 동일하게 검증하세요. 테스트 피라미드를 기억하며 단위 테스트를 많이, 통합 테스트를 적절히, E2E 테스트는 핵심 시나리오에만 적용하는 것이 실무에서 가장 효율적인 전략입니다.