DevYGwan
Mocking 시 예상대로 동작하지 않는다면, 인스턴스 동등성 문제를 의심해라 본문
Test 코드를 작성하면서, Controller로직 테스트 시 Mocking처리를 한 경험은 자주 있을 것입니다. Mocking을 처리하면서, 특정 메서드가 호출될 때 원하는 동작을 지정할 수 있게 하기 위해 when() 메서드를 사용할때 발생한 문제에 대해서 공유하고 이를 해결하기 위한 여러가지 방법에 대해서 설명드리려고 합니다.
간단하게 제가 겪은 문제는 인스턴스 간의 "동등성" 문제로 인해, when()에서 사용한 메서드의 파라미터로 들어온 dto 클래스를 같은 클래스라고 인식하지 못해 when()이 제대로 동작하지 않아 생기는 문제입니다. 좀 더 문제가 된 상황을 자세히 설명드리겠습니다.
문제 상황
저는 회원가입에 관한 controller로직 테스트를 진행하려고 했습니다. 테스트 코드는 다음과 같습니다.
@DisplayName("회원가입 성공 시 회원 정보를 반환한다.")
@Test
void signUpTest1() throws Exception {
//given
var member = new Member(MEMBER_ID, MEMBER_EMAIL, MEMBER_PASSWORD);
var signUpRequest = new SignUpRequest(MEMBER_EMAIL, MEMBER_PASSWORD);
//when
when(memberService.signUp(signUpRequest))
.thenReturn(member);
//then
mockMvc.perform(
post("/api/v1/members/signUp")
.content(objectMapper.writeValueAsString(signUpRequest))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(MEMBER_ID))
.andExpect(jsonPath("$.email").value(MEMBER_EMAIL));
}
흔히 볼 수 있는 아주 간단한 테스트입니다. 이에 대한 테스트를 진행했는데, 계속해서 아래와 같은 에러가 발생했습니다.
java.lang.AssertionError: No value at JSON path "$.id"
그래서 이에 대한 원인을 고민해보았습니다.
1. json 형식의 문제?
그래서 이러한 에러를 검색해보니 json형식이 달라서 생기는 문제라는 내용이 많아 실제 서버를 실행하고 postman으로 해당 api을 테스트해보았습니다.
하지만, 보시는 바와 같이 제대로 동작하고, json 형식 또한 제가 예상한 바와 같은 것을 확인할 수 있었습니다. 그래서 이 문제는 아닌 것을 확인할 수 있었습니다
2. given()이 제대로 동작을 안하나?
다음으로 제가 예상한 문제는 코드에서 제가 선언한 given()이 제대로 동작을 하지 않는가? 였습니다. 위의 코드에서 제가
//given
var member = new Member(MEMBER_ID, MEMBER_EMAIL, MEMBER_PASSWORD);
var signUpRequest = new SignUpRequest(MEMBER_EMAIL, MEMBER_PASSWORD);
//when
when(memberService.signUp(signUpRequest))
.thenReturn(member);
memberService.signUp() 메서드의 매개변수로 위에서 선언한 signUpRequest가 들어오면, member를 반환하도록 given을 통해 미리 정의했습니다. 이 작업이 제대로 동작하는지에 대한 테스트를 진행했습니다.
테스트를 진행하기 위해 테스트 코드 실행 시, controller에서 signUp 메서드의 반환값인 member를 출력해보기로 했습니다. 제가 예상하기론 when()메서드를 통해 작업에 대한 동작에 대한 결과를 정의했기 때문에 제가 원하는 member에 대한 주소값이 나올 것이라고 예상했습니다.
하지만 반환 값은 null이 발생했습니다.
그래서 아 given이 제대로 동작하지 않는구나를 확인했습니다. 그렇다면 분명 코드로 작성한 given이 왜 제대로 동작하지 않는 것인지 확인해보도록 하겠습니다.
Given이 동작 안하는 이유
given이 동작하지 않는 이유가 뭘까 곰곰히 생각해봤습니다. 제가 예상한 원인은
mockMvc에서 .content(objectMapper.writeValueAsString(signUpRequest)) 로 이렇게 처리했기 때문에 해당 로직에 들어가는 memberService의 signUp() 메서드의 매개변수로 들어가는 SignUpRequest가 제가 위에서 선언한 signUpRequest와 다르기 때문에 발생하는 문제라고 생각했습니다.
그래서 이를 확인하기 위해 제가 테스트 시 선언한 SignUpRequest 인스턴스의 주소값과, controller에서 사용된 SignUpRequest 인스턴스의 주소값을 비교해봤습니다.
테스트 결과 두개의 주소가 다른 것을 확인할 수 있었습니다. 이로 인해 given()이 제대로 동작하지 않는 것임을 확인할 수 있었습니다. 제대로 동작하지 않은 이유는 위쪽의 mocking 로직은 특정 인스턴스(위에서 선언한 signUpRequest) 인스턴스가 들어왔을때만 동작하는 mocking 처리이기 때문에 mockMvc에서 직렬화/역직렬화 과정을 거쳐 다시 만들어진 인스턴스는 이전에 만들었던 signUpRequest와 다른 인스턴스이기 때문에 제대로 동작하지 않습니다.
정리하자면 , 객체(인스턴스)의 동등성 때문입니다. 기본적으로 객체의 동등성은 메모리 주소가 같음을 의미하는 것입니다. (동일성과 같음) 따라서 위의 두 객체는 메모리 주소값이 다르기 때문에 다른 것으로 인식해 given()이 제대로 동작하지 않는 것입니다.
기본적으로 객체의 동등성을 비교할 수 있는 equals()의 경우 == 인 동일성 로직을 통해 동작한다.
그렇다면 이를 해결할 수 있는 방법은 어떤 것이 있는지 확인해보도록 하겠습니다.
해결 방법 1 : ArgumentMatchers.any() 사용
첫번째 해결방법으로는, given()절에 ArgumentMatchers.any()를 사용하는 것입니다. ArgumentMatchers.any()란 메서드 호출 시 인자로 전달되는 값을 특정하지 않고, 어떤 값이든 허용하고자 할 때 사용합니다. 이를 적용한 코드는 아래와 같습니다.
//given
var member = new Member(MEMBER_ID, MEMBER_EMAIL, MEMBER_PASSWORD);
//when
when(memberService.signUp(ArgumentMatchers.any(SignUpRequest.class)))
.thenReturn(member);
해결 방법 2 : 객체의 동등성 비교 로직 변경 : equals(), hashCode() 오버라이딩
두번째 해결방법으로는 기본적인 객체의 동등성 비교를 주소로 하는 것이 아닌 equals()와 hashCode()를 정의하여 객체 안의 값이 같다면 같은 객체로 인식하도록 처리하여 해결할 수 있습니다. 테스트 코드는 원래 테스트 코드와 같고 SignUpRequest 클래스에 equals()와 hashCode()를 재정의합니다. 이를 적용한 코드는 아래와 같습니다.
@Getter
@NoArgsConstructor
public class SignUpRequest {
@Email
@NotBlank
private String email;
@NotBlank
private String password;
public SignUpRequest(String email, String password) {
this.email = email;
this.password = password;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SignUpRequest that = (SignUpRequest) o;
return Objects.equals(email, that.email) && Objects.equals(password, that.password);
}
@Override
public int hashCode() {
return Objects.hash(email, password);
}
}
- equals() 메서드는 객체의 내용이 같은지 비교하는 데 사용됩니다.
- hashCode() 메서드는 객체의 해시 코드를 반환합니다.
다시 한번 request값의 주소를 출력해보면 위에 출력했던 것과 다르게 동일한 주소값을 출력하는 것을 확인할 수 있습니다.
해결 방법 3 : dto클래스를 Record 클래스로 변경
마지막 해결 방법은 dto 클래스를 Record 클래스로 변경하는 방법이 있습니다. 먼저 간단히 해결을 위한 코드를 보여드리고, Record 클래스에 대해서 설명하도록 하겠습니다. 테스트 코드는 원래 테스트 코드와 같고 SignUpRequest 클래스를 기본적인 Class로 생성하는 것이 아닌 Record 클래스로 변경합니다. 이를 적용한 코드는 아래와 같습니다.
public record SignUpRequest(
String email,
String password) {
// 컴팩트 생성자 부분
public SignUpRequest {
validate();
// 자동 생성됨
// this.email = email;
// this.password = password;
}
}
※ Record Class
레코드 클래스란 jdk14에서 추가된 클래스로, 데이터를 단순히 보관하기 위한 클래스를 만들 때 유용한 기능입니다. (정식적으로 레코드를 사용하기 위해선 jdk16 이상을 사용해야한다.) 레코드는 불변 객체를 간단하고 명확하게 정의할 수 있도록 돕습니다. 불변 객체란 생성된 이후 그 상태가 변경되지 않는 객체를 의미합니다. 불변 객체를 생성하기 위해서는,
- final 키워드를 클래스와 필드에 선언하여 상속 및 변경을 막습니다.
- 필드는 모두 private로 선언되어 외부에서 접근할 수 없도록 합니다.
- 생성자를 통해 필드가 초기화되고 이후에 변경을 할 수 없도록 설계합니다.
등이 있습니다.
레코드의 특징으로는,
- 불변 객체를 생성한다. -> 데이터를 단순히 보관하기 위한 클래스(dto)를 정의할 때 유용하다.
- 모든 필드를 포함한 생성자, getter, equals(), hashCode(), toString()을 자동으로 생성해주는 장점이 있습니다.
- 불필요한 코드를 줄이기 때문에 가독성이 좋고과 자동으로 생성해주기 때문에 코드 관리가 용이합니다.
- 컴팩트 생성자 기능을 제공한다.
- 파라미터를 작성하지 않아도 된다.
- 초기화 로직은 마지막에 자동으로 호출해준다.
- 기본적인 초기화 로직 외의 검증 로직 같이 생성자 안에서 추가적인 로직 처리가 필요할때 유용하다.
정리해보자면 제가 범한 문제는, Mockito의 given()으로 내가 동작을 정의하고 싶은 클래스 메서드에 input으로 특정 클래스가 들어왔을 때의 동작을 명시했는데 정작 controllerTest에서 들어오는 input 클래스가 내가 정의한 클래스와 "동등성" 비교에서 다르다고 판별해 내가 정의한 동작이 제대로 수행되지 않아 발생한 문제였습니다. 이에 대한 다양한 해결 방법이 있지만, 코드적으로도 깔끔하고 유지보수도 편한 Record Class를 DTO 클래스를 정의하는데 사용하는 것이 가장 좋은 방법이라고 생각합니다. 이 후 프로젝트에서는 jdk 17로 버전을 업그레이드하고 Record Class 사용을 적극적으로 사용하는 것이 좋다고 생각합니다.
'Study > Spring' 카테고리의 다른 글
Flyway 도입 및 오픈소스 기여 (1) | 2024.09.26 |
---|---|
비동기 처리 시 @Async와 Springboot의 마법 (0) | 2024.07.18 |
DB관련 Test 코드 작성 시 유의할 점 :: @DataJpaTest (0) | 2024.05.04 |
JVM Memory의 3가지 메트릭 지표 - Committed Memory란? (0) | 2024.04.02 |
조회 성능 향상을 위한 캐시 처리 With Redis (1) | 2024.01.29 |