Service 단위 테스트와 Mocking 이슈들 (feat. RestTemplate)

qpwoeiru·2024년 8월 24일
0

이번엔 내가 API를 추가하면서 테스트 코드를 작성할 때 너무 헷갈렸던 부분들을 정리해보고자 한다. RestTemplate을 테스트 코드에서 Mocking 할 때의 이야기이다.

현재 진행하고 있는 프로젝트에서 백준 닉네임이 유효한지 검증하는 API를 추가하는데 RestTemplate을 사용했다. 이 API의 Service 단위 테스트 코드를 작성하면서 직면했던 두 개의 문제들이 있었다.

  1. RestTemplate을 Service 메서드 내부에서 생성했을 때 Mocking 동작이 안된다
  2. RestTemplate의 동작을 지정하지 않았는데 테스트가 성공한다 (when()을 쓰지 않은 경우)

1. RestTemplate을 Service 메서드 내부에서 생성했을 때 Mocking 동작이 안된다

사실 이건 지금 생각해보면 너무 당연하다. 그래도 다음 번엔 다신 바보같지 않기 위해..
내가 처음 설계했던 Service 메서드는 아래와 같았다.

@Transactional(readOnly = true)
public void checkBjNickname(String bjNickname) {
		String bjUserUrl = "https://www.acmicpc.net/user/" + bjNickname;
		![](https://velog.velcdn.com/images/o_z/post/19ad1644-f01e-4925-a580-411aac665aa1/image.png)

		HttpHeaders headers = new HttpHeaders();
		headers.set("User-Agent",
			"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
		HttpEntity<String> entity = new HttpEntity<>(headers);
		RestTemplate restTemplate = new RestTemplate();
		try {
			restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class);
			if (userRepository.existsByBjNickname(bjNickname))
				throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), "이미 가입된 백준 닉네임 입니다.");
		} catch (HttpClientErrorException e) {
			if (e.getStatusCode() == HttpStatus.NOT_FOUND)
				throw new CheckBjNicknameValidationException(HttpStatus.NOT_FOUND.value(), "백준 닉네임이 유효하지 않습니다.");
		} catch (HttpServerErrorException e) {
			log.info("BOJ server error occurred : " + e.getMessage());
			throw new BOJServerErrorException("현재 백준 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
		}
		log.info("success to check baekjoon nickname validity");
}

RestTemplate을 메서드 내부에서 new로 생성해 사용했다. 이때까진 DI 고려를 안했어서 그냥 생성해 사용해도 되겠다고 생각했다.
이렇게 작성한 후 테스트 코드들 중 하나는 아래와 같았다.

@Test
@DisplayName("백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임")
void checkBjNickname_2() {
		// given
		String bjNickname = "bjNickname";
		when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
			.thenReturn(new ResponseEntity<>(HttpStatus.OK));
		when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
		// when, then
		assertThatThrownBy(() -> userService.checkBjNickname(bjNickname))
			.isInstanceOf(CheckBjNicknameValidationException.class)
			.hasFieldOrPropertyWithValue("code", HttpStatus.CONFLICT.value())
			.hasFieldOrPropertyWithValue("error", "이미 가입된 백준 닉네임 입니다.");
}

테스트를 수행하면 계속 결과가 아래와 같았다. 409가 발생해야 하는데 404..? NOT FOUND면 백준 닉네임이 유효하지 않는다는 예외가 발생하는 건데..
테스트 코드에서 bjNickname을 내 실제 백준 닉네임으로도 수정해서 테스트를 돌려봤다.
??? 이번엔 RestTemplate에 대해 when()으로 동작을 설정한 부분이 필요 없는 stubbing이라고 한다.
닉네임에 따라 결과가 달라진다..? RestTemplate으로 API 호출이 실제로 일어나고 있단 것이다. 분명 난 @Mock으로 RestTemplate도 등록했는데 왜 사용이 안될까? 그리고 when()으로 등록한 동작도 왜 필요 없는 stubbing일까?

사실 조금만 생각해보면 당연한 거였다.. Service 메서드 내에서 RestTemplate을 생성했기 때문에 애초에 Mock을 안해도 되는 것이다. 그러기에 실제로 RestTemplate을 통해 API 호출이 발생한 것이고..

(다시 이 때의 테스트 코드를 보면 외부 API 호출에 의해 테스트 코드 성공/실패 여부가 갈리는 게 정말 의존성을 분리한 단위 테스트라고 볼 수 있을지 의문이다. 아마 다른 방법이 있었을 것 같은데 결과적으로는 RestTemplate을 메서드 내에서 생성하지 않게 되어 이 부분은 더 찾아보지 못했다.)


2. RestTemplate의 동작을 지정하지 않았는데 테스트가 성공한다 (when()을 쓰지 않은 경우)

1번 문제를 한 번 겪은 후, 이대로 PR을 올리려 했었는데 다른 코드들을 한 번 검토해보다가 RestTemplate을 사용하는 API를 하나 더 발견했다. 거기서도 RestTemplate을 기본으로만 사용하고 있었는데, 여러 곳에서 사용하고 있기도 하고 timeout을 모든 RestTemplate에 일괄적으로 적용하도록 만들고 싶어서 RestTemplateConfig를 따로 등록해서 DI로 사용하고자 설계를 변경했다.

그래서 UserService 코드가 아래처럼 변경되었다. 변경된 부분이라곤 UserService에서 RestTemplate을 DI 해주고 메서드 내에서 RestTemplate을 생성한 코드를 삭제한 것 밖에 없다.

...
public class UserService{
	// DI로 RestTemplate 주입
	private final RestTemplate restTemplate;
	...
	@Transactional(readOnly = true)
	public void checkBjNickname(String bjNickname) {
		String bjUserUrl = "https://www.acmicpc.net/user/" + bjNickname;
		
		HttpHeaders headers = new HttpHeaders();
		headers.set("User-Agent",
			"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
		HttpEntity<String> entity = new HttpEntity<>(headers);
		// 내부에서 RestTemplate 생성 부분 없음
		try {
			restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class);
			if (userRepository.existsByBjNickname(bjNickname))
				throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), "이미 가입된 백준 닉네임 입니다.");
		} catch (HttpClientErrorException e) {
			if (e.getStatusCode() == HttpStatus.NOT_FOUND)
				throw new CheckBjNicknameValidationException(HttpStatus.NOT_FOUND.value(), "백준 닉네임이 유효하지 않습니다.");
		} catch (HttpServerErrorException e) {
			log.info("BOJ server error occurred : " + e.getMessage());
			throw new BOJServerErrorException("현재 백준 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
		}
		log.info("success to check baekjoon nickname validity");
	}

이렇게 했을 때 이번엔 정말 Mocking과 동작 지정이 필요하다고 생각해 테스트 코드도 아까와 똑같이 작성했다.

@Test
@DisplayName("백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임")
void checkBjNickname_2() {
		// given
		String bjNickname = "bjNickname";
		when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
			.thenReturn(new ResponseEntity<>(HttpStatus.OK));
		when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
		// when, then
		assertThatThrownBy(() -> userService.checkBjNickname(bjNickname))
			.isInstanceOf(CheckBjNicknameValidationException.class)
			.hasFieldOrPropertyWithValue("code", HttpStatus.CONFLICT.value())
			.hasFieldOrPropertyWithValue("error", "이미 가입된 백준 닉네임 입니다.");
}

테스트는 성공했다.
그런데 여기서 when()의 동작을 없앴을 때도 테스트 코드가 성공한다.

@Test
@DisplayName("백준 닉네임 유효성 검증 : 이미 가입된 백준 닉네임")
void checkBjNickname_2() {
		// given
		String bjNickname = "bjNickname";
		// when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)))
		//	.thenReturn(new ResponseEntity<>(HttpStatus.OK));
		when(userRepository.existsByBjNickname(bjNickname)).thenReturn(true);
		// when, then
		assertThatThrownBy(() -> userService.checkBjNickname(bjNickname))
			.isInstanceOf(CheckBjNicknameValidationException.class)
			.hasFieldOrPropertyWithValue("code", HttpStatus.CONFLICT.value())
			.hasFieldOrPropertyWithValue("error", "이미 가입된 백준 닉네임 입니다.");
}

이유가 뭘까? RestTemplate도 Mocking하고 동작도 설정해야 할텐데..?
한참을 고민하고 찾아봤는데 Mocking의 기본 개념에서 답이 있었다.

Mocking한 객체의 동작을 when()으로 따로 설정해주지 않으면 해당 객체에 대한 메서드 호출 결과가 default로 null이다. 따라서 when()으로 동작을 지정해주지 않으면 restTemplate.exchange()의 결과는 null이 된다.

...
try {
	restTemplate.exchange(bjUserUrl, HttpMethod.GET, entity, String.class); // return null
    // 위에서 Exception 발생 안함 => 백준 닉네임은 유효하다고 판단
	if (userRepository.existsByBjNickname(bjNickname))
		throw new CheckBjNicknameValidationException(HttpStatus.CONFLICT.value(), "이미 가입된 백준 닉네임 입니다.");
} 
...

이 과정에서 return null이 HttpClientErrorException,HttpServerErrorException를 throw 하지 않기에 백준 닉네임이 유효하다고 판단하고 try 내 로직이 계속 수행되는 것이다.

사실 테스트 코드를 통과하는 게 목적이라면 when()을 작성하지 않아도 될테지만, 테스트 코드는 동작을 명확히 하고 API 안정성을 높이는 것이 우선이라고 생각한다. 그래서 나는 when()으로 restTemplate.exchange()의 결과로 ResponseEntity<>(HttpStatus.OK)를 지정해 명확히 하기로 했다.


RestTemplate 사용해서 외부 API 호출하는 것까진 별 어려움 없이 진행했는데, 테스트 코드를 작성하면서 생각보다 꽤 멈춰 있던 것 같다. 글은 RestTemplate의 테스트 코드를 다뤘지만 이번 고민의 지식 핵심은 모두 Mocking이었던 것 같다. 좀 익숙해졌다 생각했는데 아직 Mocking에 대한 지식이 제대로 자리 잡지 못했다는 것도 깨달았고 다시 한 번 개념을 명확히 하는 데에 좋은 기회가 된 것 같다.

0개의 댓글

관련 채용 정보