구현 Package 및 Class
입력값 제한하기
우리는 이벤트를 등록하기에 앞서, Id
값은 자동으로 이벤트 등록시 자동으로 등록이 되야하고,
free
무료인지 아닌지 여부, offline
오프라인인지 아닌지에 대한 여부 또한, 직접 등록이 아닌
입력받은 데이터로 계산해서 등록이 되어야 하는 요소이다.
이러한 입력값을 제한하기 위해, 우리는 입력 해야하는 값만을 다루는 DTO
를 만들 것이다.
EventDTO 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| package me.hantomas.restapi.events;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class EventDto {
private String name;
private String description;
private LocalDateTime beginEnrollmentDateTime;
private LocalDateTime closeEnrollmentDateTime;
private LocalDateTime beginEventDateTime;
private LocalDateTime endEventDateTime;
private String location;
private int basePrice;
private int maxPrice;
private int limitOfEnrollment;
}
|
EventController 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| package me.hantomas.restapi.events;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import java.net.URI;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
private final EventRepository eventRepository;
public EventController(EventRepository eventRepository){
this.eventRepository = eventRepository;
}
@PostMapping
public ResponseEntity createEvent(@RequestBody EventDto eventDto){
Event event = Event.builder()
.name(eventDto.getName())
.description(eventDto.getDescription())
// ...
.build(); // 추가
Event newEvent = this.eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(newEvent);
}
}
|
EventDto
를 객체로 사용하고 EventRepository
로 DB에 저장하기 위해서는 EventDto
를 Event
객체로 변환 하는 과정이 필요하다. 그러기 위해서는 위 추가한 코드와 같이 데이터를 하나하나 변환해 주는 방법이 있지만, 이는 데이터가 많아질수록 변환해 줘야 하는 값도 많아짐을 의미한다.- 이를 한번에 가능하도록 하는
ModelMapper
가 있다.
pom.xml 수정
1
2
3
4
5
| <dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.3.1</version>
</dependency>
|
ModelMapper
를 사용하기 위해 의존성을 추가해 준다.
RestApiApplication 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| package me.hantomas.restapi;
import org.modelmapper.ModelMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class RestApiApplication {
public static void main(String[] args) {
SpringApplication.run(RestApiApplication.class, args);
}
@Bean
public ModelMapper modelMapper(){
return new ModelMapper();
}
}
|
RestApiApplication
에 ModelMapper
를 빈으로 등록해 준다.
ModelMapper를 사용한 EventController 리팩토링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| package me.hantomas.restapi.events;
import org.modelmapper.ModelMapper;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import java.net.URI;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {
private final EventRepository eventRepository;
private final ModelMapper modelMapper; // 추가
public EventController(EventRepository eventRepository, ModelMapper modelMapper){ // 추가
this.eventRepository = eventRepository;
this.modelMapper = modelMapper; // 추가
}
@PostMapping
public ResponseEntity createEvent(@RequestBody EventDto eventDto){
Event event = modelMapper.map(eventDto, Event.class); // 추가
Event newEvent = this.eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(newEvent);
}
}
|
ModelMapper
를 생성자 주입 방식으로 주입해준다.Event event = modelMapper.map(eventDto, Event.class);
: eventDto
의 값을 Event
클래스에 인스턴스로 만들어 준다.
결과 (오류)
객체 변환도 잘 해줬는데 위와 같이 NullPointException 오류가 나고 말았다.
그 이유는,
1
2
3
4
5
6
7
8
9
10
| @PostMapping
public ResponseEntity createEvent(@RequestBody EventDto eventDto){
Event event = modelMapper.map(eventDto, Event.class);
Event newEvent = this.eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(newEvent);
}
|
EventController
에서 우리가 save()
에 전달한 객체는 createEvent()
메서드 내에서 새로 만들어준 객체다. 그래서 Mockito.when(eventRepository.save(event)).thenReturn(event);
에서 Stubbing해준 event
객체와 다르기 때문에 Mocking이 안되었고, 위와 같은 NullPointException 오류가 발생한 것이다.
오류 수정
이를 해결하기 위해서는 더 이상 슬라이스 테스트가 아니여야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| package me.hantomas.restapi.events;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringRunner.class)
@SpringBootTest // 추가
@AutoConfigureMockMvc // 추가
//@WebMvcTest
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
// @MockBean
// EventRepository eventRepository
@Test
public void createEvent() throws Exception{
Event event = Event.builder()
.id(100) // 추가
.name("Spring")
.description("REST API Development With Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
.closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
.beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
.endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("강남역 D2 스타트업 팩토리")
.free(true) // 추가
.offline(false) // 추가
.eventStatus(EventStatus.PUBLISHED) // 추가
.build();
// Mockito.when(eventRepository.save(event)).thenReturn(event);
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(event))
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("id").exists())
.andExpect(header().exists(HttpHeaders.LOCATION))
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_UTF8_VALUE))
.andExpect(jsonPath("id").value(Matchers.not(100))) // 추가
.andExpect(jsonPath("free").value(Matchers.not(true))) // 추가
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name())) // 추가
;
}
}
|
- 더이상 슬라이스 테스트를 진행하지 않기 때문에
@WebMvcTest
를 삭제하고, @SpringBootTest
와 @AutoConfigureMockMvc
를 추가해 준다. @SpringBootTest
는 Mocking을 한 DispatcherServlet을 만들도록 하는 기본값들이 내재되어 있기 때문에 MockMvc로 계속해서 테스트가 가능하다. @SpringBootTest
를 사용하면 애플리케이션을 실행했을 때와 가장 근사한 형태로 테스트를 수행할 수 있다. 실제 웹 테스트를 구현할 때는 @SpringBootTest
사용하는데, 그렇지 않으면 Mocking해줘야 할 것들이 너무 많아지기 때문이다.- Mocking에 관련된 것들은 삭제해준다.
결과 (성공)
이렇게 하면 id(100)
, free(true)
, offline(false)
,eventStatus(EventStatus.PUBLISHED)
로 요청을 넣어 줬지만,
Dto
는 이 데이터들을 다루지 않기 때문에 이 입력들을 무시하게된다.
따라서, .andExpect(jsonPath("id").value(Matchers.not(100)))
,.andExpect(jsonPath("free").value(Matchers.not(true)))
,.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
를 만족시키기 때문에 성공적인 응답값을 출력한다.
입력값 이외의 입력값들에 대한 오류 발생시키기
입력값을 제한하는 방법도 있지만, 입력값 이외의 입력에 대한 오류를 발생시키는 방법도 있다.
EventControllerTests 수정 (입력값 이외의 입력 Bad Request 오류 발생)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
| package me.hantomas.restapi.events;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
public void createEvent() throws Exception{ // 수정
EventDto event = EventDto.builder()
.name("Spring")
.description("REST API Development With Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
.closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
.beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
.endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("강남역 D2 스타트업 팩토리")
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(event))
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("id").exists())
.andExpect(header().exists(HttpHeaders.LOCATION))
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_UTF8_VALUE))
.andExpect(jsonPath("id").value(Matchers.not(100)))
.andExpect(jsonPath("free").value(Matchers.not(true)))
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
;
}
@Test
public void createEvent_Bad_Request() throws Exception{ // 추가
Event event = Event.builder()
.id(100)
.name("Spring")
.description("REST API Development With Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2024,02,19,14,21))
.closeEnrollmentDateTime(LocalDateTime.of(2024,02,20,14,21))
.beginEventDateTime(LocalDateTime.of(2024,02,21,14,21))
.endEventDateTime(LocalDateTime.of(2024,02,22,14,21))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("강남역 D2 스타트업 팩토리")
.free(true)
.offline(false)
.eventStatus(EventStatus.PUBLISHED)
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(event))
)
.andDo(print())
.andExpect(status().isBadRequest())
;
}
}
|
createEvent()
는 성공적인 방법에 대한 테스트로 바꿔준다.
(Event
객체와 그 프로퍼티들을 넘겨주는 방법에서 👉 EventDto
객체와 그 프로퍼티들을 넘겨주는 방법)createEvent_Bad_Request()
이라는 Test를 생성하고, EventController
의 createEvent()
메서드에 Event객체와 그 프로퍼티들을 넘겨 주도록 한다. 여기에는 id(100)
,free(true)
,offline(false)
,eventStatus(EventStatus.PUBLISHED)
의 프로퍼티들이 포함되어 있다..andExpect(status().isBadRequest())
를 통해 Bad Request 에러가 발생하는지 확인한다.
application.properties 설정 추가
application.properties
에서 ObjectMapper
를 커스터 마이징한다.
1
| spring.jackson.deserialization.fail-on-unknown-properties=true
|
- 위는 springboot가 제공하는 properties를 사용한 objectMapper 확장 기능(커스터마이징)이다.
ObjectJson
을 object
로 변환하는 과정(json문자열
을 object
로 변환하는 과정)을 deserialization이라고 부르고,
object
(객체를) json 문자열
로 변환하는 과정을 serialization이라고 부른다.spring.jackson.deserialization.fail-on-unknown-properties=true
는 deserialization
할 때 unknown properties
가 있으면 실패하라는 설정이다.EventController
의 createEvent()
메서드에서는 EventDto
를 객체(object)로, 그리고 그에 속하는 프로퍼티들(properties)을 사용하는데, 테스트가 id(100)
,free(true)
,offline(false)
,eventStatus(EventStatus.PUBLISHED)
프로퍼티들을 넘기고, EventDto는 이 프로퍼티들의 입력을 받지 않아, Error를 발생시킨다.
결과 (성공)
정리
입력 받을 값만 걸러서 받을 것인지, 입력값 이외에 다른 값들을 보내는 경우에는 에러를 발생하게 할건지는 선택이다.