Web API를 많이 작성하다보면
웹 애플리케이션을 실행하고 브라우저를 열어서 테스트할 URI를 입력하고
다시 코드를 작성하고 웹 애플리케이션을 재시작하는 등을 반복하게 된다.
이때 Web API를 실행하는 실행하는 시간보다
웹 애플리케이션을 실행하고 종료하는 시간이 더 오래걸리는 상황이 발생하게 되는데 여기에는 문제점이 있다.
1. 개발자의 수동 테스트
2. 코드 수정 후 서버를 재시작하고 테스트
이런 문제를 해결하기 위해 다음과 같은 방법을 사용할 수 있다.
1. JUnit 테스트
2. MockMVC 테스트
이 글에서 MockMVC을 이용하여 Web API테스트를 직접 해보려고 한다.
1. MockMVC란?
우리는 웹 애플리케이션을 작성한 후,
해당 웹 애플리케이션을 Tomcat이라는 이름의 WAS(Web Application Server)에 배포(deploy)하여 실행한다.
브라우저의 요청은 WAS에 전달되는 것이고 응답도 WAS에게서 받게 된다.
WAS는 요청을 받은 후, 해당 요청을 처리하는 웹 애플리케이션을 실행하게 된다.
즉, Web API를 테스트한다는 것은 WAS를 실행해야만 된다는 문제가 있다.
이런 문제를 해결하기 위해 Spring3.2부터 MockMVC가 추가되었다.
MockMVC는 WAS와 같은 역할을 수행한다.
요청을 받고 응답을 받는 WAS와 같은 역할을 수행하면서 웹 애플리케이션을 실행해준다.
WAS는 실행 시 상당히 많은 작업을 수행하는데
MockMVC는 웹 애플리케이션을 실행하기 위한 최소한의 기능만을 가지고 있다.
그렇기 때문에 MockMVC를 이용한 웹 애플리케이션 실행은 상당히 빠르다.
MockMVC를 이용하면 아래와 같은 테스트를 수행할 수 있다.
2. MockMVC Test 해보기
GuestbookApiController를 단위 테스트 한다는 것은,
GuestbookApiController가 사용하는 GuestbookService에 대한 부분은 함께 테스트하지 않는다는 것을 의미한다.
이를 위해 GuestbookService에 대한 Mock객체를 사용할 것이고
Mockito를 이용해 Mock객체를 생성할 것이다.
그리고 Controller를 테스트하기 위해 MockMVC를 사용할 것이다.
※ Controller, Service 참고
GuestbookApiController.java
package kr.or.connect.guestbook.controller;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import kr.or.connect.guestbook.dto.Guestbook;
import kr.or.connect.guestbook.service.GuestbookService;
@RestController
@RequestMapping(path="/guestbooks")
public class GuestbookApiController {
@Autowired
GuestbookService guestbookService;
@GetMapping
public Map<String, Object> list(@RequestParam(name="start", required=false, defaultValue = "0") int start){
List<Guestbook> guestbooks = guestbookService.getGuestbooks(start);
int count = guestbookService.getCount();
int pageCount = count/guestbookService.LIMIT;
if(count%guestbookService.LIMIT > 0)
pageCount++;
List<Integer> pageStartList = new ArrayList<>();
for(int i=0; i<pageCount; i++) {
pageStartList.add(i * guestbookService.LIMIT);ㅁ
}
Map<String, Object> map = new HashMap<>();
map.put("guestbooks", guestbooks);
map.put("pageStartList", pageStartList);
map.put("count", count);
return map;
}
@PostMapping
public Guestbook write(@RequestBody Guestbook guestbook,
HttpServletRequest request) {
String clientId = request.getRemoteAddr();
Guestbook resultGuestbook = guestbookService.addGuestbook(guestbook, clientId);
return resultGuestbook;
}
@DeleteMapping("/{id}")
public Map<String, String> delete(@PathVariable(name="id") Long id,
HttpServletRequest request){
String clientIp = request.getRemoteAddr();
int deleteCount = guestbookService.deleteGuestbook(id, clientIp);
return Collections.singletonMap("success", deleteCount > 0? "true":"false");
}
}
GuestbookService.java
package kr.or.connect.guestbook.service;
import java.util.List;
import kr.or.connect.guestbook.dto.Guestbook;
public interface GuestbookService {
public static final Integer LIMIT = 5;
public List<Guestbook> getGuestbooks(Integer start);
public int deleteGuestbook(Long id, String ip);
public Guestbook addGuestbook(Guestbook guestbook, String ip);
public int getCount();
}
GuestbookApiControllerTest.java
package kr.or.connect.guestbook.controller;
import kr.or.connect.guestbook.config.ApplicationConfig;
import kr.or.connect.guestbook.config.WebMvcContextConfiguration;
import kr.or.connect.guestbook.dto.Guestbook;
import kr.or.connect.guestbook.service.GuestbookService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {WebMvcContextConfiguration.class, ApplicationConfig.class })
public class GuestbookApiControllerTest {
@InjectMocks
public GuestbookApiController guestbookApiController;
@Mock
GuestbookService guestbookService;
private MockMvc mockMvc;
@Before
public void createController() {
MockitoAnnotations.initMocks(this);
mockMvc = MockMvcBuilders.standaloneSetup(guestbookApiController).build();
}
@Test
public void getGuestbooks() throws Exception {
Guestbook guestbook1 = new Guestbook();
guestbook1.setId(1L);
guestbook1.setRegdate(new Date());
guestbook1.setContent("hello");
guestbook1.setName("kim");
List<Guestbook> list = Arrays.asList(guestbook1);
when(guestbookService.getGuestbooks(0)).thenReturn(list);
RequestBuilder reqBuilder = MockMvcRequestBuilders.get("/guestbooks").contentType(MediaType.APPLICATION_JSON);
mockMvc.perform(reqBuilder).andExpect(status().isOk()).andDo(print());
verify(guestbookService).getGuestbooks(0);
}
@Test
public void deleteGuestbook() throws Exception {
Long id = 1L;
when(guestbookService.deleteGuestbook(id, "127.0.0.1")).thenReturn(1);
RequestBuilder reqBuilder = MockMvcRequestBuilders.delete("/guestbooks/" + id).contentType(MediaType.APPLICATION_JSON);
mockMvc.perform(reqBuilder).andExpect(status().isOk()).andDo(print());
verify(guestbookService).deleteGuestbook(id, "127.0.0.1");
}
}
@Mock
GuestbookService guestbookService;
@Mock어노테이션을 붙여 선언된 guestbookService는 mockito에 의해 Mock객체로 생성된다.
말 그대로 가짜 객체인 것이다.
@InjectMocks
public GuestbookApiController guestbookApiController;
@InjectMocks어노테이션이 붙여서 선언된 guestbookApiController는
Mock객체인 GuestbookService를 사용하게 된다.
스프링에 의해 주입된 객체를 사용하는 것이 아닌
Mockito 프레임워크에 위해 생성된 목객체가 주입되어 객체가 생성된다.
@Before
public void createController() {
...
}
}
테스트 메소드가 실행되기 전에 @Before 어노테이션이 붙은 메소드가 실행된다.
MockitoAnnotations.initMocks(this);
현재 객체에서 @Mock이 붙은 필드를 목 객체로 초기화 시킨다.
mockMvc = MockMvcBuilders.standaloneSetup(guestbookApiController).build();
MockMVC타입의 변수 mockMvc를 초기화 한다.
guestbookApiController를 테스트 하기 위한 MockMvc객체를 생성합니다.
Guestbook guestbook1 = new Guestbook();
guestbook1.setId(1L);
guestbook1.setRegdate(new Date());
guestbook1.setContent("hello");
guestbook1.setName("kim");
List<Guestbook> list = Arrays.asList(guestbook1);
when(guestbookService.getGuestbooks(0)).thenReturn(list);
List<Guestbook>타입의 변수 list를 초기화하고 해당 list에 방명록 한 건을 저장한다.
when(guestbookService.getGuestbooks(0)).thenReturn(list);
위의 문장은 아래와 같이 동작한다.
when(목객체.목객체메소드호출()).threnReturn(목객체 메소드가 리턴 할 값)
guestbookService.getGuestbook(0) 이 호출되면 위에서 선언된 list객체가 리턴 되도록 설정합니다.
RequestBuilder reqBuilder
= MockMvcRequestBuilders.get("/guestbooks").contentType(MediaType.APPLICATION_JSON);
MockMvcRequestBuilders를 이용해 MockMvc에게 호출할 URL을 생성한다.
get(“/guestbooks”)
GET 방식으로 /guestbooks 경로를 호출하라는 의미이다.
contentType(MediaType.APPLICATION_JSON);
application/json 형식으로 api를 호출한다는 의미이다.
즉 2가지가 합치면 application/json형식으로 /guestbooks를 GET방식으로 호출한다는 것을 뜻한다.
이러한 URL정보를 가진 reqBuilder를 생성한다.
mockMvc.perform(reqBuilder).andExpect(status().isOk()).andDo(print());
mockMvc.perform(reqBuilder) 는 reqBuilder에 해당하는 URL에 대한 요청을 보냈다는 것을 의미한다.
andExpect(status().isOk()) 는 mockMvc에 위해 URL이 실행되고
상태코드값이 200이 나와야 한다는 것을 의미한다.
andDo(print())는 처리 내용을 출력하게 된다.
.andExpect(jsonPath("$.name").value("kim")) 과 같은 문장을 사용하여
Json 결과에 “name”:”kim”이 있을 경우에만 성공이 될 수 있도록 할 수도 있다.
이 경우 jsonPath에 대한 라이브러리가 pom.xml파일에 추가 되어야 한다.
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<version>2.4.0</version>
</dependency>
여기까지 실행되면 화면에 다음과 같은 결과가 출력되면서 테스트가 성공하게 된다.
verify(guestbookService).getGuestbooks(0);
Guestbook 목객체의 getGuestbooks(0)메소드가 호출됐다면 검증은 성공하게 된다.
방명록을 삭제하는 Web API Test
@Test
public void deleteGuestbook() throws Exception {
Long id = 1L;
when(guestbookService.deleteGuestbook(id, "127.0.0.1")).thenReturn(1);
RequestBuilder reqBuilder = MockMvcRequestBuilders.delete("/guestbooks/" + id).contentType(MediaType.APPLICATION_JSON);
mockMvc.perform(reqBuilder).andExpect(status().isOk()).andDo(print());
verify(guestbookService).deleteGuestbook(id, "127.0.0.1");
}
RequestBuilder reqBuilder = MockMvcRequestBuilders.delete("/guestbooks/" + id).contentType(MediaType.APPLICATION_JSON);
“/guestbooks/” + id 경로를 DELETE방식으로 호출하기 위한 경로 정보를 가지고 있는 reqBuilder객체를 생성한다.
mockMvc.perform(reqBuilder).andExpect(status().isOk()).andDo(print());
reqBuilder에 해당하는 URL을 호출한 후, 상태 코드가 200일 경우 성공하고 결과를 출력하게 된다.
verify(guestbookService).deleteGuestbook(id, "127.0.0.1");
guestbookService 목객체의 deleteGuestbook(id, “127.0.0.1”)메소드가
Web API가 동작하면서 호출되었다면 성공하게 된다.
Mockito 메소드 종류
Mock() - 모의 객체를 생성하는 역할
when() - 협력객체 메소드 반환 값을 지정해주는 역할(stub)
verify() - stub안의 협력객체 메소드가 호출 되었는지 확인
times() - 지정한 횟수 만큼 협력 객체 메소드가 호출 되었는지 확인
never() - 호출되지 않았는지 여부 검증
atLeastOnce() - 최소 한 번은 특정 메소드가 호출되었는지 확인
atLeast() - 최소 지정한 횟수 만큼 호출되었는지 확인
atMost() - 최대 지정한 횟수 만큼 호출되었는지 확인
clear() - stub을 초기화 한다
timeOut() - 지정된 시간 안에 호출되었는지 확인
참조 : 부스트코스 웹 백엔드 과정
'Web developer > Spring' 카테고리의 다른 글
[Spring] 스프링 시큐리티 이해하기 (0) | 2021.08.21 |
---|---|
[Spring] 기본 설정 + Spring Security 설정 (0) | 2021.08.20 |
[Spring] Swagger를 사용하여 Web API 문서화 해보기 (0) | 2021.08.02 |
[Spring] IoC & DI (0) | 2021.07.27 |
[Spring] Annotation @정리2 (0) | 2021.04.18 |
댓글