TDD, BDD에 대해 공부하면서 Error를 만나게 되었고 열심히 구글링을 하여 해결법은 찾았으나 그 원인까지는 설명해주지 않았기에 분석 후 정리를 한다. (오류의 정리이기 때문에 이 글에서는 TDD와 BDD에 대한 설명은 없습니다.)
오류 내용
1. url: /create-monster, message: Required request body is missing
2. Argument(s) are different! Wanted, Actual invocations have different arguments
1번 오류에 대한 정리(url: /create-monster, message: Required request body is missing)
오류의 시작
- response 객체와 request 객체 (테스트에 공통으로 사용할 수 있게 인스턴스 변수로 선언)
private MonsterEntity responseMonster = MonsterEntity.builder()
.id(1L)
.monsterLevel(BABY)
.monsterType(FLY)
.statusCode(StatusCode.HEALTHY)
.ssn("12345612345123")
.name("BabyMonster")
.age(3)
.height(170)
.weight(73)
.build();
private CreateMonsterDto.Request getCreateRequest(){
return CreateMonsterDto.Request.builder()
.id(1L)
.monsterLevel(BABY)
.monsterType(FLY)
.statusCode(StatusCode.HEALTHY)
.ssn("12345612345123")
.name("BabyMonster")
.age(3)
.height(170)
.weight(73)
.build();
}
- 실행 코드(Controller)
@Test
@DisplayName("Monster Created Test")
void createMonster() throws Exception{
// given
given(mMakerService.createMonster(getCreateRequest()))
.willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
// when
CreateMonsterDto.Response result = mMakerService.createMonster(getCreateRequest());
// then
mockMvc.perform(
post("/create-monster")
.contentType(contentType)
.content(new ObjectMapper().writeValueAsString(result)))
.andExpect(status().isOk())
.andDo(print())
.andReturn();
}
- MMakerService.createMonster (Service)
public CreateMonsterDto.Response createMonster(
CreateMonsterDto.Request request
){
validateCreateMonsterRequest(request);
return CreateMonsterDto.Response.fromEntity(
monsterRepository.save(
createMonsterFromRequest(request)
)
);
}
위의 코드들은 몬스터를 생성하는 테스트 코드이다. 내가 원했던 결과는 Request로 보낸 데이터를 Response로 잘 응답받는 것이었다.
요청)
MockHttpServletRequest:
Body = null
응답)
MockHttpServletResponse:
Status = 200
Error message = null
Headers = []
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
그런데 저 코드를 실행하니 Request, Response의 body에는 아무런 데이터가 실려있지 않았다. ExceptionHandler는 message: Required request body is missing 다음과 같은 메시지를 뱉어주고 있었다. 나는 분명 데이터를 넣어서 보내줬는데 뭐가 문제인 걸까...?
원인 파악
잠깐! 원인 파악 과정 도중 알게 된 내용
어떠한 함수가 실행 됐을 때에 어떤 데이터를 응답해줄지 가정해둔 given의 createMonster 메서드에서 받는 매개변수와
실행 함수가 정의된 when에서 createMonster 메서드의 "매개변수의 내용물이 같아야 한다."
엥? "매개변수의 내용물이 같아야 한다." 이 말은 무슨 말일까? given의 getCreateRequest(). name = '괴물감자' 가 들어있는데, when 의 getCreateRequest().name = '괴물 고구마'가 들어있어서는 안 된다는 말이다. 내용물(값)이 같아야 한다.
- given: given(mMakerService.createMonster(getPotato())) 의 getPotato().name = '괴물감자
- when: mMakerService.createMonster(getPotato()) 의 getPotato().name = '괴물감자'
사실 생각해보면 당연한 내용이다.
어떠한 함수가 매개변수를 받아 어떤 행위를 할지 정의하였고(given)
given(mMakerService.createMonster(obj.name = 괴물고구마)) .willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
어떠한 함수를 실행시킨다(when)
mMakerService.createMonster(obj.name = 괴물감자);
- given : 어떠한 데이터가 준비되었을때. 즉, Mock객체가 특정 상황에서 어떤 행위를 해야할지
- when: 어떤 함수를 실행할지
- then: 어떤 결과를 내놓는다어떤 함수를 실행시킬 때 매개변수 Obj.name 값으로 괴물 감자를 넣어줬으면서 그 함수가 해야 할 행위에는 매개변수 Obj.name 값으로 괴물 고구마를 넣어주는 것은 말이 안 되는 것이다.
(obj.name = 괴물감자 는 매개변수로 넘어간 obj 안에 값이 저렇게 들어있다고 쉽게 설명하기 위해 저렇게 표기해둔 것이다.)
또한 리턴 값도 마찬가지다. willReturn()에 리턴되는 값으로는 "현재 테스트 코드 상에서는" Request로 넘겨준 값들을 반환해줘야 한다. Request로 괴물 감자를 보내주었고, 괴물 감자를 save 하였다. save 이후 저장한 객체를 다시 return 하여 되돌려 받았을 때 응답 즉, Response에서 괴물 호박 이 나오는 건 말이 안 된다. 감자를 저장했는데 호박이 나온 것이다...
(주니어 개발자가(6개월 차) 구글링을 통해, 그리고 테스트를 하며 깨달은 것이므로 분석이 정확하지 않을수도 있다.)
위의 원인 파악 과정 도중 알게 된 내용을 다 읽어봤다면 아래의 내용을 보자마자 바로 원인을 알 수 있을 것이다.
@ToString 을 사용했을때
- CreateMonsterDto.Request(id=1, monsterLevel=BABY, monsterType=FLY, statusCode=HEALTHY, ssn=96050311082045, name=애기몬스터, age=3, height=170, weight=73)
@ToString 을 사용하지 않았을때
- com.fc_study.monsterGrowth.dto.CreateMonsterDto$Request@61911947
new ObjectMapper().writeValueAsString(getCreateRequest()) 를 사용했을때 (Json 타입으로의 변환)
- : {"id":1,"monsterLevel":"BABY","monsterType":"FLY","statusCode":"HEALTHY","ssn":"96050311082045","name":"애기몬스터","age":3,"height":170,"weight":73}
@ToString을 사용하지 않았을 때의 결괏값을 본다면 객체의 주소 값(CreateMonsterDto$Request@61911947)을 반환해 주는 것을 볼 수 있다. 즉, 객체의 내용물을 비교해야 하는데 주소 값을 비교하기 때문에 문제가 되고 있다고 생각해볼 수 있었고. 위의 원인 파악 과정 도중 알게 된 내용인 "매개변수의 내용물이 같아야 한다."가 성립되지 않는 것이다. 물론 매개변수로 넘겨진 객체 안의 내용물들은 똑같다 그러나 매개변수로 보내진 객체의 주소가 다르고, 객체의 주소로 비교했기 때문에 null을 반환한 것이다.
값을 꺼내서 비교하지않고 객체끼리 비교했을때 어떤결과가 나오는지 살펴보자.
getCreateRequest().equals(getCreateRequest())
코드는 다음과 같이 비교하였고, 분명 내용물이 같은 객체인데 false가 나온다... 근데 잘 살펴보면 그냥 getCreateRequst()를 비교했을때에는 객체의 주소가 나온다.
- First: com.fc_study.monsterGrowth.dto.CreateMonsterDto$Request@221ca495
- Second: com.fc_study.monsterGrowth.dto.CreateMonsterDto$Request@119d4443
그렇다면 안의 값을 꺼내서 한번 비교를 해보자.
getCreateRequest().getId().equals(getCreateRequest().getId())
코드는 다음과 같이 비교하였고, 값을 꺼내서 비교해주니 true 가 나온다. 당연한 결과이다 1 == 1 같냐고 묻는것이다.
해결방법
해결방법은 2가지가 있었다.
1. @EqualsAndHashCode
2. org.mockito.ArgumentMatchers.any();
해결방법 1: 첫 번째 방법으로는 @EqualsAndHashCode 어노테이션을 사용하는 것이다.
매개변수로 넘어가는 CreateMonsterDto.Request의 어노테이션으로 달아주면 된다.
@EqualsAndHashCode Annotation 이란?
@EqualsAndHashCode 어노테이션은 equals 와 hashcode 를 자동으로 생성해주는 어노테이션 입니다. @ToString 어노테이션과 마찬가지로 exclude 파라미터로 필드를 제외하거나 callSuper 파라미터로 부모 객체를 생략하거나 포함할 수 있습니다.
- equals : 두 객체의 내용이 같은지, 동등성(equality)를 비교하는 연산자입니다.
동등성 : 객체의 값이같다. 동등성은 변수가 참조하고 있는 객체의 주소가 서로 다르더라도 내용만 같으면 두 변수는 동등하다고 이야기 할 수 있다.(equals 연산자로 파악가능)
- hashcode : 두 객체가 같은 객체인지, 동일성(identity)를 비교하는 연산자입니다.
동일성 : 객체의 주소가 같다. 주소값이 같기 때문에 두 변수가 있다면 같은 객체를 바라본다.(== 연산자)
equals와 hashcode를 자동으로 생성해준다고 한다. 현재 문제가 되는 것은 주소 값을 비교하기 때문에 문제가 되는것인데 동등성(equals)를 보면 이렇게 적혀있다. "동등성은 변수가 참조하고 있는 객체의 주소가 서로 다르더라도 내용만 같으면 두 변수는 동등하다고 이야기 할 수있다." 라고. 주소값을 비교하기 때문에 문제가 되었는데 @EqualsAndHashCode를 사용하여 객체 안의 내용물을 비교하여 문제가 해결되었다.(RequestDto 에 붙여주면 된다)
@EqualsAndHashCode를 그냥 사용하게 되면 서로 다른 연관관계를 순환 참조하느라 무한루프가 발생하게 되고, 결국 스택 오버 플로우가 발생하기 때문에 id 값만 주로 사용한다. @EqualsAndHashCode(of = "id")
exclude : 제외시킬 변수명
of : 포함시킬 변수명
callSuper : 상위 클래스 호출 여부 물음
doNotUseGetters : getter 사용 여부
해결방법 2: 두 번째 방법으로는 org.mockito.ArgumentMatchers.any(); 메서드를 사용하는 것이다.
any() 란?
any() : 모든 매개 변수에 대하여 같은 행동을 하는 Mock 객체를 만들 수 있다.
Mock 이란?
실제 객체를 다양한 조건으로 인해 제대로 구현하기 어려울 경우 가짜 객체를 만들어 사용하는데, 이를 Mock 객체라 한다.
MockObject (Mock 객체)란?
- 행위를 검증하기 위해 사용되는 객체를 지칭하며 수동으로 만들 수도 있고 프레임워크를 통해 만들 수 있다.
- 행위 기반 테스트는 복잡도나 정확성 등 작성하기 어려운 부분이 많기 때문에 상태 기반 테스트가 가능하다면 만들지 않는다.
- Mock 객체는 테스트 더블 하위 객체로 써의 좁은 의미와 테스트 더블을 포함한 넓은 의미 2가지로 사용될 수 있다.
언제 사용해야 할까?
- 테스트 작성을 위한 환경 구축이 어려운 경우
- 테스트가 특정 경우나 순간에 의존적인 경우
- 시간이 걸리는 경우
유의사항
1. Mock 프레임워크가 정말 필요한지 확인한다. Mock을 사용하는 경우 테스트 케이스 유지에 복잡성이 더해지기 때문에 Mock이 없는 의존성 적은 구조로 프로그래밍한다.
2. 어떤 Mock 프레임워크를 사용하느냐는 핵심 문제가 아니다. 어떤 프레임워크를 사용하느냐에 따라 테스트 케이스 작성에 커다란 영향이 미치지 않는다. 단지 익숙해지기까지 시간이 필요할 뿐이다.
3. Mock 객체는 Mock 일 뿐이다. 실제 객체로 작동을 해보았을 때 잘 작동하지 않을 수도 있다. Mock 객체는 흉내를 내는 객체이기 때문이다.
실행할 함수(when)와 함수의 실행행위를 정의(given)할 때 넘겨주는 매개변수의 내용물이 아닌 주소 값으로 비교하여 문제가 되었었는데 가짜 객체를 만들어 사용함으로써 문제가 해결되었다.
밑의 코드는 현재 글의 시작 부분에 있는 테스트 코드에 매개변수를 Mock 객체로 대체한 모습이다.
@Test
@DisplayName("Monster Created Test")
void createMonster() throws Exception{
// given
given(mMakerService.createMonster(any()))
.willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
// when
CreateMonsterDto.Response result = mMakerService.createMonster(any());
// then
mockMvc.perform(
post("/create-monster")
.contentType(contentType)
.content(new ObjectMapper().writeValueAsString(result)))
.andExpect(status().isOk())
.andDo(print())
.andReturn();
}
2번 오류에 대한 정리(Argument(s) are different! Wanted, Actual invocations have different arguments)
오류의 시작
일단 코드부터 확인해보자
@Test
@DisplayName("Monster Created Test")
void createMonster() throws Exception{
// given
given(mMakerService.createMonster(any()))
.willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
// when
CreateMonsterDto.Response result = mMakerService.createMonster(any());
// then.
mockMvc.perform(
post("/create-monster")
.contentType(contentType)
.content(new ObjectMapper().writeValueAsString(result)))
.andExpect(status().isOk())
.andDo(print())
.andReturn();
then(mMakerService).should(times(2)).createMonster(getCreateMonster());
}
1번 에러의 테스트 코드에서 한 줄이 추가되었다. then(mMakerService). should(times(2)). createMonster(getCreateMonster()); 해당 메서드가 몇 번 실행되었는지 검증하는 코드이다. 2번 에러는 해당 코드에서 발생하였고 간단한 문제였다.
- Argument(s) are different! Wanted
- 주장이 다릅니다!
- Actual invocations have different arguments
- 실제 호출에는 다른 인수가 있습니다.
given과 when에서는 any()(Mock 객체)를 사용해놓고 then 검증과정에서는 getCreateMonster()을 사용했으니 문제가 됐었던 것이다.
해결방법
given(mMakerService.createMonster(any()))
.willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
// when: 어떠한 함수를 실행하면
CreateMonsterDto.Response result = mMakerService.createMonster(any());
// then: 어떠한 결과가 나와야 한다.
mockMvc.perform(
post("/create-monster")
.contentType(contentType)
.content(new ObjectMapper().writeValueAsString(result)))
.andExpect(status().isOk())
.andDo(print())
.andReturn();
then(mMakerService).should(times(2)).createMonster(any());
다음과 같이 테스트 코드에서 사용하는 매개변수들을 any()로 같이 사용하거나 아래와 같이 getCreateRequest(); 로 사용하면 된다. getCreateRequest(); 를 사용할 때는 @EqualsAndHashCode를 사용해야 함을 잊지 말자
given(mMakerService.createMonster(getCreateRequest()))
.willReturn(CreateMonsterDto.TestResponse.fromEntity(responseMonster));
// when: 어떠한 함수를 실행하면
CreateMonsterDto.Response result = mMakerService.createMonster(getCreateRequest());
// then: 어떠한 결과가 나와야 한다.
mockMvc.perform(
post("/create-monster")
.contentType(contentType)
.content(new ObjectMapper().writeValueAsString(result)))
.andExpect(status().isOk())
.andDo(print())
.andReturn();
then(mMakerService).should(times(2)).createMonster(getCreateRequest());
마무리
문제는 해결되었으나 아직은 어떤 방법을 사용하는 게 더 나은 방법인지 모르겠다. Mock객체로 해결하는 방법이 좋은지 @EqualsHashCode로 해결하는 게 좋은지... 내가 모르는 또 다른 해결 방법도 분명 있을 것이다. 앞으로도 테스트 코드를 계속 짜고 많은 글과 강의, 다른 사람들의 코드를 보며 꾸준히 성장해서 알게 되길 바란다.
이 글은 주니어 개발자(6개월 차)가 강의를 보고 복습하며 발생된 에러를 구글링과 디버깅을 통해 해결한 것이므로 좋은 방법이 아닐 가능성이 높습니다. 참고만 해주시고 더 좋은 방법이 있으시다면, 또는 더 좋은 테스트 방법에 대해서 조언해주시면 감사하겠습니다 :)
해당 복습 내용에 대한 코드가 있는 GitHub 주소
https://github.com/Doosic/monsterGrowth
GitHub - Doosic/monsterGrowth: 강의내용 복습 및 정리
강의내용 복습 및 정리. Contribute to Doosic/monsterGrowth development by creating an account on GitHub.
github.com
참고했던 블로그들
'Experience' 카테고리의 다른 글
Instagram Api Refresh Token batch 리펙토링 (0) | 2023.06.24 |
---|---|
JRebel이 안될때 확인해 볼 사항들 (0) | 2023.06.21 |
특수 사례 패턴 (외부 API를 대하는 방법) (0) | 2022.12.19 |
MCMS 웹 프로젝트 후기 (2) | 2022.12.04 |
Cache 기본 개념 및 SpringBoot 예제 (0) | 2022.07.22 |