JUnit 5 + Mockito 테스트 가이드 — 단위부터 통합까지

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()로 한 테스트에서 여러 조건을 한 번에 검증합니다

이 글이 도움이 되었나요?