Mockito when().thenReturn()에서 eq()와 직접 변수 사용의 차이점 이해하기
📌 서론
테스트 코드로 단위 테스트를 작성하다보면 Mockito의 when().thenReturn() 메서드를 자주 사용하게 될 거다. 근데 이때 파라미터로 직접 변수를 사용한 적도 있고 eq() 메서드를 사용해서 파라미터를 세팅해 준 적도 많았다. 이 두 메서드의 차이점이 명확하지 않아서 에러를 발생시키고 이유도 알지 못한 채 에러를 고치고 그냥 넘어간 적도 있었다.
이 글에서는 이 두 접근 방식의 차이점을 명확하게 이해하고, 어떤 상황에서 각각을 사용하는 것이 적합한지를 함께 알아가보자.
테스트 코드를 작성할 실제 로직 코드
우리가 작성한 서비스 코드는 레시피 게시글에 있는 모든 댓글을 가져오는 로직이다.
간략하게 설명하자면, recipeId로 해당 레시피 글을 알아오고 그 레시피 글에 해당하는 댓글 목록을 페이징 처리해서 가져오는 프로세스다.
public PagingResponseDto<CommentListResponseDto> getCommentList(Long recipeId, int page, int size, String sortType) {
// 1. 정렬조건을 정한 뒤 Pageable 객체 생성
Pageable pageable = PageRequest.of(page, size);
// 2. 데이터를 받아온다.
Page<CommentListResponseDto> commentList = commentPort.getCommentList(recipeId, pageable, sortType);
// 3. 받아온 데이터를 꺼내서 응답 dto에 값을 세팅해준다.
List<CommentListResponseDto> content = commentList.getContent();
Long totalCount = commentList.getTotalElements();
return PagingResponseDto.of(content, totalCount);
}
테스트 코드 - 단위 테스트
우리 프로젝트에서 서비스 레이어는 '단위 테스트' 전략을 선택하고 있다.
🔻 그 이유가 궁금하다면 아래 글을 보고 오면 이해가 될 것 같다. 🔻
헥사고날 아키텍처와 TDD로 가는 길 (1) - 레이어 별 단위 테스트, 통합 테스트 전략 선택
다음은 서비스 코드를 테스트하는 단위 테스트 코드다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[단위] 댓글 서비스 테스트")
class CommentServiceTest {
@InjectMocks
private CommentService sut;
@Mock
private CommentPort commentPort;
@DisplayName("[happy] recipeId와 기본 페이징으로 댓글 목록을 정상적으로 가져온다.")
@Test
void whenGetCommentList_thenReturnsPagedComments() {
// given
Long recipeId = 1L;
int page = 0;
int size = 10;
String sortType = "new";
// 테스트에서 예상되는 반환 값. (여기서는 하나의 댓글 데이터만 포함)
List<CommentListResponseDto> mockCommentList = List.of(
CommentListResponseDto.of(1L, 1L, "nickname", "commentValue", "2022-11-22", false)
);
// PageImpl을 사용해 mockPage 객체 생성
Page<CommentListResponseDto> mockPage = new PageImpl<>(mockCommentList);
// mock 설정
when(commentPort.getCommentList(eq(recipeId), any(Pageable.class), eq(sortType)))
.thenReturn(mockPage);
// when
PagingResponseDto<CommentListResponseDto> result = sut.getCommentList(recipeId, page, size, sortType);
// then
assertThat(result.getContent()).hasSize(mockCommentList.size());
assertThat(result.getContent()).containsExactlyElementsOf(mockCommentList);
}
}
이렇게 테스트 코드를 작성하던 중
when(commentPort.getCommentList(eq(recipeId), any(Pageable.class), eq(sortType))).thenReturn(mockPage);
이 코드에 대해서 의문점이 생겼다.
when() 메서드 안에서 어떨 때는 직접 파라미터로 설정한 recipeId 변수를 넣어 줄때도 있고, 어떨때는 eq()를 사용해 파라미터를 설정해 준 적이 많았다.
대체 이 메서드들의 차이는 뭐고 eq(recipeId)를 넣어줬을 때랑 그냥 recipeId로 넣어줬을 때의 차이가 궁금해졌다.
우선 eq(recipeId)와 recipeId를 직접 사용하는 것의 차이를 이해하기 위해서는 Mockito의 인자 매처(argument matchers)와 일반 값 비교의 차이를 이해해야 한다.
Mockito 인자 매처 (Argument Matchers)
Mockito에서 인자 매처는 메서드 호출 시 기대하는 인자 값이나 인자의 타입을 지정할 때 사용된다. 대표적인 매처로는 eq, any, anyInt, anyString 등이 있다.
1. eq 메서드 사용
- eq()는 인자 매처 중 하나로, 주어진 인자가 명시된 값과 "동등한 지"를 확인한다.
- 이는 equals() 메서드를 사용하여 비교하는 것과 유사하다. 즉, eq(recipeId)는 호출된 메서드의 인자가 recipeId 객체와 equals() 메서드를 통해 동등한지 확인한다.
- Mockito의 인자 매처를 사용할 때는 모든 인자에 대해 매처를 사용해야 한다. 예를 들어, 하나의 인자에 eq()를 사용한다면 다른 인자들도 eq(), any(), anyInt() 등의 매처를 사용해야 한다.
2. eq 메서드 사용 X - 변수 직접 사용
- recipeId를 직접 사용하면, 해당 인자의 값이 recipeId와 정확히 같아야 한다.
- 이 경우에는 Mockito 내부적으로 == 연산을 사용하여 객체의 동일성을 확인한다.
- 이 방법은 인자 매처를 사용하지 않기 때문에, 다른 인자들에 대해서도 인자 매처를 사용하지 않아도 된다.
이 두 방법의 핵심 차이점은 "동등성"과 "동일성"의 비교 방법에 있다. eq()를 사용하는 경우, Mockito는 equals() 메서드를 사용하여 객체의 동등성을 비교한다. 반면, 직접 값을 사용하는 경우, Mockito는 == 연산을 통해 객체의 동일성, 즉 메모리 상의 같은 객체인지를 확인한다.
🌟 잠깐! 여전히 잘 이해가 되지 않을 수 있으니 더 쉬운 코드로 예시를 들어보자
1. eq() 사용하는 경우
다음은 메서드가 객체의 동등성에 기반하여 호출되어야 하는 경우다. 즉, 객체가 동일하지 않더라도 내용이 같으면 통과하는 서비스 메서드다.
public class SomeService {
public String process(Long id) {
// 서비스 로직
return "Result for ID: " + id;
}
}
위 서비스를 태스트하는 테스트 클래스는 다음과 같다.
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.eq;
public class SomeServiceTest {
@Test
public void testProcessWithEq() {
SomeService service = mock(SomeService.class);
Long expectedId = new Long(100);
// eq() 사용
when(service.process(eq(expectedId))).thenReturn("Mocked Result");
Long actualId = new Long(100); // expectedId와 내용은 같지만, 다른 객체
String result = service.process(actualId);
assertEquals("Mocked Result", result); // 테스트 통과
}
}
이 예시에서는 expectedId와 actualId가 내용은 같지만 서로 다른 객체다. eq()를 사용하여 Mockito가 내부적으로 equals() 메서드를 통해 두 객체가 동등한 지 확인한다. 결과적으로 process 메서드는 모의 객체에 의해 처리되고, 테스트는 통과한다.
2. 직접 변수 사용하는 경우
다음은 메서드가 객체의 동일성에 기반하여 호출되어야 하는 경우다. 즉, 정확히 같은 객체로만 호출해야 통과하는 서비스 메서드다.
(위 service와 동일한 SomeService 클래스를 사용한다.)
public class SomeService {
public String process(Long id) {
// 서비스 로직
return "Result for ID: " + id;
}
}
위 서비스를 태스트 하는 테스트 클래스는 다음과 같다.
import static org.mockito.Mockito.when;
public class SomeServiceTest {
@Test
public void testProcessWithDirectValue() {
SomeService service = mock(SomeService.class);
Long expectedId = new Long(100);
// 직접 변수 사용
when(service.process(expectedId)).thenReturn("Mocked Result");
Long actualId = new Long(100); // expectedId와 내용은 같지만, 다른 객체
String result = service.process(actualId);
// 테스트 실패 - Mockito는 expectedId 객체로만 호출되길 기대함
}
}
이 경우에는 expectedId와 actualId가 내용은 같지만 서로 다른 객체다. Mockito는 process 메서드가 expectedId 객체로만 호출되기를 기대한다. 따라서 actualId로 process 메서드를 호출하면, 이는 Mockito에 의해 모의된 동작과 일치하지 않으므로 테스트는 실패한다.
이러한 차이점은 테스트를 설계할 때 중요하다. 특정 메서드가 특정 객체와 정확히 같은 객체를 인자로 받아야 하는지, 아니면 단순히 동등한 값을 받으면 충분한지에 따라 사용하는 방법이 달라진다.
- eq()를 사용하면 내부적으로 equals()를 사용하여 객체의 동등성을 기반으로 동작을 모의한다.
- 직접 변수를 사용하면 Mockito는 해당 객체가 정확히 같은 객체로 호출되길 기대한다 (== 동일성 비교).
우리의 테스트 코드에서 eq() 사용과 직접 변수 사용
우리 테스트 코드에서 eq()를 사용하지 않고 직접 변수를 사용했을 때의 결과를 예상해 보자.
직접 변수를 사용하는 코드는 다음과 같다:
// Mockito 설정
when(commentPort.getCommentList(recipeId, any(Pageable.class), sortType)).thenReturn(mockPage);
// 테스트 실행
PagingResponseDto<CommentListResponseDto> result = sut.getCommentList(recipeId, page, size, sortType);
이 경우, commentPort.getCommentList() 메서드는 recipeId와 sortType에 대해 정확히 동일한 객체를 사용할 것으로 예상한다. 즉, == 연산자를 사용하여 객체의 동일성을 판단한다.
앗차,,, 테스트를 실행하니 다음과 같은 에러가 발생했다.
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Invalid use of argument matchers!
3 matchers expected, 1 recorded:
-> at com.recipia.recipe.application.service.CommentServiceTest.whenGetCommentList_thenReturnsPagedComments(CommentServiceTest.java:107)
This exception may occur if matchers are combined with raw values:
//incorrect:
someMethod(any(), "raw String");
When using matchers, all arguments have to be provided by matchers.
For example:
//correct:
someMethod(any(), eq("String by matcher"));
For more info see javadoc for Matchers class.
at com.recipia.recipe.application.service.CommentServiceTest.whenGetCommentList_thenReturnsPagedComments(CommentServiceTest.java:107)
위 에러 메시지는 Mockito의 인자 매처 사용과 관련된 문제를 정확히 지적하고 있다. Mockito에서는 한 번에 하나의 메서드 호출에 대해 모든 인자에 대해 인자 매처를 사용하거나, 모든 인자에 대해 직접 값을 사용해야 한다. 인자 매처와 직접 값을 혼용하는 것은 허용되지 않는다.
내가 작성한 코드 같은 경우, when(commentPort.getCommentList(recipeId, any(Pageable.class), sortType))... 구문에서 recipeId와 sortType에 직접 값을 사용하고, any(Pageable.class)에서는 인자 매처를 사용하고 있다. 이로 인해 Mockito는 예외를 발생시킨 것이었다.
문제를 해결하기 위해서는 모든 인자에 대해 직접 값을 제공해 보고 다시 테스트를 돌려보자.
when(commentPort.getCommentList(recipeId, PageRequest.of(page, size), sortType)).thenReturn(mockPage);
이렇게 수정하고 다시 테스트를 실행하니 성공했다.
🌟 흠,, 두 개 다 성공하니까 사실 큰 차이 없는 거 아닌가?라는 생각이 들었다.
사실, Mockito의 eq()와 직접 변수 사용이 둘 다 성공하는 이유는 우리의 서비스 코드가 이러한 테스트 시나리오에 잘 맞춰져 있기 때문이다.
우리의 서비스 로직에서 getCommentList 메서드는 recipeId, pageable, sortType 세 개의 파라미터를 받는다. 이 중 recipeId와 sortType은 특정 값을 기반으로 하여 비교할 수 있는 간단한 타입이며, Pageable 객체는 복잡한 타입이다.
우리의 테스트 시나리오에서 eq()와 직접 변수 사용이 모두 성공하는 이유는 서비스 로직이 특정 객체의 동일성보다는 값을 기반으로 하는 동작을 수행하기 때문이다. 즉, recipeId와 sortType에 대해서는 객체의 동일성(==)이나 동등성(equals())이 큰 차이를 만들지 않는다. 서비스 로직은 주어진 파라미터 값에 따라 동작하며, 이 값들이 어떤 특정 객체의 인스턴스인지는 중요하지 않다.
그러나 Pageable 객체 같은 복잡한 타입의 경우, Mockito 테스트에서 any() 같은 매처를 사용하는 것이 일반적이다. 왜냐하면, 이러한 객체는 테스트 시 특정한 상태나 구성을 가질 수 있고, 이를 직접 구현하여 테스트에 사용하기 복잡할 수 있기 때문이다.
결론적으로, 우리의 서비스 코드는 파라미터 값의 동일성보다는 동등성에 더 초점을 맞추고 있으며, 이는 Mockito 테스트에서 eq()와 직접변수 사용이 모두 성공할 수 있는 환경을 조성한다. 이는 테스트 코드가 실제 서비스 코드의 동작 방식과 잘 일치하고 있음을 보여준다.
🔥 결론
이 글을 통해 Mockito의 eq() 메서드 사용과 직접 변수 사용의 차이점을 탐구하였다. 주요 차이점은 객체의 '동등성'과 '동일성'의 비교 방법에 있다. eq()는 객체의 동등성을, 직접 변수 사용은 객체의 동일성을 기반으로 한다. 이러한 차이점을 이해하는 것은 단위 테스트를 설계할 때 중요하다.
eq()의 사용은 테스트에 더 많은 유연성을 부여한다. 이는 테스트 대상 메서드가 특정 객체와 정확히 동일한 객체를 인자로 받지 않아도 되는 경우에 유용하다. 반면, 직접 변수를 사용하는 방식은 메서드가 정확히 동일한 객체를 요구하는 경우에 적합하다.
이러한 이해는 테스트의 명확성과 정확성을 높이는 데 도움이 된다. 여러분들은 자신의 상황과 요구사항에 따라 적절한 방법을 선택할 수 있을 것이다. 단위 테스트는 소프트웨어의 안정성과 품질을 보장하는 중요한 과정이므로, 이러한 세부 사항에 주의를 기울이는 것이 중요하다.
마지막으로, Mockito의 인자 매처를 사용할 때는 일관성을 유지하는 것이 중요하다. 인자 매처를 사용하면 모든 인자에 대해 매처를 사용해야 하며, 이는 테스트 코드의 가독성과 유지보수성을 높이는 데 도움이 된다. 따라서, 테스트 설계 시에는 이러한 점을 고려하여 인자 매처의 사용을 결정하는 것이 좋다.
이 글이 Mockito를 사용한 단위 테스트를 더 효과적으로 작성하는 데 도움이 되기를 바란다.
테스트 격리성의 중요성과 모의 객체(Mock Object)를 활용한 전략
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring 테스트코드' 카테고리의 다른 글
스프링 이벤트 리스너 테스트를 위한 @SpyBean과 @MockBean의 활용 (0) | 2024.01.14 |
---|---|
테스트 격리성의 중요성과 모의 객체(Mock Object)를 활용한 전략 (3) | 2024.01.01 |
스프링 테스트 코드에서 실제 호출 @SpyBean으로 확인하기 (3) | 2023.12.31 |
스프링 테스트 코드: @MockBean과 @Mock의 차이 (+ @InjectMocks) (1) | 2023.12.31 |
테스트 코드의 가독성 향상 - BDD 방법론과 @DisplayName 어노테이션 활용 (2) | 2023.12.26 |