들어가며
테스트는 소프트웨어 품질을 보장하는 가장 확실한 방법입니다. 하지만 무작정 테스트를 작성하면 오히려 유지보수 비용만 증가합니다. 어떤 계층에서 어떤 종류의 테스트를 작성해야 하는지, 적절한 도구를 어떻게 활용하는지 아는 것이 중요합니다. 이 글에서는 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.properties에 testcontainers.reuse.enable=true를 추가해야 합니다.
7. 테스트 작성 팁
- 테스트 이름은 한글로:
@DisplayName에 한글로 시나리오를 명확히 작성하면 실패 시 원인 파악이 쉽습니다. - Given-When-Then 패턴: 모든 테스트를 준비(Given) - 실행(When) - 검증(Then) 구조로 작성합니다.
- 하나의 테스트에 하나의 검증: 한 테스트에서 너무 많은 것을 검증하면 실패 원인을 찾기 어렵습니다.
- 테스트 픽스처 분리: 반복되는 테스트 데이터 생성 로직은 Builder나 Factory로 분리합니다.
- @SpringBootTest는 최소화: 전체 컨텍스트 로드는 느리므로, 슬라이스 테스트(@WebMvcTest, @DataJpaTest)를 우선 사용합니다.
마치며
좋은 테스트 전략은 개발 속도를 높이고 리팩토링에 대한 자신감을 줍니다. 핵심은 각 계층에 맞는 테스트 도구를 선택하는 것입니다. 서비스 로직은 Mockito로 빠르게, 컨트롤러는 @WebMvcTest로 요청/응답을, Repository는 @DataJpaTest로 쿼리를, 그리고 전체 흐름은 Testcontainers로 실제 환경과 동일하게 검증하세요. 테스트 피라미드를 기억하며 단위 테스트를 많이, 통합 테스트를 적절히, E2E 테스트는 핵심 시나리오에만 적용하는 것이 실무에서 가장 효율적인 전략입니다.
'Spring Boot' 카테고리의 다른 글
| Spring Security 6 실전 가이드 - JWT + OAuth2 인증/인가 완벽 정리 (0) | 2026.04.02 |
|---|---|
| Spring AI 2.0과 Spring Boot 4 - AI 퍼스트 Java 개발의 시작 (0) | 2026.04.01 |
| Spring AOP 실전 활용 - 로깅부터 성능 측정까지 (0) | 2026.03.26 |
| Spring Boot 예외 처리 전략 - @ControllerAdvice부터 ProblemDetail까지 (0) | 2026.03.26 |
| Spring Boot 3.x 마이그레이션 가이드 - 실무에서 꼭 알아야 할 변경사항 (0) | 2026.03.24 |