Mockito 사용 중 @Spy로 실제 객체 동작 활용하기

    배경

    소프트웨어 개발에서 리팩토링은 필수지만, 리팩토링으로 인해 기존 테스트 코드가 깨지는 경우가 많습니다. 최근 프로젝트를 리팩토링하다 비슷한 문제를 마주쳐서, 이번 시간에는 그 내용을 공유해보려 합니다.

    저희 팀은 ScheduleService 클래스에서 유효성 검증 로직을 분리하여 별도의 ScheduleCreateUpdateValidator 클래스로 추출하는 리팩토링을 진행했습니다. 이 과정에서 기존 테스트 코드가 실패하게 되었고, 해결 과정에서 Mockito의 @Spy를 사용하며 편리함을 깨달았습니다.

     

    리팩토링 전후 코드 비교

    리팩토링 전 (ScheduleService 클래스 내부에 유효성 검증 로직 포함):

    @Service
    @RequiredArgsConstructor
    public class ScheduleService {
        // 다른 의존성들...
    
        @Transactional
        public void createSchedule(ScheduleCreateUpdateRequest request, Long projectId, UserDetails userDetails) {
            Project project = projectRepository.findById(projectId)
                    .orElseThrow(() -> new NotFoundElementException(ExceptionMessage.PROJECT_NOT_FOUND));
            validateScheduleCreateUpdateRequest(request, project); // 내부 메서드로 유효성 검증
    
            // 이후 로직...
        }
    
        // 유효성 검증 메서드들...
        private void validateScheduleCreateUpdateRequest(ScheduleCreateUpdateRequest req, Project project) { /*...*/ }
        private void validateScheduleDayRequest(ScheduleDayRequest req, Project project) { /*...*/ }
        // 기타 여러 검증 메서드들
    }

    리팩토링 후 (유효성 검증 로직 분리):

    @Service
    @RequiredArgsConstructor
    public class ScheduleService {
        // 다른 의존성들...
        private final ScheduleCreateUpdateValidator scheduleValidator;
    
        @Transactional
        public void createSchedule(ScheduleCreateUpdateRequest request, Long projectId, UserDetails userDetails) {
            Project project = projectRepository.findById(projectId)
                    .orElseThrow(() -> new NotFoundElementException(ExceptionMessage.PROJECT_NOT_FOUND));
            scheduleValidator.validate(request, project); // 외부 클래스로 유효성 검증 위임
    
            // 이후 로직...
        }
    }
    @Component
    public class ScheduleCreateUpdateValidator {
        public void validate(ScheduleCreateUpdateRequest req, Project project) {
            // 분리된 유효성 검증 로직
        }
    
        // 기타 여러 검증 메서드들
    }

     

    깨진 테스트와 해결 접근법

    리팩토링 전에는 유효성 검증 로직이 ScheduleService 내부에 있었기 때문에 모킹 없이도 테스트가 가능했지만, 리팩토링 후에는 외부 의존성이 되어 모킹이 필요한 상황이 되었습니다. (넣지않으면 NPE가 발생합니다.)

    처음에는 scheduleValidator@Mock으로 사용하려고했습니다. 그러다 기존 테스트 코드가 validate를 함께 테스트하고 있다는 걸 알게됐죠. @Mock으로는 실제 validation 동작을 할 수 없어 기존 테스트가 깨집니다.

    어떻게하면 테스트를 다시 성공시킬 수 있을까요? (원래는 Validator 테스트를 분리하는게 좋긴 합니다만,, 일단 변경도중이었고, 설명을 위해)

    @ExtendWith(MockitoExtension.class)
    class ScheduleServiceTest {
        @InjectMocks
        private ScheduleService scheduleService;
    
        @Mock // 이 부분이 문제!
        private ScheduleCreateUpdateValidator scheduleValidator;
    
        // 다른 목 객체들...
    }

    테스트를 수정하는 몇 가지 접근법이 있습니다.

    1. 실제 객체 생성 후 리플렉션으로 주입 - 복잡하고 오류 발생 가능성 높음
    2. 직접 ScheduleService 인스턴스 생성 - 모든 의존성을 수동으로 주입해야 함

    그런데 코드가 다소 난잡해집니다. 더 좋은 방법은 없을까요?

     

    최적의 해결책: Mockito의 @Spy 활용

    가장 우아한 해결책은 Mockito의 @Spy를 사용하는 것이었습니다:

    @ExtendWith(MockitoExtension.class)
    class ScheduleServiceTest {
        @InjectMocks
        private ScheduleService scheduleService;
    
        @Spy // Mock 대신 Spy 사용
        private ScheduleCreateUpdateValidator scheduleValidator = new ScheduleCreateUpdateValidator();
    
        // 다른 목 객체들...
    }

     

    Mock vs Spy: 핵심 차이점

    • Mock: 기본적으로 '아무것도 하지 않음'이 기본 동작입니다. 모든 메서드 호출은 null 또는 기본값을 반환하며, 명시적으로 행동을 정의해야 합니다.
    • Spy: 실제 객체를 '감시'하며, 기본적으로 실제 메서드가 호출됩니다. 특정 메서드만 선택적으로 오버라이드할 수 있습니다.

     

    왜 @Spy가 이상적인 해결책인가?

    1. 최소한의 코드 변경: 기존 테스트 구조를 유지하면서 @Mock@Spy로 바꾸고 인스턴스를 초기화하는 것만으로 해결됩니다.
    2. MockitoExtension 유지: SpringExtension으로 전환하지 않고도 실제 객체 동작을 테스트에 포함시킬 수 있어 테스트 실행 속도가 빠릅니다.
    3. 유연성: 필요한 경우 특정 메서드만 모킹하여 테스트 시나리오를 조정할 수 있습니다.
    // 필요한 경우 특정 메서드만 모킹 가능
    when(scheduleValidator.someSpecificMethod(any())).thenReturn(expectedResult);

     

    서비스 계층 테스트에서 Validator를 포함시켜야 할까?

    마지막으로 앞서 언급했던 부분을 다시 되짚어 보겠습니다.

    서비스 계층의 단위 테스트에서 실제 Validator 로직을 포함시키는게 좋을까요? 제 대답은 아닙니다.

    단위 테스트의 목적에도 상반될 뿐더러 분리했을 때 비로소 각 테스트 클래스가 하나의 책임을 갖는다고 느껴지거든요. 이상적인 단위 테스트 관점에서는 서비스와 Validator를 분리하여 테스트하는 것이 더 바람직합니다:

    1. 관심사의 분리: 서비스 로직과 유효성 검증 로직은 별개의 책임으로, 각각 독립적으로 테스트되어야 합니다.
    2. 테스트 안정성: Validator를 모킹하면 Validator의 변경이 서비스 테스트에 영향을 미치지 않아 테스트가 더 안정적입니다.
    3. 본질에 집중: 서비스 계층 테스트는 비즈니스 로직에 집중할 수 있고, Validator 테스트는 유효성 검증 로직에 집중할 수 있습니다.
    4. 디버깅 용이성: 테스트 실패 시 문제가 서비스 로직에 있는지, Validator에 있는지 쉽게 파악할 수 있습니다.

     

    결론

    이번 시간에는 Mockito의 @Spy가 리팩토링 후 테스트 코드를 우아하게 수정할 수 있는 강력한 도구임을 배웠습니다.

    장기적으로는 단위 테스트의 기본 원칙에 따라 Validator를 Mock으로 처리하고 별도의 단위 테스트를 작성하는 것이 바람직합니다. 그러나 리팩토링 과정에서 @Spy는 기존 테스트를 최소한의 변경으로 신속하게 수정할 수 있게 해주는 것도 맞습니다.

    댓글