Blog

ReflectionTestUtils로 단위 테스트의 엔티티 ID 설정하기

작성자 확인에서 NullPointerException이 발생했습니다

아래 코드는 질문 번호와 회원 번호를 받아 작성자가 같으면 질문을 삭제합니다.

Java
public void deleteById(Long questionId, Long memberId) {  
	Question question = findById(questionId);  
	if (validateQuestionWriter(memberId, question)) {  
		questionRepository.deleteById(questionId);  
	}  
}  
  
private Boolean validateQuestionWriter(Long memberId, Question question) {  
	return question.getMember().getId().equals(memberId);  
}

저장소를 모킹하고 삭제가 호출되는지 확인하는 테스트를 작성했습니다.

Java
@Test  
@DisplayName("성공한다.")  
void success() {  
	//given  
	Member member = getMember(getOauth2Entity("123", SocialProvider.KAKAO));  
	Question question = getQuestion(  
					member,  
					getMbtiEntity("ENTP"),  
					"질문 내용");  
	  
	Long mockQuestionId = 1L;  
	Long mockMemberId = 1L;  
	  
	given(questionRepository.findById(mockQuestionId))  
		.willReturn(Optional.of(question));  
	  
	//when  
	questionService.deleteById(mockQuestionId, mockMemberId);  
	  
	//then  
	verify(questionRepository, times(1)).deleteById(mockQuestionId);  
}


private Member getMember(Oauth2Entity oauth2Entity) {  
	Member member = Member.builder()  
					.oauth2Entity(oauth2Entity)  
					.build();
return member;  
}

실행 중 다음 오류가 발생했습니다.

java.lang.NullPointerException: Cannot invoke “java.lang.Long.equals(Object)” because the return value of “…Member.getId()” is null

Member의 빌더에는 ID를 받는 부분이 없었습니다. ID는 GenerationType.IDENTITY로 생성하도록 했고 이 테스트에서는 DB에 저장하지 않고 객체만 만들었습니다. 그래서 member.getId()가 null이었고 question.getId()도 같은 이유로 비어 있었습니다.

당시에는 “null과 null을 비교해서 NPE가 발생했다”고 설명했지만 인자로 넘긴 회원 번호는 1L입니다. member.getId()가 null인데 여기에 equals()를 호출해서 오류가 났습니다.

테스트 때문에 ID 변경 경로를 열고 싶지는 않았습니다

그렇다면 테스트에 필요한 ID는 어떻게 넣어야 할까요? ID용 setter를 추가하거나 빌더가 ID를 받도록 바꿀 수도 있습니다. 하지만 그러면 테스트에서만 쓰려던 경로를 다른 코드에서도 쓸 수 있게 됩니다. 기존 객체의 사용 방법은 그대로 두고 테스트에서만 값을 넣기 위해 ReflectionTestUtils를 사용했습니다.

Reflection을 쓰면 실행 중에 클래스의 필드나 메서드 같은 정보를 살펴보고 접근할 수 있습니다. 테스트에서는 Spring의 ReflectionTestUtils로 이 기능을 편하게 씁니다.

ID를 넣기 전후를 비교했습니다

Java
@Test  
@DisplayName("성공한다.")  
void success() {  
	//given  
	Member member = getMember(getOauth2Entity("123", SocialProvider.KAKAO));  
	Question question = getQuestion(
				member,  
				getMbtiEntity("ENTP"),  
				"질문 내용");  
	  
	Long mockQuestionId = 1L;  
	Long mockMemberId = 1L;  

	//전
	System.out.println("question.getId() = " + question.getId());  
	System.out.println("question.getMember().getId() = " + question.getMember().getId());  
	System.out.println("member.getId() = " + member.getId());  
	
	ReflectionTestUtils.setField(member, "id", mockMemberId);  
	ReflectionTestUtils.setField(question, "id", mockQuestionId);  

	//후
	System.out.println("question.getId() = " + question.getId());  
	System.out.println("question.getMember().getId() = " + question.getMember().getId());  
	System.out.println("member.getId() = " + member.getId());
	given(questionRepository.findById(mockQuestionId))  
		.willReturn(Optional.of(question));  
	  
	//when  
	questionService.deleteById(mockQuestionId, mockMemberId);  
	  
	//then  
	verify(questionRepository, times(1)).deleteById(mockQuestionId);  
}

출력은 다음과 같았습니다.

question.getId() = null
question.getMember().getId() = null
member.getId() = null
question.getId() = 1
question.getMember().getId() = 1
member.getId() = 1

Question이 참조하는 회원도 같은 객체이므로 member에 넣은 ID가 question.getMember().getId()에서도 보입니다. 이 방식으로 DB 저장 없이 작성자 비교에 필요한 값을 마련했습니다.

record에는 같은 방법을 적용하지 않습니다

당시 DTO로 사용하던 record의 컴포넌트는 생성 이후 같은 방식으로 바꿀 수 없었습니다. record는 생성할 때 필요한 값을 전달해서 테스트 데이터를 준비해야 합니다.

record는 Java 14에서 미리보기 기능으로 등장했고 Java 16에서 정식 도입됐습니다. record의 필드가 final이어도 내부에서 참조하는 객체까지 모두 불변인 것은 아닙니다. 도입 시점은 JEP 395에서, record 필드 쓰기 제한은 Field.set API에서 확인할 수 있습니다.