JUnit 5 기본 구조
JUnit 5는 JUnit Platform(실행 엔진), JUnit Jupiter(테스트 API), JUnit Vintage(하위 호환) 세 모듈로 구성됩니다. Spring Boot spring-boot-starter-test에 JUnit 5와 Mockito가 모두 포함되어 있어 별도 의존성 추가 없이 바로 사용할 수 있습니다.
테스트를 요리에 비유하면, 단위 테스트는 재료 하나하나의 품질 검사이고, 통합 테스트는 완성된 요리의 맛 검증입니다. 두 가지 모두 필요합니다.
JUnit 5 어노테이션과 기본 테스트
// CalculatorTest.java — JUnit 5 기본 어노테이션
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
// 테스트 대상 클래스
class Calculator {
int add(int a, int b) { return a + b; }
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다");
return a / b;
}
}
@DisplayName("Calculator 테스트") // 테스트 이름 지정
class CalculatorTest {
private Calculator calculator;
@BeforeEach // 각 테스트 전 실행
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("덧셈 - 양수끼리")
void addPositiveNumbers() {
// given (준비)
int a = 3, b = 5;
// when (실행)
int result = calculator.add(a, b);
// then (검증)
assertEquals(8, result, "3 + 5 = 8이어야 합니다");
}
@Test
@DisplayName("나눗셈 - 0으로 나누면 예외 발생")
void divideByZeroThrowsException() {
ArithmeticException exception = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("0으로 나눌 수 없습니다", exception.getMessage());
}
// 파라미터화 테스트 — 여러 입력값을 한 번에 테스트
@ParameterizedTest
@CsvSource({"1, 1, 2", "0, 0, 0", "-1, 1, 0", "100, 200, 300"})
@DisplayName("덧셈 - 다양한 입력값")
void addVariousInputs(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 10})
@DisplayName("나눗셈 - 양수로 나누기")
void divideByPositive(int divisor) {
assertDoesNotThrow(() -> calculator.divide(100, divisor));
}
// 다중 검증 — 하나가 실패해도 나머지 검증 계속 실행
@Test
@DisplayName("모든 기본 연산 검증")
void assertAllOperations() {
assertAll("Calculator 기본 연산",
() -> assertEquals(5, calculator.add(2, 3)),
() -> assertEquals(3, calculator.divide(9, 3)),
() -> assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0))
);
}
}
// 실행 결과:
// CalculatorTest > 덧셈 - 양수끼리 PASSED
// CalculatorTest > 나눗셈 - 0으로 나누면 예외 발생 PASSED
// CalculatorTest > 덧셈 - 다양한 입력값 [1] 1, 1, 2 PASSED
// CalculatorTest > 덧셈 - 다양한 입력값 [2] 0, 0, 0 PASSED
// ... (모두 PASSED)
Mockito Mock과 Spy
// UserServiceTest.java — Mockito Mock/Spy 활용
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// 인터페이스 및 클래스 정의
interface UserRepository {
Optional<User> findById(Long id);
User save(User user);
}
interface EmailService {
void sendWelcomeEmail(String email);
}
record User(Long id, String name, String email) {}
class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다: " + id));
}
User createUser(String name, String email) {
User user = userRepository.save(new User(null, name, email));
emailService.sendWelcomeEmail(email);
return user;
}
}
@ExtendWith(MockitoExtension.class) // Mockito 확장 활성화
class UserServiceTest {
@Mock // 가짜 객체 — 모든 메서드가 기본값 반환
UserRepository userRepository;
@Mock
EmailService emailService;
@InjectMocks // Mock을 자동 주입하여 테스트 대상 생성
UserService userService;
@Test
void 사용자_조회_성공() {
// given — Mock 행동 정의
User mockUser = new User(1L, "홍길동", "hong@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// when — 테스트 대상 실행
User result = userService.getUser(1L);
// then — 결과 검증
assertEquals("홍길동", result.name());
assertEquals("hong@example.com", result.email());
// 호출 횟수 검증
verify(userRepository, times(1)).findById(1L);
}
@Test
void 사용자_조회_실패_시_예외() {
// given — 빈 Optional 반환
when(userRepository.findById(999L)).thenReturn(Optional.empty());
// when & then
RuntimeException ex = assertThrows(
RuntimeException.class,
() -> userService.getUser(999L)
);
assertTrue(ex.getMessage().contains("999"));
}
@Test
void 사용자_생성_시_이메일_발송() {
// given
User savedUser = new User(1L, "김개발", "kim@example.com");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// when
User result = userService.createUser("김개발", "kim@example.com");
// then — 반환값 검증
assertEquals("김개발", result.name());
// 이메일 서비스 호출 검증
verify(emailService).sendWelcomeEmail("kim@example.com");
// 저장소 호출 순서 검증
var inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any());
inOrder.verify(emailService).sendWelcomeEmail(anyString());
}
}
// 실행 결과:
// UserServiceTest > 사용자_조회_성공 PASSED
// UserServiceTest > 사용자_조회_실패_시_예외 PASSED
// UserServiceTest > 사용자_생성_시_이메일_발송 PASSED
ArgumentCaptor와 Spy
// AdvancedMockitoTest.java — ArgumentCaptor, Spy 활용
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// 알림 서비스
class NotificationService {
void send(String recipient, String message) {
// 실제로는 이메일/SMS 전송
System.out.println(recipient + "에게 전송: " + message);
}
}
class OrderService {
private final NotificationService notificationService;
OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
void placeOrder(String userId, String item) {
// 주문 처리 로직 ...
notificationService.send(userId, "주문 완료: " + item);
}
}
@ExtendWith(MockitoExtension.class)
class AdvancedMockitoTest {
@Mock
NotificationService notificationService;
@InjectMocks
OrderService orderService;
// ArgumentCaptor — 메서드에 전달된 인자를 캡처하여 검증
@Captor
ArgumentCaptor<String> recipientCaptor;
@Captor
ArgumentCaptor<String> messageCaptor;
@Test
void 주문_시_알림_메시지_내용_검증() {
// when
orderService.placeOrder("user-42", "키보드");
// then — 전달된 인자 캡처
verify(notificationService).send(
recipientCaptor.capture(),
messageCaptor.capture()
);
assertEquals("user-42", recipientCaptor.getValue());
assertTrue(messageCaptor.getValue().contains("키보드"));
assertTrue(messageCaptor.getValue().contains("주문 완료"));
}
// Spy — 실제 객체를 감싸서 일부만 Mock
@Test
void Spy_실제_메서드_호출_확인() {
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);
// 실제 메서드 호출됨
spyList.add("하나");
spyList.add("둘");
assertEquals(2, spyList.size()); // 실제 size() 호출
// 특정 메서드만 스텁 (나머지는 실제 동작)
doReturn(100).when(spyList).size();
assertEquals(100, spyList.size()); // 스텁된 값 반환
assertEquals("하나", spyList.get(0)); // 실제 값 반환
// verify로 호출 확인
verify(spyList, times(2)).add(anyString());
}
}
// 실행 결과:
// AdvancedMockitoTest > 주문_시_알림_메시지_내용_검증 PASSED
// AdvancedMockitoTest > Spy_실제_메서드_호출_확인 PASSED
Spring Boot 통합 테스트
// TaskControllerIntegrationTest.java — @SpringBootTest 통합 테스트
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest // 전체 애플리케이션 컨텍스트 로드
@AutoConfigureMockMvc // MockMvc 자동 구성
class TaskControllerIntegrationTest {
@Autowired
MockMvc mockMvc; // HTTP 요청 시뮬레이션
@Test
void POST_태스크_생성_201() throws Exception {
String requestBody = """
{
"title": "JUnit 학습",
"description": "JUnit 5와 Mockito 정리"
}
""";
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated()) // 201 확인
.andExpect(jsonPath("$.title").value("JUnit 학습")) // JSON 필드 검증
.andExpect(jsonPath("$.completed").value(false));
}
@Test
void POST_제목_없으면_400() throws Exception {
String requestBody = """
{
"title": "",
"description": "설명만"
}
""";
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest()); // 400 확인
}
@Test
void GET_존재하지_않는_태스크_404() throws Exception {
mockMvc.perform(get("/api/tasks/99999"))
.andExpect(status().isNotFound());
}
}
// 실행 결과:
// TaskControllerIntegrationTest > POST_태스크_생성_201 PASSED
// TaskControllerIntegrationTest > POST_제목_없으면_400 PASSED
// TaskControllerIntegrationTest > GET_존재하지_않는_태스크_404 PASSED
실전 팁
테스트 코드 품질을 높이는 핵심 원칙입니다.
- 테스트 이름은 한국어로 명확하게 작성합니다.
사용자_조회_실패_시_예외처럼 무엇을 검증하는지 바로 알 수 있어야 합니다 - Given-When-Then 패턴을 일관되게 사용합니다. 준비(Mock 설정) → 실행(테스트 대상 호출) → 검증(결과 확인) 순서를 지킵니다
- Mock은 외부 의존성에만 사용합니다. 테스트 대상 클래스 자체를 Mock하면 의미 없는 테스트가 됩니다
@Spy는 레거시 코드 테스트에 유용합니다. 리팩토링이 어려운 클래스의 일부 메서드만 스텁할 때 사용합니다- 통합 테스트는 핵심 시나리오만 작성합니다. 모든 경우를 통합 테스트로 커버하면 속도가 느려집니다. 경계값과 예외 케이스는 단위 테스트로 처리합니다
@ParameterizedTest로 반복적인 테스트를 줄이고,assertAll()로 한 테스트에서 여러 조건을 한 번에 검증합니다