Testing 시에 Layer간의 연결을 끊고 Layer 별 기능을 독립적으로 테스트할 수 있도록 도와주는 도구가 바로 Mockito 입니다.🍸
왜 layer별로 독립적인 테스트가 필요할까요?
특정 계층의 테스트만 필요한데 불필요한 전 계층을 다 거치게 되면서 성능이 떨어지거나 테스트 관심 영역 면에서 벗어나는 것을 방지하기 위해서 입니다.
처음 사용해보려니 생소한데, 어떤 요소들을 생각하면서 사용하면 좀 더 쉽게 사용할 수 있는지 깨달았던 부분들을 글로 남겨두려고 합니다.
Mockito 사용법
1. 테스트하려는 Controller와 유사한 구조를 갖는 test class를 test directory에 생성합니다.
: 유사한 구조라함은 Controller에서 의존하는 객체, 정의된 메서드(로직은 제외)를 모두 작성해 줍니다.
[테스트 하고자 하는 Controller]
아래의 MemberController의 CRUD method들을 테스트해보려고 합니다.
package com.codestates.member.controller;
@RestController
@RequestMapping("/v11/members")
@Validated
@Slf4j
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
public MemberController(MemberService memberService, MemberMapper mapper) {
this.memberService = memberService;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) {
Member member = mapper.memberPostToMember(requestBody);
member.setStamp(new Stamp()); // homework solution 추가
Member createdMember = memberService.createMember(member);
MemberDto.response response = mapper.memberToMemberResponse(createdMember);
return new ResponseEntity<>(
new SingleResponseDto<>(response),
HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(
@PathVariable("member-id") @Positive long memberId,
@Valid @RequestBody MemberDto.Patch requestBody) {
requestBody.setMemberId(memberId);
Member member =
memberService.updateMember(mapper.memberPatchToMember(requestBody));
return new ResponseEntity<>(
new SingleResponseDto<>(mapper.memberToMemberResponse(member)),
HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(
@PathVariable("member-id") @Positive long memberId) {
Member member = memberService.findMember(memberId);
return new ResponseEntity<>(
new SingleResponseDto<>(mapper.memberToMemberResponse(member))
, HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers(@Positive @RequestParam int page,
@Positive @RequestParam int size) {
Page<Member> pageMembers = memberService.findMembers(page - 1, size);
List<Member> members = pageMembers.getContent();
return new ResponseEntity<>(
new MultiResponseDto<>(mapper.membersToMemberResponses(members),
pageMembers),
HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(
@PathVariable("member-id") @Positive long memberId) {
memberService.deleteMember(memberId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
[Controller를 참고해서 Test class의 기본 뼈대 잡기]
열심히 작성해 둔 MemberController class를 토대로 Test class의 기본 뼈대를 잡아줍니다.
package com.codestates.restdocs.member;
//(1)@WebMvcTest 애너테이션은 Controller를 테스트 하기 위한 전용 애너테이션.
//@WebMvcTest 애너테이션의 괄호 안에는 테스트 대상 Controller 클래스를 지정해 주면 됨.
@WebMvcTest(MemberController.class)
//(2)JPA에서 사용하는 Bean들을 Mock객체로 주입해주는 설정.
//xxxApplication Class에는 @EnableJpaAuditing을 붙여주어야 함.
@MockBean(JpaMetamodelMappingContext.class)
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc; //(4)MockMvc는 Tomcat같은 서버를 실행하지 않고도 Spring기반
//애플리케이션의 Controller를 test할 수 있도록 해주는 일종의 Spring MVC FW
@Autowired
private Gson gson; //(5)Object를 JSON format으로 변형시켜 줄 library(dependecny 추가필요)
//(6)Application Context에 등록되어 있는 Bean에 대한 Mockito Mock object를 생성하고 DI해주는 역할
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
//(7)Controller에 정의되어 있는 CRUD method들 입니다.
//@Test를 붙여 테스트의 대상임을 알려주면 됩니다.
@Test
public void postMemberTest() throws Exception {
//(8)
// given
// when
// then
}
@Test
public void patchMemberTest() throws Exception {
// given
// when
// then
}
@Test
public void getMemberTest() throws Exception {
//given
//when
//then
}
@Test
public void getMembersTest() throws Exception {
//given
//when
//then
}
@Test
public void deleteMemberTest() throws Exception {
//given
//when
//then
}
}
- (1) Test를 위해서 @SpringBootTest를 사용하기도 하는데, 이는 통합 테스트 시에 사용하면 되는 annotation입니다. 전체 Bean을 모두 등록하기 때문에 실행 시 무겁습니다. 반대로 @WebMvcTeset는 API layer와 관련된 bean만 등록하게 되어 가볍습니다.
- (6) @MockBean은 Controller가 의존하고 있는 Class 나 Interface 변수에 붙여주면 됩니다. MockBean으로 설정된 객체는 when()이나 given()을 사용해 stubbing을 하게 됩니다.
Stubbing이란?
Test 시에 Mock object가 특정 행동 시에 특정한 값을 반환하도록 지정해 주는 것입니다.
(어떤 method를 수행할 때 어떤 값을 반환하게 할 지 개발자가 정할 수 있습니다.)
- (8) given, when, then은 test logic을 작성하는데 도움을 주는 가이드입니다.
- given: test에 필요한 데이터들을 마련해줍니다. 그런데 어떤 데이터가 필요한지 어떻게 알 수 있을까요?
제가 터득한 방법은 Controller에서 참고하는 방법입니다. 한 번 Controller에 정의된 postMember() method를 살펴보겠습니다.
@PostMapping //(1)
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) {
//(2)
Member member = mapper.memberPostToMember(requestBody);
//(3)
member.setStamp(new Stamp());
//(4)
Member createdMember = memberService.createMember(member);
return new ResponseEntity<>( //(5)
new SingleResponseDto<>(mapper.memberToMemberResponse(createdMember)),
HttpStatus.CREATED);
}
쭉 코드를 살펴보면서 어떤 데이터를 만들어야 할 지 번호로 붙여보았습니다. 붙여놓고 보니 다 만들어주어야 하네요 😂
기본적으로 parameter로 받는 데이터와 response 데이터는 만들어주어야 하더라구요. 그리고 stubbing이 필요한 코드들을 찾아주면 됩니다. stubbing이 필요한 코드는 어떻게 찾냐면, @MockBean이 붙은 객체가 일을 하는 부분은 stubbing이 필요하다고 보면 되는 겁니다. 왜냐면 MockBean이 된 애들은 이제 껍데기 뿐인 객체이기 때문에 그냥 두면 아무런 일을 수행할 수가 없으니, 그 친구들에게 이런 method가 호출되면 이렇게 반응하렴! 이라고 알려주어야 합니다. 그 반응은 우리의 테스트가 원활하게 진행되기 위한 의도된 반응으로 정의해주면 되겠죠?
설명대로 작성한 given 부분의 코드를 한 번 살펴보겠습니다.
@Test
public void postMemberTest() throws Exception {
// given
//(1)
MemberDto.Post post = new MemberDto.Post("hgd@gmail.com",
"홍길동",
"010-1234-5678");
//(2)
String content = gson.toJson(post);
//(3)
MemberDto.response responseDto =
new MemberDto.response(1L,
"hgd@gmail.com",
"홍길동",
"010-1234-5678",
Member.MemberStatus.MEMBER_ACTIVE,
new Stamp());
//(4)stubbing
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class)))
.willReturn(new Member());
given(memberService.createMember(Mockito.any(Member.class)))
.willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
// ...
}
- (1) Parameter로 받는 MemberDto.Post 객체를 만들어 준비해 줍니다.
- (2) MemberDto.Post를 JSON format으로 변경해주어야 이후에 MockMvc에서 사용이 가능해집니다.
- (3) Response에 해당하는 MemberDto.response 객체를 만들어 준비해 줍니다.
- (4) @MockBean들의 행동을 정의해줍니다. 이 때 중요한 것은 가장 마지막에 쓰이는 response를 return할 stubbing입니다. 다른 stubbing은 사실 알맹이가 없는 객체들을 반환해도 무관하지만, 우리가 마지막에 test 결과로 확인해야할 response를 return 하는 stubbing은 (3)에서 준비해 둔 response 객체를 반환하도록 해주어야 원하는대로 동작할 수 있습니다.
- (4-1) Mockito.any()와 같은 parameter는 말그대로 아무 데이터나 다 들어올 수 있다는 의미입니다. 어떤 데이터 타입이 들어와도 willReturn에 정의된 대로 해당 MockBean 객체는 new Member()를 반환하게 됩니다. 다만 좀 더 구체적으로 어떤 class가 들어올 때인지, 정확히 어떤 타입의 데이터인지(anyLong(), anyList(), etc) 설정해 둘 수도 있습니다.
- when: given에서 test에 사용될 데이터와 stubbing을 모두 준비하고, when 파트에서는 실제로 로직을 수행해보게 됩니다. 이때 mockMvc가 이용됩니다.
//given
//...
// when
ResultActions actions =
mockMvc.perform(
post("/v11/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
mockMvc 객체의 perform method를 사용하는데, parameter로 가장 먼저 어떤 HTTP request인지에 따라 알맞은 RequestBuilder를 사용해줍니다(post, get, patch, delete 모두 존재합니다.). 그리고 request builder의 parameter로는 url을 명시해줍니다.
accept는 client가 return받을 response data type을 명시해주면 되고, contentType은 server side에서 처리 가능한 데이터의 type을 명시해줍니다.
그리고 content에는 request body에 해당하는 data를 설정해주면 됩니다. 아까전에 gson으로 변환한 JSON 문자열을 사용하면 됩니다!
이렇게 하면 mockMvc가 post request를 수행해줍니다. 마치 우리가 postman을 이용해서 post request를 보낸 것과 동일한 효과를 가집니다.
- then: then에서는 when에서 request를 수행한 후 나온 결과를 검증하게 됩니다. .andExpect() method를 사용해서 어떤 값을 기대했고, 실제 결과는 어떠한지 비교해보는 단계입니다. 여기에서 test의 성패가 판가름 나겠죠?
MvcResult result = actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.email").value(post.getEmail()))
.andExpect(jsonPath("$.data.name").value(post.getName()))
.andExpect(jsonPath("$.data.phone").value(post.getPhone()))
.andReturn();
- status().isCreated()는 201 Created 응답이 나왔는지 확인하는 것입니다. status()다음에 .isOk나 .isNoContent등 다양한 응답 결과를 확인해 볼 수 있습니다.
- jsonPath 부분은 response로 돌려받은 JSON 데이터의 값들이 post로 보냈던 필드 값들과 일치하는 지 확인하는 것입니다. 제대로 응답이 나왔다면 값이 동일해야겠습니다.
- 마지막에 .andReturn()으로 MvcResult 객체를 리턴해줍니다. (만일 해당 객체를 리턴받아 값을 확인하거나 사용할 것이 아니라면 굳이 MvcResult 객체에 담지 않아도 됩니다. )
actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.email").value(post.getEmail()))
.andExpect(jsonPath("$.data.name").value(post.getName()))
.andExpect(jsonPath("$.data.phone").value(post.getPhone()));
그냥 return 받지 않고 이렇게 test만 끝내도 됩니다.
이렇게 Mockito를 이용하여 Controller를 test해보는 방식에 대해서 쭉 알아보았습니다.
HttpRequest 별로 조금 차이가 있지만 사용하는 annotation이나 given, when, then 스텝으로 코드를 작성하는 것은 동일합니다.
다른 HttpRequest는 어떻게 test code를 작성했는지 궁금하신 분들을 위해서 git hub link를 남겨두겠습니다.
도움이 된다면 좋을 것 같습니다 ☺️
+ 조언이 있다면 댓글로 알려주시면 감사하겠습니다!
[Git-hub 바로가기]
https://github.com/happyduck-git/mockito-testing
참고자료
https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
https://shinsunyoung.tistory.com/52
https://gracelove91.tistory.com/88
'Java Spring > MVC' 카테고리의 다른 글
em.flush() vs tx.commit() (1) | 2022.07.23 |
---|---|
Spring Rest Docs 만들기 (1) | 2022.07.19 |
Entity mapping-Cascade의 역할 (0) | 2022.07.17 |
[Spring] Spring JDBC 사용하기 (0) | 2022.07.02 |
[Spring] H2 in-memory DB 사용하기 (0) | 2022.06.28 |