Spring

[Spring Boot] JUnit5 + Mockito 단위 테스트 기초 정리

곽코딩루카 2025. 11. 27. 11:26
반응형
목차
0. 서론
1. 단위 테스트에서 우리가 하고 싶은 일
2. 아주 쉬운 예제로 시작해 보기
3. Mockito 기본 세팅
4. @Test, @DisplayName, @Nested – 테스트 구조 잡기
5. Mockito 기본 동작 – when/thenReturn, verify, verifyNoInteractions
6. 정리: 테스트 코드 작성 흐름
7. 마무리

 

 

0. 서론

@Mock, @InjectMocks, @ExtendWith, @Nested, @DisplayName까지 한 번에

요즘 스프링 개발을 하다 보면 “테스트 코드도 같이 작성해 주세요”라는 말을 자주 듣는다.
문제는 제대로 배워본 적이 없어서, 처음 테스트 코드를 보면 주술처럼 느껴진다는 점이다.

 

@ExtendWith(MockitoExtension.class)
@Mock
@InjectMocks
@Nested
@DisplayName
when().thenReturn(...)
verify(...)
verifyNoInteractions(...)

 

이런 것들이 한꺼번에 쏟아지면 “그냥 서비스 하나 더 만드는 게 빠르겠다”는 생각이 들 수도 있다.
이번 글에서는 내가 실제로 테스트 코드를 공부하면서 정리한 내용을 바탕으로, 위 어노테이션과 메소드들이 무엇을 하는지, 어떤 흐름으로 단위 테스트를 작성하면 되는지를 쉬운 예제와 함께 정리해 본다.

특히 JUnit5 + Mockito 조합을 기준으로 설명한다.

 

1. 단위 테스트에서 우리가 하고 싶은 일

단위 테스트의 목표는 결국 한 줄로 요약된다.

 

“이 메서드를 이렇게 호출했을 때, 이 결과와 이 동작이 일어나야 한다.”

 

조금 더 세분화하면:

  1. 파라미터 검증: 잘못된 값이 들어오면 예외가 발생해야 한다.
  2. 도메인/레포지토리 호출 검증: 내부적으로 어떤 메서드를 호출했는지 확인한다.
  3. 결과 검증: 리턴 값이 기대한 값인지 확인한다.

이걸 도와주는 도구가 바로
JUnit5(테스트 프레임워크)와 Mockito(가짜 객체, Mock 생성용 라이브러리)다.

 

2. 아주 쉬운 예제로 시작해 보기

실제 업무 코드는 도메인 규칙이 복잡해서 처음 학습용으로 쓰기엔 조금 무겁다.
그래서 최대한 단순한 예제를 하나 만들어 보자.

 

 

2-1. 예제 도메인: 사용자 인사말 서비스

  • UserRepository에서 사용자를 찾고
  • 사용자 이름으로 “Hello {이름}” 메세지를 만들어주는 서비스
// src/main/java/.../User.java
public class User {
    private final Long id;
    private final String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() { return id; }

    public String getName() { return name; }
}







// src/main/java/.../UserRepository.java
public interface UserRepository {
    User findById(Long id);
}







// src/main/java/.../GreetingService.java
public class GreetingService {

    private final UserRepository userRepository;

    public GreetingService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public String getGreeting(Long userId) {
        if (userId == null) {
            throw new IllegalArgumentException("userId는 null일 수 없습니다.");
        }

        User user = userRepository.findById(userId);
        if (user == null) {
            throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
        }

        return "Hello " + user.getName();
    }
}

 

우리가 테스트하고 싶은 메서드는 GreetingService#getGreeting 하나다.

 

 

3. Mockito 기본 세팅 – @ExtendWith, @Mock, @InjectMocks

3-1. @ExtendWith(MockitoExtension.class)

@ExtendWith(MockitoExtension.class)
class GreetingServiceTest {
    ...
}

 

Unit5에서는 @ExtendWith를 통해 테스트에 “확장 기능”을 붙일 수 있는데,
MockitoExtension은 말 그대로 “Mockito를 쓸 수 있는 환경”을 만들어준다.

  • @Mock으로 가짜 객체를 만들 수 있게 해주고
  • @InjectMocks로 테스트 대상 객체를 생성할 때 Mock들을 자동으로 주입해 준다

라고 생각하면 된다.

 

3-2. @Mock – 가짜 객체 만들기

@Mock
UserRepository userRepository;

 

@Mock이 붙은 필드는 진짜 구현이 아니라 Mockito가 만들어주는 가짜 객체다.

  • 실제 DB는 전혀 사용하지 않는다.
  • when(…).thenReturn(…) 형태로
    “이 메서드가 호출되면 이런 값을 돌려줘”라고 지정해 줄 수 있다.
  • 어떤 메서드가 어떻게 호출되었는지 기록해 두었다가 verify(…)로 검증할 수 있다.

3-3. @InjectMocks – Mock들을 끼워 넣어서 테스트 대상 생성

@InjectMocks
GreetingService greetingService;

GreetingService는 생성자에서 UserRepository를 받는다. (생성자주입)

public GreetingService(UserRepository userRepository) { ... }

테스트에서는 UserRepository를 @Mock으로 만들어두었기 때문에, 아래 코드와 동일한 효과가 난다

// Mockito가 내부적으로 해주는 일
greetingService = new GreetingService(userRepository);

 

즉,

  • @Mock → 부품(가짜 의존성)
  • @InjectMocks → 그 부품을 끼워 넣은 테스트 대상 객체(GreetingService 인스턴스) (System Under Test” = 테스트할 대상 클래스.)

라고 보면 된다.

 

4. @Test, @DisplayName, @Nested – 테스트 구조 잡기

4-1. @Test – 이 메서드는 테스트 함수다

@Test
void 성공_정상_사용자_인사말_반환() { ... }

JUnit5에게 “이 메서드는 테스트로 실행해 달라”고 알려주는 어노테이션이다.
@Test가 붙은 메서드만 테스트로 인식된다.

4-2. @DisplayName – 실행 결과에 보여줄 이름

@Test
@DisplayName("정상 사용자 ID를 넣으면 Hello {이름} 문자열을 반환한다")
void 성공_정상_사용자_인사말_반환() { ... }

DE나 콘솔에서 테스트가 실행될 때,
성공_정상_사용자_인사말_반환 대신 사람이 읽기 좋은 문장으로 표시해 준다.

실행 결과가 이렇게 보인다.

  • getGreeting
    • 정상 사용자 ID를 넣으면 Hello {이름} 문자열을 반환한다

테스트 결과를 나중에 다시 볼 때, 어떤 시나리오였는지 한눈에 보인다.

4-3. @Nested – 테스트를 기능별로 묶기

@Nested는 “테스트를 그룹으로 묶고 싶을 때” 쓴다.

@Nested
@DisplayName("getGreeting 메서드")
class GetGreeting {

    @Test
    @DisplayName("정상 사용자 ID를 넣으면 Hello {이름} 문자열을 반환한다")
    void 성공_정상_사용자_인사말_반환() { ... }

    @Test
    @DisplayName("userId가 null이면 예외를 던지고 UserRepository는 호출하지 않는다")
    void 실패_userId_null() { ... }
}

실행 결과 계층도는 다음과 같이 정리된다.

  • getGreeting 메서드
    • 정상 사용자 ID를 넣으면 Hello {이름} 문자열을 반환한다
    • userId가 null이면 예외를 던지고 UserRepository는 호출하지 않는다

여러 메서드를 테스트할 때는 이렇게 기능 단위로 그룹을 나눠두면,
테스트 클래스가 커져도 구조가 눈에 잘 들어온다.

 

5. Mockito 기본 동작 – when/thenReturn, verify, verifyNoInteractions

이제 실제 예제 테스트 코드를 보면서 메소드들을 정리해 보자.

5-1. 성공 케이스 테스트

@ExtendWith(MockitoExtension.class)
class GreetingServiceTest {

    @Mock
    UserRepository userRepository;

    @InjectMocks
    GreetingService greetingService;

    @Nested
    @DisplayName("getGreeting 메서드")
    class GetGreeting {

        @Test
        @DisplayName("정상 사용자 ID를 넣으면 Hello {이름} 문자열을 반환한다")
        void 성공_정상_사용자_인사말_반환() {
            // given
            Long userId = 1L;
            User user = new User(userId, "치영");

            // userRepository.findById(1L)가 호출되면 user를 반환하도록 설정
            when(userRepository.findById(userId))
                    .thenReturn(user);

            // when
            String result = greetingService.getGreeting(userId);

            // then
            assertEquals("Hello 치영", result);

            // userRepository.findById(1L)가 실제로 한 번 호출되었는지 검증
            verify(userRepository).findById(userId);

            // 그 외 다른 메서드는 호출되지 않았는지 검증
            verifyNoMoreInteractions(userRepository);
        }
    }
}

 

여기서 중요한 포인트만 짚어보자.

when(…).thenReturn(…)

when(userRepository.findById(userId))
        .thenReturn(user);
  • Mock 객체는 실제 구현이 없기 때문에 우리가 동작을 “설정”해줘야 한다.
  • “테스트 중에 userRepository.findById(1L)가 호출되면,
    user 객체를 돌려줘라” 라는 뜻이다.

assertEquals(기대값, 실제값)

assertEquals("Hello 치영", result);
  • JUnit의 기본 검증 메소드
  • 기대값과 실제값이 같으면 성공, 다르면 실패

verify(mock).메서드(인자)

verify(userRepository).findById(userId);

 

  • “테스트를 실행하는 동안 userRepository.findById(1L)가 호출되었는지” 확인
  • Mock 객체는 모든 호출을 기록해 두었다가,
    verify가 그 기록을 검사한다고 생각하면 이해가 쉽다.

    메소드 형태가 조금 햇갈릴 수 있는데 이렇게 이해하면 쉽다.
    userRepository객체를 verify메소드로 검증하라 findById메소드가 호출되었는지!

 

verifyNoMoreInteractions(userRepository);
  • 위에서 검증한 호출(여기서는 findById) 외에
    다른 메서드가 호출되면 테스트를 실패시킨다.
  • “의도한 것 이외의 추가 호출이 없어야 한다”는 걸 강하게 보장하고 싶을 때 사용한다.

 

5-2. 실패 케이스 – 파라미터 검증과 assertThrows, verifyNoInteractions

이번에는 userId가 null일 때를 테스트해 보자.

@Nested
@DisplayName("getGreeting 메서드")
class GetGreeting {

    @Test
    @DisplayName("userId가 null이면 예외를 던지고 UserRepository는 호출하지 않는다")
    void 실패_userId_null() {
        // when & then
        IllegalArgumentException ex = assertThrows(
                IllegalArgumentException.class,
                () -> greetingService.getGreeting(null)
        );

        assertEquals("userId는 null일 수 없습니다.", ex.getMessage());

        // 어떤 메서드도 호출되면 안 된다.
        verifyNoInteractions(userRepository);
    }
}

 

여기서 새로 나온 것들을 보면:

 

assertThrows

IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> greetingService.getGreeting(null)
);

 

 

  • “이 코드를 실행했을 때 반드시 IllegalArgumentException이 발생해야 한다”는 의미
  • 예외가 발생하지 않거나 다른 예외가 발생하면 테스트는 실패한다.
  • 발생한 예외 객체를 반환해 주기 때문에, 메시지도 검증할 수 있다.

 

assertEquals

assertEquals("userId는 null일 수 없습니다.", ex.getMessage());

 

이 한 줄은 **“예외 메시지가 내가 기대한 문자열과 정확히 같은지”**를 검증하는 코드다.

 

 

verifyNoInteractions

verifyNoInteractions(userRepository);
  • 해당 Mock 객체에 대해 어떠한 메서드 호출도 없어야 한다는 검증이다.
  • userId == null이면 서비스 메서드는 바로 예외를 던지고 끝나야 하기 때문에
    findById 같은 호출이 일어나면 안 된다.
  • 이런 상황에 verifyNoInteractions를 사용하면 “파라미터 검증에서 바로 빠져나왔는지”까지 확인할 수 있다.

 

6. 정리: 테스트 코드 작성 흐름

이제까지 나온 내용을 정리하면,
JUnit5 + Mockito로 단위 테스트를 작성할 때 대략 이런 흐름으로 코드를 짤 수 있다.

  1. 테스트 클래스 기본 구조
    • @ExtendWith(MockitoExtension.class)로 Mockito 활성화
    • @Mock으로 의존성(Repository, 외부 서비스 등) 가짜 객체 생성
    • @InjectMocks로 테스트 대상 서비스 생성
    • 기능별로 @Nested + @DisplayName으로 그룹화
  2. 성공 케이스
    • given
      • when(mock.method(...)).thenReturn(...)로 Mock 동작 설정
    • when
      • 서비스 메서드 호출
    • then
      • assertEquals, assertThat 등으로 결과 검증
      • verify(mock).method(...)로 필요한 호출이 있었는지 검증
      • verifyNoMoreInteractions(mock)로 불필요한 추가 호출 방지
  3. 실패 케이스(파라미터 검증, 예외)
    • assertThrows로 특정 예외가 발생하는지 확인
    • 예외 메시지까지 assertEquals로 검증
    • verifyNoInteractions(mock)로 내부 호출이 전혀 없었는지 확인

테스트 코드도 결국 “코드”이기 때문에,
한 번에 완벽하게 이해하려고 하기보다는
이런 작은 예제에서 흐름을 익히고,
실제 업무 코드에 하나씩 적용해 보는 게 제일 빠른 것 같다.

 

7. 마무리

이번 글에서는 다음 내용을 중심으로 정리했다.

  • @ExtendWith(MockitoExtension.class), @Mock, @InjectMocks가 각각 어떤 역할을 하는지
  • @Nested, @DisplayName으로 테스트 구조를 어떻게 잡으면 좋은지
  • when().thenReturn(), verify(), verifyNoInteractions(), verifyNoMoreInteractions() 같은 Mockito 메서드들이 실제로 어떤 상황에서 쓰이는지
  • assertThrows, assertEquals를 이용해 예외와 결과를 어떻게 검증하는지

처음 테스트 코드를 볼 때는 문법 하나하나가 낯설지만,
실제로는 “서비스가 어떤 메서드를 호출해야 하고, 어떤 결과를 내야 하는지”를 코드로 정리한 것뿐이다.

실제 프로젝트에서도 오늘 사용한 예제 패턴 그대로:

  • 파라미터 검증 실패 케이스
  • 정상 흐름(성공 케이스)
  • 레포지토리/도메인 메서드 호출 여부 검증

을 쌓아 나가다 보면, 어느 순간 테스트 코드를 짜는 게 자연스러워질 것이다.

필요하다면 다음 글에서는
Spring Boot 환경에서 @SpringBootTest와 Mock 없이 실제 Bean을 띄워 놓고 검증하는 통합 테스트,
혹은 도메인 레벨에서 엔티티/VO만 가지고 순수 자바 단위 테스트를 작성하는 방법도 정리해 볼 예정이다.

반응형