본문 바로가기

Spring

[Spring] 단위 테스트 작성 with (JUnit5, Mockito)

🤔 단위 테스트(Unit Test)란?

단위 테스트(Unit Test) 는 소프트웨어 개발에서 가장 기본적인 테스트 기법으로, 특정 기능 단위(보통 메서드)의 동작을 검증한다

 

🤔 Mockito란?

Mockito는 테스트 대상 객체의 의존성을 모방(mock)하는 가짜 객체를 쉽게 생성해 주는 라이브러리이다.
실제 복잡한 의존성(DB, 네트워크, 외부 API)을 대신해, 테스트 환경을 단순화하고 빠르게 만들어 준다.

 

소프트웨어 개발 시 단위 테스트는 필수지만, 우리가 테스트하려는 클래스(예: MemberService)는 보통 이런 특징이 있다.

  1. 테스트 대상 클래스가 다른 객체에 강하게 의존 (예: MemberRepository)
  2. 의존 객체는 복잡하거나 무거움 (DB 연결, 네트워크 호출, 외부 API 등)
  3. 실제 객체 사용 시 테스트가 느리고, 실패 가능성이 많고, 독립적이지 않음
  4. 예를 들어, 서비스가 DB에 저장하려고 하면 테스트할 때마다 DB 세팅, 초기화가 필요해 비효율적
Mock = 테스트를 위한 '가짜' 객체

 

테스트 시 의존하는 복잡한 객체 대신 간단한 가짜 객체(Mock Object)를 사용해 원하는 동작을 흉내 내자!

이런 요구를 충족시키는 프레임워크가 바로 Mockito이다.

 

✏️ Mockito의 특징

  • 진짜 객체와 똑같은 인터페이스를 가지고 있음
  • 동작(메서드 호출)에 대해 원하는 대로 '행동'을 정해줄 수 있음
  • 데이터베이스, 네트워크 등 외부 의존 없이 테스트 가능

🌟 Mockito 동작 원리

  • Mockito는 Java의 프록시(Proxy) 기술을 사용해 런타임에 인터페이스나 클래스의 가짜 객체를 만든다.
  • 이 가짜 객체는 메서드 호출을 가로채서, 미리 지정된 행동(결과 반환, 예외 발생 등)을 수행한다.

📚 주요 용어

용어  설명
Mock 테스트 대상 의존 객체를 가짜로 만든 객체. 실제 객체 대신 사용하여 메서드 호출 시 원하는 동작을 지정 가능.
Stub Mock과 비슷하지만, 주로 메서드의 반환값을 미리 정의하는 데 집중.
Spy 실제 객체를 감싸면서 특정 메서드만 가짜 동작으로 변경 가능. 일부는 실제 동작, 일부는 모킹.
Verification 테스트 중 특정 메서드가 호출되었는지, 몇 번 호출되었는지 검증하는 과정.

 

📚 Mockito, Spring Test 관련 어노테이션

// Mockito 확장자 등록 - Mockito 어노테이션 활성화
@ExtendWith(SpringExtension.class)
class MemberServiceTest {

    // 의존하는 MemberRepository를 가짜(Mock) 객체로 생성
    @Mock
    private MemberRepository memberRepository;

    // 테스트 대상 서비스 클래스
    @InjectMocks
    private MemberService memberService;
    
}
어노테이션 용도
@Mock 가짜(Mock) 객체를 생성하여 테스트에서 사용.
@InjectMocks 테스트 대상 객체에 @Mock 객체들을 주입.
@ExtendWith(MockitoExtension.class) Mockito 기능 활성화 (JUnit5 환경에서 필요).
@SpringBootTest Spring 컨텍스트를 띄워 통합 테스트 시 사용.
@WebMvcTest 컨트롤러만 테스트할 때, Spring MVC 컨텍스트만 로드.
@MockBean Spring 컨텍스트 내에서 Mock 빈으로 등록할 때 사용.

 

📚 Mockito 주요 메서드 

1. when(...).thenReturn(...)

// memberRepository.findById(1L) 가 호출되면, 실제 DB 조회 없이 Optional.of(new Member("홍길동"))를 반환한다는 의미
when(memberRepository.findById(1L)).thenReturn(Optional.of(new Member("홍길동")));
  • 특정 메서드가 호출될 때 미리 지정한 값을 반환하도록 설정
  • 테스트 중에 실제 동작 대신 예상 결과를 미리 만들어 놓는 것
2. verify(...)

// memberRepository.save()가 최소 한 번 호출됐는지 검증
verify(memberRepository).save(any(Member.class));

// delete() 메서드가 정확히 2번 호출됐는지 검증
verify(memberRepository, times(2)).delete(any());

// save()가 한 번도 호출되지 않았는지 검증
verify(memberRepository, never()).save(any());
  • 특정 메서드가 테스트 중에 호출됐는지, 몇 번 호출됐는지를 검증
  • 테스트에서 메서드 호출 여부를 체크해 기대 동작이 일어났는지 확인하는 용도
3. any()

// 어떤 이메일이 들어와도 항상 빈 결과를 반환
when(memberRepository.findByEmail(any())).thenReturn(Optional.empty());

// 타입이 Member인 인자면 모두 허용해 호출 검증
verify(memberRepository).save(any(Member.class));
  • 메서드 호출 시 전달된 인자값을 무시하고, 어떤 값이라도 허용하는 matcher(매처)
  • Mockito의 매처(matcher)는 특정 인자에 국한하지 않고 호출 검증 및 동작 지정 시 편리하게 사용하다.
  • 단, Mockito 매처 사용 시, 모든 인자에 대해 매처를 사용하거나 모두 실제 값이어야 하며 혼용 불가하다.
4. thenAnswer(...)

// save() 호출 시 입력받은 Member 객체에 ID를 세팅하고 그 객체를 반환하는 동작을 구현한 것
when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> {
    Member arg = invocation.getArgument(0);
    arg.setId(100L);  // 저장 후 ID 부여하는 동작 흉내
    return arg;
});
  • 메서드 호출 시 단순히 값을 반환하는 것을 넘어서 호출된 인자값을 이용해 커스텀 동작을 구현
  • 복잡한 로직이나 호출에 따른 결과를 동적으로 처리할 때 사용한다.

❗️ Mockito 매처 혼용 금지 규칙

someMock.someMethod(arg1, arg2, arg3);

Mockito의 메서드 스텁(stubbing)이나 검증(verification)을 할 때, 메서드 파라미터가 여러 개라면

이 중 일부 파라미터에는 매처(any(), eq(), anyString() 등)를 쓰고, 나머지에는 직접 실제 값을 쓰는 게 허용되지 않는다.

// ❌ 이렇게 매처와 실제 값이 섞여 있으면 오류 발생!
when(someMock.someMethod(any(), "hello")).thenReturn(something);

Mockito는 내부적으로 매처를 사용하는 인자와 실제 값을 구분해서 처리한다. 매처가 쓰인 인자는 Mockito가 알아서 매칭 작업을 해주고실제 값이 쓰인 인자는 그냥 값 자체로 비교하는데, 이 둘이 섞이면 Mockito가 내부적으로 어떤 비교를 해야 할지 혼란이 생겨 오류가 발생하기 때문이다. 

 

✅ 올바른 방법

// 1. 모든 인자에 매처 사용
when(someMock.someMethod(any(), anyString())).thenReturn(something);

// 2. 모든 인자에 실제 값 사용
when(someMock.someMethod("foo", "hello")).thenReturn(something);

 

👇 테스트 예제

@Test
void 회원_생성_성공() {
    // given
    // 가상의 로그인 ID를 지정
    String loginId = "kqk1234";

    // [1] memberRepository.findByLoginId(loginId) 호출 시 Optional.empty()를 리턴하도록 설정
    // → 이미 존재하는 회원이 없는 상태라고 가정
    when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty());

    // [2] memberRepository.save(...) 메서드가 호출되면, 전달된 인자(Member 객체)를 그대로 리턴
    // → 실제 저장소가 없으므로, 저장된 결과처럼 동작하게 만듦
    // `any()`는 어떤 Member 객체든 상관없이 매칭되게 함 (모든 객체 허용하는 매처)
    when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));

    // [3] 테스트용 회원 생성 요청 DTO 준비
    MemberCreateDto dto = new MemberCreateDto(
            loginId,
            "dqwjfir!",               // 비밀번호
            "testMember",             // 이름
            LocalDate.now(),          // 생년월일
            "xuni1234@gmail.com",     // 이메일
            "010-1111-1111",          // 연락처
            EnterStatus.ENTER         // 입장 상태
    );

    // when
    // [4] 테스트 대상인 memberService.joinMember() 호출
    // → 내부에서 memberRepository.findByLoginId(), save() 등이 호출됨
    memberService.joinMember(dto);

    // then
    // [5] save()에 전달된 Member 객체를 캡처해서 검증
    // → 진짜 DB에 저장되진 않지만, 어떤 객체가 저장되었는지 확인 가능
    ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);

    // [6] save()가 호출되었고, 그때 어떤 Member가 들어갔는지 캡처함
    verify(memberRepository).save(captor.capture());

    // [7] 캡처된 Member 객체를 꺼냄
    Member savedMember = captor.getValue();

    // [8] 저장된 Member의 필드 값이 예상한 값인지 검증
    assertEquals("kqk1234", savedMember.getLoginId());
    assertEquals("testMember", savedMember.getUsername());

    // [9] save() 메서드가 실제로 호출되었는지도 한 번 더 검증
    verify(memberRepository).save(any());
}

 

'Spring' 카테고리의 다른 글

[Spring] 프로파일(profiles) 로딩 우선순위  (1) 2025.05.29
[Spring] BCryptPasswordEncoder  (0) 2025.04.17
[Spring] 스프링 빈과 스프링 컨테이너  (0) 2025.01.12
[Spring] 예외 처리 방식  (0) 2024.12.11
[Spring] Security JWT  (0) 2024.11.10