Mockito를 사용한 단위테스트
들어가기에 앞서
개발 시 테스트코드의 중요성은 깨달으면 깨달을수록 크게 느껴지게된다. 테스트코드는 크게 통합테스트와 단위테스트 2가지로 나뉘게 되는데 자주 실행시켜서 테스트해볼 수 있어야하는 만큼 가능하면 단위테스트로 작성하는 것이 좋을 것이다.
언어마다 테스트코드 라이브러리가 다양하게 있는데 자바에서 사용하는 라이브러리로 Junit5와 Mockito를 이용해서 테스트코드를 작성해보도록 하자.
API만들기
먼저 테스트 할 API를 만들어야 한다.
@PostMapping
public String sign(@Valid @ModelAttribute("member") MemberSaveRequestDto dto,
BindingResult bindingResult) throws IOException {
if (bindingResult.hasErrors()) {
return "members/sign";
}
memberService.save(dto);
return "redirect:/login";
}
클라이언트에서 form형식으로 데이터를 받아서 처리하는 API이다. @Valid를 이용해서 들어오는 데이터가 설정한 조건에 맞는 데이터인지를 확인한 후 맞으면 save를 진행시키고 아니라면 BindingResult를 이용해서 에러의 내용을 view로 보내서 클라이언트에게 에러내용을 보여주도록 되어있다.
MemberSaveRequestDto의 코드는 다음과 같다.
@Getter @Setter
public class MemberSaveRequestDto {
@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$")
private String username;
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}")
private String password;
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$")
private String email;
private Role role;
public MemberSaveRequestDto() {
}
public MemberSaveRequestDto(String username, String password, String email, MultipartFile image, Role role) {
this.username = username;
this.password = password;
this.email = email;
this.image = image;
this.role = role;
}
}
Mockito를 이용한 테스트코드 작성
이제 이 API를 테스트 할 수 있는 테스트 코드를 만들어보자.
Mockito를 사용하기 위해서 먼저 주입을 해줘야한다. Junit4와 Junit5에서 주입방식이 조금 다르기 때문에 주의해서 사용해야 한다. 지금은 Junit5를 사용하도록 하자.
먼저 코드를 살펴본 뒤 하나씩 살펴보자.
@ExtendWith(MockitoExtension.class)
class MemberControllerTest {
@InjectMocks
MemberController memberController;
@Mock
MemberService memberService;
MockMvc mockMvc;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(memberController).build();
}
}
- @ExtendWith(MockitoExtension.class) : Mockito를 사용할 수 있도록 설정해준다.
- Junit4에서는 @RunWith(MockitoJUnitRunner.class)를 사용해야 한다.
- @InjectMocks : 테스트하고자 하는 대상의 가짜 객체를 만들어서 주입해준다.
- @Mock : 테스트하고자 하는 대상이 의존하고 있는 클래스의 가짜 객체를 만들어서 주입해준다.
- Mockmvc : 테스트를 하기 위해서는 HTTP호출이 필요한데 스프링은 이를 대신해줄 MockMvc를 제공하고 있다.
- @BeforeEach : 테스트가 실행되기 전에 같이 실행된다는 의미이다.
- MockMvcBuilders.standaloneSetup(memberController).build() : 테스트 하고자하는 대상을 주입한다.
뒤에서 API와 테스트코드를 함께 만들어볼 예정이지만 이번에 테스트 할 대상은 MemberController이다. 그리고 MemberController에서는 비즈니스로직을 담아놓은 MemberService를 의존하고 있다.
우리가 테스트 하고자 하는 대상인 MemberController를 @InjectMocks로 담아주고 MemberController가 의존하고 있는 대상인 MemberService를 @Mock으로 담아주는 것이다. 만약 테스트에 필요한 의존객체가 더 있다면 모두 추가해주어야 한다.
@InjectMocks과 @Mock으로 추가한 객체는 실제 객체를 가져오는 것이 아니다. 그렇기 때문에 내부에 있는 API들은 사용할 수 없다. 하지만 테스트를 하기 위해서는 컨트롤러의 동작과 컨트롤러가 호출하는 서비스의 동작이 이루어져야 가능할 것이다. 이런 동작의 결과들을 개발자가 직접 지정한 후 결과를 이용해서 테스트를 작성해나가는 것이다. 이러한 과정을 stub이라고도 한다.
stub으로 받은 결과물을 이용해 테스트하고자 하는 API에서 나머지 기능들을 테스트 할 수 있다.
컨트롤러에서 HTTP호출을 이용한 통신을 할 떄는 MockMvc를 이용한다. perform을 이용해서 URI를 호출한 뒤 필요한 파라미터를 보내주면 그에 맞는 결과값들을 확인할 수 있다.
코드로 살펴보자.
@ExtendWith(MockitoExtension.class)
class MemberControllerTest {
....
@Test
void 회원가입_성공() throws Exception {
// given
given(memberService.save(any(MemberSaveRequestDto.class))).willReturn(1L);
// when, then
mockMvc.perform(multipart(baseUrl)
.file("image", new byte[]{})
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "username")
.param("password", "password123!")
.param("email", "email@naver.com"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login"));
verify(memberService).save(any(MemberSaveRequestDto.class));
}
}
위의 코드와 이어지는 내용이므로 Mock객체들은 이미 선언되어 있다.
테스트코드를 할 때는 given, when, then의 형식을 많이 사용한다.
- given : 테스트를 위한 데이터를 준비하는 과정
- when : 데이터를 이용해서 어떤 동작을 실행하는 과정
- then : 실행된 동작이 원하는 결과대로 잘 나타났는지 확인하는 과정
(1) stub 만들기
먼저 given에서는 stub을 만들어주도록 하자.
회원가입을 테스트하는 코드이기 때문에 API내부에 있는 memberService.save()에 대한 호출 결과를 미리 지정해준다.
형태는 given().willReturn()의 형태이다.
given()안에 실행하고자 하는 메소드를 넣어준다. 메소드의 파라미터가 필요한 경우 파라미터를 넘겨주어야 하는데 any(), anyLong(), anyString() 등 필요한 파라미터의 타입에 맞게 넣어주면 된다. any가 아니라 필요한 객체나 변수를 직접 선언해서 넣어줘도 되지만 객체를 파라미터로 받는 경우 넘어가는 객체와 실제 컨트롤러가 호출되면서 생성되는 객체가 완전히 동일하지 않은 객체가 되면서 에러가 나타나기 때문에 any로 설정해주는 것이 좋다.
willReturn는 메소드가 실행된 결과를 넣어주는 것이다. 현재 필자가 만든 save()라는 메소드는 Long타입을 반환하게 되있으므로 1L을 입력해주었다.
willReutrn말고 willThrow도 있는데 이 경우는 예외가 발생하는 경우를 만들어서 발생하는 예외 클래스를 넣어주면 된다.
원래 Mockito에서는 given메소드가 아니라 when메소드를 사용했었다. 하지만 BDD에 맞는 given, when, then의 방법을 사용하다보니 given에 속하는 stub이 when으로 선언하는 메소드가 되면서 가독성이 불편해지기 시작했고 이것을 해결하기 위해서 BDDMockito라는 것을 만들었다. 이것은 Mockito를 상속받아서 Mockito의 기능을 그대로 사용하지만 표현방법을 바꾼 클래스이다. 여기서 사용한 given이 바로 BDDMockito에 속한 메소드이다.
(2) API호출
다음으로는 본격적으로 API를 호출하고 테스트를 진행하고 있다.
perform안에서 호출하는 API의 HTTP메소드와 URL을 지정해준다. 현재 multipart로 되어있는 것은 파일을 넘겨야하기 때문이고 POST, GET, PUT 등과 같이 일반적으로 사용하는 메소드들을 사용할 수 있다.
현재 필자의 코드는 form을 이용해서 데이터를 넘기도록 만들었으므로 ContentType을 그것에 맞게 지정하고 필요한 파라미터들을 param()을 이용해서 추가해주었다.
(3) API결과 확인
API가 실행된 결과들을 andExpect()를 이용해서 확인할 수 있다.
만약 API가 모두 정상적으로 동작하면 상태코드로 /login으로 리다이랙션을 시키기 때문에 그에 맞는 상태코드 3xx와 리다이랙션 시킬 URL을 지정해서 제대로 동작하는지를 확인하고 있다.
(4) 메소드 실행여부 확인
컨트롤러에서 service를 의존하면서 필요한 메소드를 호출하고 있다. stub을 이용해서 메소드의 결과를 직접 만들어서 다른 동작들이 잘 이루어질 수 있도록 만들었지만 실제로 컨트롤러에 존재하는 메소드가 실행되었는지의 여부또한 확인 할 필요가 있다.
verify를 이용해서 메소드의 실행여부를 확인해보자.
위의 코드에서는 정상실행을 테스트하는 것이기 때문에 반드시 save()가 실행되어야 한다. 만약 메소드 실행전에 예외가 발생하는 경우를 테스트 하는 경우라면 save()가 실행되지 않아야 할 것이다.
verify(memberService, never()).save(any(MemberSaveRequestDto.class));
이와 같이 작성할 수 있다. never()를 추가함으로서 실행되지 않아야 성공하는 테스트코드가 되는 것이다. 이 외에도 verify의 옵션은 다양하게 있으니 적절하게 사용하면 될 것이다.
테스트코드는 정말 다양한 상황들을 테스트 할 수 있다. 필자는 form형식의 데이터를 테스트했지만 json으로 데이터를 받는 경우도 있을 것이고 return값도 필자는 바로 view를 호출하는 방식이었지만 json으로 return해서 테스트해야하는 경우도 있을 것이다. 이 글에서는 단위테스트를 위한 테스트코드를 어떻게 작성하면 좋을지에 대한 느낌을 알아보는 정도로만 작성했기 때문에 이런 부분에 대해서는 필요시 찾아보면서 진행하는 것이 좋을것이다.
테스트코드 작성 시 마주한 에러
마지막으로 필자가 겪었던 어려운 상황과 해결방법을 소개하고 마무리하도록 하겠다.
PUT메소드를 사용한 API에서 MultipartFile을 넘겨줘야 하는 경우
단위테스트에서 HTTP호출을 사용하기 위해서 MockMvc를 사용하는데 API를 호출하는 과정중 perform()메소드를 사용해서 호출한다. 여기에 HTTP메소드와 URL을 입력해서 API를 호출하는데 일반적인 경우는 GET, POST등과 같이 작성하면 되지만 파일을 파라미터로 넘기기 위해서는 multipat를 사용해야 한다. 그런데 문제는 multipart에서 사용하는 HTTP메소드를 스프링에서 강제로 POST로 지정해놓은 것이다. 그렇기 때문에 수정할 때 사용하는 PUT을 테스트하기가 까다로워진다.
이 문제를 해결하는 방법으로 multipart메소드를 바로 넣어서 사용하는 것이 아니라 분리해서 내용을 채워준 후 사용하는 방법이 있다. 바로 코드로 살펴보자.
@Test
void 프로필수정() throws Exception {
// given
given(memberService.update(any(MemberUpdateRequestDto.class), anyLong())).willReturn(new MemberResponseDto());
// when, then
mockMvc.perform(put(baseUrl + "/profile")
.file("image", new byte[]{}) // 이 부분이 에러
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "username")
.param("password", "password123!")
.param("email", "email@naver.com")
.sessionAttr(SessionConst.USER_ID, 1L))
.andDo(print())
.andExpect(status().isOk())
.andExpect(view().name("/members/profileForm"));
verify(memberService).update(any(MemberUpdateRequestDto.class), anyLong());
}
일반적이라면 위와 같은 코드를 사용하겠지만 이렇게하면 앞에 말했던 이유로 사용이 불가능하다. 실제로 file을 넘기는 파라미터 부분에서 컴파일에러가 발생한다.
이 문제를 해결하는 코드로는
@Test
void 프로필수정() throws Exception {
// given
given(memberService.update(any(MemberUpdateRequestDto.class), anyLong())).willReturn(new MemberResponseDto());
// when, then
MockMultipartHttpServletRequestBuilder builder = multipart(baseUrl + "/profile");
builder.with(new RequestPostProcessor() {
@Override
public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {
request.setMethod("PUT");
return request;
}
});
mockMvc.perform(builder
.file("image", new byte[]{})
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "username")
.param("password", "password123!")
.param("password_check", "password123!")
.param("email", "email@naver.com")
.sessionAttr(SessionConst.USER_ID, 1L))
.andDo(print())
.andExpect(status().isOk())
.andExpect(view().name("/members/profileForm"));
verify(memberService).update(any(MemberUpdateRequestDto.class), anyLong());
}
위와 같이 사용해주어야 한다. perform을 분리해서 multipart로 설정하고 이후에 메소드를 put으로 설정하는 방법이다. 이렇게 사용하면 테스트가 무사히 통과된다.