개요
테스트하려는 메서드에서 다른 메서드를 호출하는 코드가 있었다. 그런데 이 메서드가 테스트를 하기 어렵게 구현되어 있어 실제 테스트 대상에 Mocking을 하려고 했으나 NullPointerException이 발생했다. 이는 Stubbing이 제대로 동작하지 않고, 실제 메서드가 호출됐기 때문이었다.
테스트 코드 - 에러 발생
@Test
@DisplayName("유저의 닉네임을 수정하는 데 성공한다")
void testSetUserNickname() {
// given
UserSaveRequest req = createAddUserRequest();
Long userNo = 1L;
String defaultPicUrl = "https://test/img.png";
UserEntity userEntity = createUserEntity(
req,
userNo,
defaultPicUrl);
String newNickname = "루드 굴리트";
given(userService.getCurrentUserNo()).willReturn(userNo); // 에러발생!! - Stubbing 작동 X
given(userRepository.findById(userNo)).willReturn(Optional.ofNullable(userEntity));
// when
userService.setUserNickname(userNo, newNickname);
// then
assert userEntity != null;
assertThat(userEntity.getNickname()).isEqualTo(newNickname);
assertThat(newNickname).isEqualTo(result);
}
코드 상세
테스트하려는 메서드 - setUserNickname
@Transactional
public String setUserNickname(Long userNo, String nickname) {
checkUserAuth(userNo); // 여기서 내부 메서드 호출
UserEntity userEntity = userRepository.findById(userNo)
.orElseThrow(() -> new ResourceNotFoundException("존재하지 않는 유저입니다."));
userEntity.updateNickname(nickname);
return nickname;
}
private void checkUserAuth(Long userNo) {
if (userNo.compareTo(getCurrentUserNo()) != 0) { // getCurrentUserNo()를 호출한다
throw new RequestForbiddenException();
}
}
- 권한을 검증하는 과정에서 getCurrentUserNo를 호출한다
호출되는 메서드 - getCurrentNo
public Long getCurrentUserNo() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl principal = (UserDetailsImpl) authentication.getPrincipal(); // NPE 발생!!
UserEntity userEntity = userRepository.findById(principal.getUserNo())
.orElseThrow(() -> new ResourceNotFoundException("현재 인증된 유저가 존재하지 않습니다."));
return userEntity.getNo();
}
- 내부 구현상 SecurityContextHolder의 정적메서드를 호출하는 코드가 포함되어 있다
- 정적메서드를 호출하기 때문에 매개변수로 참조변수를 넘기는 것은 다소 어색했다(IDE에서도 실제로 경고를 발생시킨다)
해결
1) @Spy 어노테이션 붙이기
애초에 @Mock과 @InjectMocks를 정확하게 이해하지 못한 채 사용했던 것 같다. @Mock은 mock객체를 만드는 반면, @InjectMocks는 실제 인스턴스를 생성하고, @Mock이나 @Spy이 붙은 객체를 주입받는다.
@Spy를 붙이면 기본적으로 원래 객체의 동작대로 행동하되, 지정한 동작에 대해 Stubbing이 가능하다. 일반적인 mock이 모든 동작에 대해 Stubbing을 해주는 것과 차이가 있다.
따라서 테스트 대상에 Mocking을 할 경우 선언부에서 @Spy와 @InjectMocks를 테스트 대상에 붙여주면 된다.
@Spy
@InjectMocks
private UserService userService;
2) BDDMockito 대신 Mockito 사용하기
또한, Stubbing 시에 가독성을 위해 BDDMockito를 사용하고 있었는데 왠지 모르겠지만 해당 경우에 동작을 안해서 Mockito를 사용해야 했다(이거는 이유를 알게되면 추가할 예정).
doReturn(userNo).when(userService).getCurrentUserNo(); // BDDMockito 대신 Mockito 사용
given(userRepository.findById(userNo)).willReturn(Optional.ofNullable(userEntity));
3) 수정한 전체 코드
@Test
@DisplayName("유저의 닉네임을 수정하는 데 성공한다")
void testSetUserNickname() {
// given
UserSaveRequest req = createAddUserRequest();
Long userNo = 1L;
String defaultPicUrl = "https://test/img.png";
UserEntity userEntity = createUserEntity(
req,
userNo,
defaultPicUrl);
String newNickname = "루드 굴리트";
doReturn(userNo).when(userService).getCurrentUserNo();
given(userRepository.findById(userNo)).willReturn(Optional.ofNullable(userEntity));
// when
userService.setUserNickname(userNo, newNickname);
// then
assert userEntity != null;
assertThat(userEntity.getNickname()).isEqualTo(newNickname);
assertThat(newNickname).isEqualTo(result);
}
정상적으로 테스트가 돌아가는 것을 확인할 수 있다.
4) 결론
이는 단편적인 해결방안인 것 같다. 처음부터 테스트가 쉽게 구현하는 게 중요하다. 위 코드와 같은 경우엔 Authentication 객체를 인수로 넘겨받는 형태로 코드를 작성했다면 더 좋은 코드가 되었을 것 같다(물론 레거시 코드라면 어쩔 수 없지만...)
참고
'Spring > Spring' 카테고리의 다른 글
[Spring] 트랜잭션 동기화 정리 (0) | 2022.08.13 |
---|---|
[Spring] 트랜잭션 추상화 정리 (0) | 2022.08.08 |
[Spring] 스프링 DataSource 간단하게 정리 (0) | 2022.08.08 |
[Spring] Mockito private method invocation 테스트하기 (0) | 2022.07.17 |
[Spring] 트랜잭션 AOP 동작 흐름 (0) | 2022.07.06 |