FoodControllerTests 클래스에서 자주 사용되는 테스트 객체를 @Before 애너테이션으로 테스트 전에 생성되도록 리팩토링하고 findAllFoods 메서드도 수정한다.
FoodController.findAllFoods 메서드를 통해서 목록표를 리턴받으면 그중에서 특정 Food의 상세정보를 요청할 수 있다. 다음과 같이 테스트 메서드를 작성한다.
findById 메서드는 jpaFoodRepository의 findOne 메서드를 호출해서 단일 행 결과를 리턴한다. 그리고 /foods/1 과 같은 url을 get 방식으로 호출하여 결과가 milk객체의 json 타입으로 돌아오는것을 테스트한다.
다음은 FoodController 의 구현부분이다.
@GetMapping 은 @RequestMapping(method=RequestMethod.GET) 과 동일한 애너테이션이다. @GetMapping 내의 URL 매핑은 "/{id}"로 되어있다. 이는 URI 템플릿 변수로 RESTful 웹서비스 표준의 일부이다. 동적으로 변하는 id값은 @PathVariable 애너테이션으로 선언된 파라미터 long id 변수에 할당할 수 있다. 컨트롤러 내부는 간단하게 repository의 findOne 결과를 반환한다.
식품을 새로 등록하려면 Post 방식을 사용한다. RESTful 웹 서비스는 URL, HTTP 방식, HTTP Status 세가지로 작업을 구분한다. 요청에는 HTTP 방식과 URL로 매핑할 컨트롤러를 식별하고, 결과를 돌려줄 때 HttpStatus 를 보내줌으로 요청한 작업에 대한 결과를 판단한다.
HTTP방식은 다음과 같다.
JpaFoodService 클래스를 MockBean 객체로 등록한다. postSave 테스트 메서드를 보면, 컨트롤러에서 jpaFoodService.save 메서드를 사용할 것으로 조건을 지정한다.
이때, save메서드에 파라미터로는 Food.class 타입인 어떠한 객체라도 들어온다는 조건이 주어졌을때, 리턴될 값은 milk 인스턴스로 선언한다.
mockMvc가 post 방식으로 데이터를 전송하는데, content는 milk인스턴스를 json형태로 변환한 값을 전달하고, contentType은 application/json; utf8 형태라고 지정한다.
응답결과로는 상태코드가 isCreated: 201로 예상되며, body에 milk인스턴스가 json형태로 파싱되서 전달받게 된다.
마지막으로 새롭게 등록된 자원(Resource)에 대한 접근할 수 있는 경로(redirectUrl) 의 urlPattern은 Path(**)에 foods/숫자 형식의 AntPattern 으로 예측한다.
컨트롤러 메서드를 확인하면 무슨 말인지 이해가 빠르다.
우선 비지니스 로직(jpaFoodService.save) 을 수행한다.
MvcUriComponentsBuilder는 응답 본문에 전송될 새로운 자원의 URI를 만들어준다. fromController는 매핑할 컨트롤러를 정하고, path는 경로를, buildAndExpand는 URI 템플릿 변수를 정하고 build한다. toUri로 uri 형태로 리턴해준다.
ResponseEntity 클래스는 Response Body Entity를 만들어서 리턴할 수 있도록 해주는데, 응답 body와 Http Status, redirectUrl등의 정보를 전달할 수 있다.
postFood 메서드에서는 created 정적 메서드를 통해 HttpStatus(Created: 201)을 전달하면서 새로운 자원에 대한 접근 URI도 파라미터로 받고있다. 체인링으로 body메서드는 Response Body 부분이며, 새롭게 생성된 객체(food)를 파라미터로 받는다. 테스트를 실행해보면 정상적으로 수행한다.
앞서 작성했던 findById 메서드는 응답 본문에 바로 Food객체를 반환했었다. 테스트도 성공한 상태이니 ResponseEntity를 반환해서 좀더 명확한 정보를 리턴하도록 리팩토링을 한다.
ResponseEntity 타입을 리턴한다. food 를 불러오는데 food가 null이면 notFound를 호출하고 아닌경우 ok를 응답하면서 응답 본문에 food객체를 파라미터로 전달한다.
Put 기능은 Post와 비슷하므로 바로 테스트와 구현을 한다.
컨트롤러에서 수정할 데이터가 있는지 먼저 조회하는 findOne메서드가 주어지고, Service로 save한다.
put을 수행할 Uri 를 빌더로 만들어주고, 응답으로 NoContent, 응답 본문은 "" 공백으로 돌아온다.
다음은 컨트롤러 메서드 구현이다.
컨트롤러는 URI 템플릿을 위한 PathVariable이 메서드 파라미터로 받고, 두번째 파라미터로 요청 바디를 food 타입으로 받는다. 데이터가 있는지 조회하고 없으면 notFound 상태로 반환하고 아닐경우 수정하고, noContent로 응답결과를 리턴한다.
Delete 를 테스트하기 전에 먼저 FoodService 인터페이스에 remove(Long id) 추상메서드를 추가로 오버로딩한다. 그러면 JdbcFoodService, MemoryFoodService, JpaFoodService 세개의 구현클래스에 전부 메서드 오버라이딩을 해주어야한다.
먼저 MemoryFoodService 부터 테스트를 작성하고 메서드를 구현한다.
milk, cola 두개의 데이터를 저장한 후에 첫번째 milk 객체의 ID(1L)를 삭제한 후 전체 Food 리스트의 개수를 검증하고 있다. 다음은 구현 클래스이다.
Iterator로 food 리스트를 반복하면서 파라미터로 전달받은 ID와 동일한 객체를 찾아서 삭제하고 반복문을 중단한다.
다음은 JdbcFoodService 테스트 메서드이다.
lastId 변수를 선언해서 초기화한다. 두개의 Food 데이터를 저장하고 마지막 저장한 Food의 Key값 ID를 lastId 변수에 대입한다.
해당 Id를 remove메서드의 파라미터로 전달하고 전체 Food 리스트의 개수를 검증한다.
다음은 JdbcFoodService 구현 메서드이다.
jdbcFoodRepository에 remove 메서드를 호출하는데 Food 객체를 생성해서 Id를 set해준다.
다음은 JpaFoodRepository 테스트 메서드와 구현이다.
JpaFoodService 에서는 remove메서드는 JpaFoodRepository에 단순히 위임해버린다.
이제 정리가 끝났으니 Delete 테스트 메서드와 구현을 작성한다.
Put 테스트 메서드와 거의 동일한데 차이점은 요청에 내용이 없고 URL 호출만 있다는 점이다.
컨트롤러 메서드를 구현한다.
@DeleteMapping 애너테이션을 메서드에 선언함으로 deleteFood 메서드는 Delete 방식으로 매핑됨을 확인할 수 있다. URI 템플릿을 위한 PathVariable로 long id를 받고있다. 데이터가 존재하지 않을 경우 notFound 상태를 반환하고 아닐 경우 삭제 로직을 실행한 뒤에 noContent 상태를 반환한다.
이전글: 냉장고 가계부 프로젝트 6
다음글: 냉장고 가계부 프로젝트 8
private Food milk; private static final Long ID = 1L; @Before public void setUp() { milk = new Food("파스퇴르 우유 1.8L", 1, "2018-09-28"); milk.setId(ID); }
FoodController.findAllFoods 메서드를 통해서 목록표를 리턴받으면 그중에서 특정 Food의 상세정보를 요청할 수 있다. 다음과 같이 테스트 메서드를 작성한다.
@Test public void findById() throws Exception { given(jpaFoodRepository.findOne(ID)).willReturn(milk); mvc.perform(get("/foods/" + ID)) .andExpect(status().isOk()) .andExpect(content().json(mapper.writeValueAsString(milk))); }
findById 메서드는 jpaFoodRepository의 findOne 메서드를 호출해서 단일 행 결과를 리턴한다. 그리고 /foods/1 과 같은 url을 get 방식으로 호출하여 결과가 milk객체의 json 타입으로 돌아오는것을 테스트한다.
다음은 FoodController 의 구현부분이다.
@GetMapping("/{id}") public Food findById(@PathVariable final long id) { return jpaFoodRepository.findOne(id); }
@GetMapping 은 @RequestMapping(method=RequestMethod.GET) 과 동일한 애너테이션이다. @GetMapping 내의 URL 매핑은 "/{id}"로 되어있다. 이는 URI 템플릿 변수로 RESTful 웹서비스 표준의 일부이다. 동적으로 변하는 id값은 @PathVariable 애너테이션으로 선언된 파라미터 long id 변수에 할당할 수 있다. 컨트롤러 내부는 간단하게 repository의 findOne 결과를 반환한다.
식품을 새로 등록하려면 Post 방식을 사용한다. RESTful 웹 서비스는 URL, HTTP 방식, HTTP Status 세가지로 작업을 구분한다. 요청에는 HTTP 방식과 URL로 매핑할 컨트롤러를 식별하고, 결과를 돌려줄 때 HttpStatus 를 보내줌으로 요청한 작업에 대한 결과를 판단한다.
HTTP방식은 다음과 같다.
- HEAD/GET: URL은 대표URL(/foods) 이다. GET은 서버에서 데이터를 얻어오는것이다. HEAD는 Response Body가 없다. 상태(OK: 200)
- POST: URL은 대표URL(/foods) 이다. POST는 서버에 새로운 데이터를 등록한다. Response Body 에 새롭게 등록된 데이터에 접근할 수 있는 Redirect URI가 전달된다. 상태(Created: 201)
- PUT: URL은 특정 데이터 접근URL(/foods/1) 이다. PUT은 서버에 기존 데이터를 업데이트한다. Response Body가 없다. 상태는(NoContent: 204)
- PATCH: PUT과 비슷하다, 차이점은 부분 업데이트라는 점이다. 상태(Ok: 200, NoContent: 204)
- DELETE: URL은 특정 데이터 접근URL(/foods/1) 이다. ResponseBody는 삭제된걸 보내거나 없거나 이다. 상태(Ok: 200/NoContent: 204)
@MockBean private JpaFoodService jpaFoodService; @Test public void postSave() throws Exception { given(jpaFoodService.save(any(Food.class))).willReturn(milk); mvc.perform(post("/foods") .content(mapper.writeValueAsString(milk)) .contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isCreated()) .andExpect(content().json(mapper.writeValueAsString(milk))) .andExpect(redirectedUrlPattern("**/foods/{id:\\d+}")); }
JpaFoodService 클래스를 MockBean 객체로 등록한다. postSave 테스트 메서드를 보면, 컨트롤러에서 jpaFoodService.save 메서드를 사용할 것으로 조건을 지정한다.
이때, save메서드에 파라미터로는 Food.class 타입인 어떠한 객체라도 들어온다는 조건이 주어졌을때, 리턴될 값은 milk 인스턴스로 선언한다.
mockMvc가 post 방식으로 데이터를 전송하는데, content는 milk인스턴스를 json형태로 변환한 값을 전달하고, contentType은 application/json; utf8 형태라고 지정한다.
응답결과로는 상태코드가 isCreated: 201로 예상되며, body에 milk인스턴스가 json형태로 파싱되서 전달받게 된다.
마지막으로 새롭게 등록된 자원(Resource)에 대한 접근할 수 있는 경로(redirectUrl) 의 urlPattern은 Path(**)에 foods/숫자 형식의 AntPattern 으로 예측한다.
컨트롤러 메서드를 확인하면 무슨 말인지 이해가 빠르다.
@Autowired private JpaFoodService jpaFoodService; @PostMapping public ResponseEntity<Food> postFood(@RequestBody final Food food) { jpaFoodService.save(food); URI location = MvcUriComponentsBuilder.fromController(getClass()) .path("/{id}") .buildAndExpand(food.getId()) .toUri(); return ResponseEntity.created(location).body(food); }
우선 비지니스 로직(jpaFoodService.save) 을 수행한다.
MvcUriComponentsBuilder는 응답 본문에 전송될 새로운 자원의 URI를 만들어준다. fromController는 매핑할 컨트롤러를 정하고, path는 경로를, buildAndExpand는 URI 템플릿 변수를 정하고 build한다. toUri로 uri 형태로 리턴해준다.
ResponseEntity 클래스는 Response Body Entity를 만들어서 리턴할 수 있도록 해주는데, 응답 body와 Http Status, redirectUrl등의 정보를 전달할 수 있다.
postFood 메서드에서는 created 정적 메서드를 통해 HttpStatus(Created: 201)을 전달하면서 새로운 자원에 대한 접근 URI도 파라미터로 받고있다. 체인링으로 body메서드는 Response Body 부분이며, 새롭게 생성된 객체(food)를 파라미터로 받는다. 테스트를 실행해보면 정상적으로 수행한다.
앞서 작성했던 findById 메서드는 응답 본문에 바로 Food객체를 반환했었다. 테스트도 성공한 상태이니 ResponseEntity를 반환해서 좀더 명확한 정보를 리턴하도록 리팩토링을 한다.
@GetMapping("/{id}") public ResponseEntity<Food> findById(@PathVariable final long id) { Food food = jpaFoodRepository.findOne(id); if(food == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(food); }
ResponseEntity 타입을 리턴한다. food 를 불러오는데 food가 null이면 notFound를 호출하고 아닌경우 ok를 응답하면서 응답 본문에 food객체를 파라미터로 전달한다.
Put 기능은 Post와 비슷하므로 바로 테스트와 구현을 한다.
@Test public void put() throws Exception { given(jpaFoodRepository.findOne(anyLong())).willReturn(milk); given(jpaFoodService.save(any(Food.class))).willReturn(milk); URI uri = UriComponentsBuilder.fromUriString("/foods/{id}").buildAndExpand(ID).toUri(); mvc.perform(MockMvcRequestBuilders.put(uri) .content(mapper.writeValueAsString(milk)) .contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isNoContent()) .andExpect(content().string("")); }
컨트롤러에서 수정할 데이터가 있는지 먼저 조회하는 findOne메서드가 주어지고, Service로 save한다.
put을 수행할 Uri 를 빌더로 만들어주고, 응답으로 NoContent, 응답 본문은 "" 공백으로 돌아온다.
다음은 컨트롤러 메서드 구현이다.
@PutMapping("/{id}") public ResponseEntity<?> updateFood(@PathVariable final long id, @RequestBody final Food food) { if(jpaFoodRepository.findOne(id) == null) { return ResponseEntity.notFound().build(); } jpaFoodService.save(food); return ResponseEntity.noContent().build(); }
컨트롤러는 URI 템플릿을 위한 PathVariable이 메서드 파라미터로 받고, 두번째 파라미터로 요청 바디를 food 타입으로 받는다. 데이터가 있는지 조회하고 없으면 notFound 상태로 반환하고 아닐경우 수정하고, noContent로 응답결과를 리턴한다.
Delete 를 테스트하기 전에 먼저 FoodService 인터페이스에 remove(Long id) 추상메서드를 추가로 오버로딩한다. 그러면 JdbcFoodService, MemoryFoodService, JpaFoodService 세개의 구현클래스에 전부 메서드 오버라이딩을 해주어야한다.
먼저 MemoryFoodService 부터 테스트를 작성하고 메서드를 구현한다.
@Test public void removeById() { Long id = 1L; foods = Arrays.asList(milk, cola); for(Food food : foods) { memoryFoodService.save(food); } memoryFoodService.remove(id); foods = memoryFoodService.findAll(); assertThat(foods.size(), equalTo(1)); }
milk, cola 두개의 데이터를 저장한 후에 첫번째 milk 객체의 ID(1L)를 삭제한 후 전체 Food 리스트의 개수를 검증하고 있다. 다음은 구현 클래스이다.
@Override public void remove(Long id) { Iterator<Food> itr = foods.iterator(); while(itr.hasNext()) { Food food = itr.next(); if(food.getId() == id) { itr.remove(); break; } } }
Iterator로 food 리스트를 반복하면서 파라미터로 전달받은 ID와 동일한 객체를 찾아서 삭제하고 반복문을 중단한다.
다음은 JdbcFoodService 테스트 메서드이다.
@Test public void removeById() { Long lastId = 0L; List<Food> foods = Arrays.asList(new Food("코카콜라 500mL", 2, "2018-10-30"), new Food("파스퇴르 우유 1.8L", 1, "2018-09-28")); for(Food food : foods) { jdbcFoodService.save(food); lastId = food.getId(); } jdbcFoodService.remove(lastId); assertThat(jdbcFoodRepository.findAll().size(), equalTo(1)); }
lastId 변수를 선언해서 초기화한다. 두개의 Food 데이터를 저장하고 마지막 저장한 Food의 Key값 ID를 lastId 변수에 대입한다.
해당 Id를 remove메서드의 파라미터로 전달하고 전체 Food 리스트의 개수를 검증한다.
다음은 JdbcFoodService 구현 메서드이다.
@Override public void remove(Long id) { Food food = new Food(); food.setId(id); jdbcFoodRepository.remove(food); }
jdbcFoodRepository에 remove 메서드를 호출하는데 Food 객체를 생성해서 Id를 set해준다.
다음은 JpaFoodRepository 테스트 메서드와 구현이다.
@Test public void remove() { Food cola = new Food("코카콜라 500mL", 2, "2018-10-30"); jpaFoodRepository.save(cola); assertThat(jpaFoodRepository.findAll().size()).isEqualTo(1); jpaFoodRepository.delete(cola.getId()); assertThat(jpaFoodRepository.findAll().size()).isEqualTo(0); } @Override public void remove(Long id) { jpaFoodRepository.delete(id); }
JpaFoodService 에서는 remove메서드는 JpaFoodRepository에 단순히 위임해버린다.
이제 정리가 끝났으니 Delete 테스트 메서드와 구현을 작성한다.
@Test public void delete() throws Exception { given(jpaFoodRepository.findOne(anyLong())).willReturn(milk); URI uri = UriComponentsBuilder.fromUriString("/foods/{id}").buildAndExpand(ID).toUri(); mvc.perform(MockMvcRequestBuilders.delete(uri) .contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isNoContent()) .andExpect(content().string("")); }
Put 테스트 메서드와 거의 동일한데 차이점은 요청에 내용이 없고 URL 호출만 있다는 점이다.
컨트롤러 메서드를 구현한다.
@DeleteMapping("/{id}") public ResponseEntity<?> deleteFood(@PathVariable final long id) { if(jpaFoodRepository.findOne(id) == null) { return ResponseEntity.notFound().build(); } jpaFoodService.remove(id); return ResponseEntity.noContent().build(); }
@DeleteMapping 애너테이션을 메서드에 선언함으로 deleteFood 메서드는 Delete 방식으로 매핑됨을 확인할 수 있다. URI 템플릿을 위한 PathVariable로 long id를 받고있다. 데이터가 존재하지 않을 경우 notFound 상태를 반환하고 아닐 경우 삭제 로직을 실행한 뒤에 noContent 상태를 반환한다.
이전글: 냉장고 가계부 프로젝트 6
다음글: 냉장고 가계부 프로젝트 8