배경
소프트웨어 개발에서 리팩토링은 필수지만, 리팩토링으로 인해 기존 테스트 코드가 깨지는 경우가 많습니다. 최근 프로젝트를 리팩토링하다 비슷한 문제를 마주쳐서, 이번 시간에는 그 내용을 공유해보려 합니다.
저희 팀은 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;
// 다른 목 객체들...
}
테스트를 수정하는 몇 가지 접근법이 있습니다.
- 실제 객체 생성 후 리플렉션으로 주입 - 복잡하고 오류 발생 가능성 높음
- 직접 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가 이상적인 해결책인가?
- 최소한의 코드 변경: 기존 테스트 구조를 유지하면서
@Mock
을@Spy
로 바꾸고 인스턴스를 초기화하는 것만으로 해결됩니다. - MockitoExtension 유지: SpringExtension으로 전환하지 않고도 실제 객체 동작을 테스트에 포함시킬 수 있어 테스트 실행 속도가 빠릅니다.
- 유연성: 필요한 경우 특정 메서드만 모킹하여 테스트 시나리오를 조정할 수 있습니다.
// 필요한 경우 특정 메서드만 모킹 가능
when(scheduleValidator.someSpecificMethod(any())).thenReturn(expectedResult);
서비스 계층 테스트에서 Validator를 포함시켜야 할까?
마지막으로 앞서 언급했던 부분을 다시 되짚어 보겠습니다.
서비스 계층의 단위 테스트에서 실제 Validator 로직을 포함시키는게 좋을까요? 제 대답은 아닙니다.
단위 테스트의 목적에도 상반될 뿐더러 분리했을 때 비로소 각 테스트 클래스가 하나의 책임을 갖는다고 느껴지거든요. 이상적인 단위 테스트 관점에서는 서비스와 Validator를 분리하여 테스트하는 것이 더 바람직합니다:
- 관심사의 분리: 서비스 로직과 유효성 검증 로직은 별개의 책임으로, 각각 독립적으로 테스트되어야 합니다.
- 테스트 안정성: Validator를 모킹하면 Validator의 변경이 서비스 테스트에 영향을 미치지 않아 테스트가 더 안정적입니다.
- 본질에 집중: 서비스 계층 테스트는 비즈니스 로직에 집중할 수 있고, Validator 테스트는 유효성 검증 로직에 집중할 수 있습니다.
- 디버깅 용이성: 테스트 실패 시 문제가 서비스 로직에 있는지, Validator에 있는지 쉽게 파악할 수 있습니다.
결론
이번 시간에는 Mockito의 @Spy
가 리팩토링 후 테스트 코드를 우아하게 수정할 수 있는 강력한 도구임을 배웠습니다.
장기적으로는 단위 테스트의 기본 원칙에 따라 Validator를 Mock으로 처리하고 별도의 단위 테스트를 작성하는 것이 바람직합니다. 그러나 리팩토링 과정에서 @Spy
는 기존 테스트를 최소한의 변경으로 신속하게 수정할 수 있게 해주는 것도 맞습니다.
'개발' 카테고리의 다른 글
Github Actions, Jib와 캐싱을 통해 CI 최적화하기 (0) | 2025.03.21 |
---|---|
SQL에서 재귀 쿼리(CTE) 사용법 및 NOT IN vs NOT EXISTS 비교 (0) | 2025.03.10 |
속성(Property)과 필드(Field)의 차이 (0) | 2022.11.07 |
[모바일] Context 선택 (0) | 2022.11.07 |
댓글