스프링 기반 REST API 개발 - 인프런 | 강의
다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발하는 강의입니다., 스프링으로 REST를 따르는 API를 만들어보...
www.inflearn.com
2.1 Event API Test Class
EventControllerTests.java
@ExtendWith(SpringExtension.class)
@WebMvcTest
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Test
public void createEvent() throws Exception {
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON))
.andExpect(status().isCreated());
}
}
- @WebMvcTest
- MockMvc 빈을 자동 설정 해주고, web 관련 빈만 등록해주는 슬라이싱 테스트용 어노테이션
- MockMvc
- 스프링 MVC 테스트의 핵심 클래스로, 웹 서버를 띄우지 않고도 스프링 MVC (DispatcherServlet)가 요청을 처리하는 과정을 확인할 수 있기 때문에 컨트롤러 테스트용으로 자주 쓰인다.
- Junit5을 사용해 테스트 코드를 작성할 때 Junit4의 @RunWith을 사용할 수 없는 문제
→ Junit5에서는 @ExtendWith 어노테이션을 제공
→ 사실 @SpringBootTest annotation 하나면 모든 것이 해결된다!
SpringBoot 3.x 버전에서 junit5 를 사용해서 테스트하는데 왜 RunWith가 먹히지 않고 필요가 없는가?
0. 이 글을 쓰는 이유 강의에서는 SpringBoot 2.x버전과 junit4를 붙여서 테스트를 진행하는데 내 환경은 SpringBoot 3.x버전이라 @RunWith가 먹히지 않았고 @SpringBootTest 어노테이션 하나로 퉁칠 수 있다고 한
developer-youn.tistory.com
- 'APPLICATION_JSON_UTF8' is deprecated
→ 'MediaType.APPLICATION_JSON'으로 수정
Deprecated 된 MediaType.APPLICATION_JSON_UTF8 | 개발자 이동욱
테스트 코드를 작성하다가, MediaType.APPLICATION_JSON_UTF8 부분이 Deprecated 된 것을 확인할 수 있었다. 밑줄로 표시까지 해줬는데, 그냥 대수롭지 않게 생각했던 것 같다. 그리고 개발자로서 이러한 부
dongwooklee96.github.io
2.2 Event 생성 API 구현: 201 응답 받기
EventController
@Controller
@RequestMapping(value = "/api/events/", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
@PostMapping
public ResponseEntity createEvent(@RequestBody Event event) {
URI createdUri = linkTo(EventController.class).slash("{id}").toUri();
event.setId(10);
return ResponseEntity.created(createdUri).body(event);
}
}
- event 객체의 id를 붙여 Location URI를 만들어서 반환한다. event의 Id는 일단 임의의 값으로 설정해준다.
EventControllerTest
@ExtendWith(SpringExtension.class)
@WebMvcTest
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
public void createEvent() throws Exception {
Event event = Event.builder()
.name("Spring")
.description("REST API Development with Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 23, 30))
.closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 24, 23, 59))
.beginEventDateTime(LocalDateTime.of(2023, 11, 25, 12, 30))
.endEventDateTime(LocalDateTime.of(2023, 11, 25, 18, 30))
.basePrice(10000)
.maxPrice(20000)
.limitOfEnrollment(100)
.location("공덕역 프론트원")
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(event)))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("id").exists());
}
}
- 임시 Event 객체를 builder 패턴을 이용해 만든 후, objectMapper를 활용해 event 객체를 JSON으로 변환한다.
- 테스트 내용
- 입력값들을 전달하면 JSON 응답으로 201 status가 나오는지 확인한다.
- Location Header에 생성된 event의 Id가 들어간 URI가 있는지 확인한다.
2.3 Event Repository
Event
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {
@Id @GeneratedValue
private Integer id;
private String name;
private String description;
private LocalDateTime beginEnrollmentDateTime;
private LocalDateTime closeEnrollmentDateTime;
private LocalDateTime beginEventDateTime;
private LocalDateTime endEventDateTime;
private String location; // (optional) 이게 없으면 온라인 모임
private int basePrice; // (optional)
private int maxPrice; // (optional)
private int limitOfEnrollment;
private boolean offline;
private boolean free;
@Enumerated(EnumType.STRING)
private EventStatus eventStatus;
}
- Event에 @Entity 어노테이션 추가
- @Enumerated(EnumType.STRING)으로 설정해 추루 ENUM값에 변경이 생겨도 문제가 발생하지 않게 한다.
EventController
@Controller
@AllArgsConstructor
@RequestMapping(value = "/api/events/", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
private final EventRepository eventRepository;
@PostMapping
public ResponseEntity createEvent(@RequestBody Event event) {
Event newEvent = eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(event);
}
}
- RequestBody의 event 객체를 EventRepository를 통해 저장한다.
EventControllerTests.java
@ExtendWith(SpringExtension.class)
@WebMvcTest
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
EventRepository eventRepository;
@Test
public void createEvent() throws Exception {
Event event = Event.builder()
.name("Spring")
.description("REST API Development with Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 23, 30))
.closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 24, 23, 59))
.beginEventDateTime(LocalDateTime.of(2023, 11, 25, 12, 30))
.endEventDateTime(LocalDateTime.of(2023, 11, 25, 18, 30))
.basePrice(10000)
.maxPrice(20000)
.limitOfEnrollment(100)
.location("공덕역 프론트원")
.build();
event.setId(10);
Mockito.when(eventRepository.save(event)).thenReturn(event);
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.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_VALUE));
}
}
- Mockito를 이용해 repository의 save()가 호출되면 event 엔티티를 반환하도록 한다.
- 테스트 내용
- id가 DB에 저장될 때 생성된 값으로 나오는지 확인한다.
2.4 입력값 제한하기
id값이나 입력 받은 값들로 계산해야 하는 값들은 유저에게 입력받지 못하도록 제한해야 한다. 이를 위해, EventDto를 만들어 적용해보자.
EventController.js
@Controller
@AllArgsConstructor
@RequestMapping(value = "/api/events/", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
private final EventRepository eventRepository;
private final ModelMapper modelMapper;
@PostMapping
public ResponseEntity createEvent(@RequestBody EventDto eventDto) {
Event event = modelMapper.map(eventDto, Event.class);
Event newEvent = eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(event);
}
}
- EventDto 형태로 전달받은 것을 Event 객체로 변환해주기 위해 ModelMapper를 사용해 준다.
- ModelMapper를 사용하지 않는다면 Builder로 하나하나 값을 매핑해주어야 하는데, ModelMapper를 사용하면 간단히 매핑이 가능하다.
@SpringBootTest 어노테이션을 이용해 슬라이스 테스트가 아닌 통합 테스트로 변경하자.
EventControllerTests.js
@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
public void createEvent() throws Exception {
Event event = Event.builder()
.id(100)
.name("Spring")
.description("REST API Development with Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 23, 30))
.closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 24, 23, 59))
.beginEventDateTime(LocalDateTime.of(2023, 11, 25, 12, 30))
.endEventDateTime(LocalDateTime.of(2023, 11, 25, 18, 30))
.basePrice(10000)
.maxPrice(20000)
.limitOfEnrollment(100)
.location("공덕역 프론트원")
.free(true)
.offline(false)
.eventStatus(EventStatus.PUBLISHED)
.build();
// Mockito.when(eventRepository.save(event)).thenReturn(event);
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.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_VALUE))
.andExpect(jsonPath("id").value(Matchers.not(100)))
.andExpect(jsonPath("free").value(Matchers.not(true)))
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()));
}
}
- 모킹해준 것을 삭제하고, 슬라이스 테스트가 아닌 통합 테스트로 변경해 eventRepository에 저장된 event를 사용할 수 있도록 한다.
- 테스트 내용
- 받기로 한 값(EventDto에 있는 값들) 말고 다른값까지 같이 들어온 경우, 이외의 값은 무시한다.
2.5 입력값 이외에 에러 발생
입력값 이외의 값이 들어오면 Bad Request를 반환하게 수정해보자.
- ObjectMapping을 커스터마이즈해 deserialization(json 문자열을 객체로 변환) 시에 unknown properties가 존재하면 실패하도록 설정한다.
application.properties
spring.jackson.deserialization.fail-on-unknown-properties=true
- 정해진 입력값 이외의 값이 들어오면 Bad Request(400)를 반환한다.
2.6 Bad Request 처리
정해진 입력값들을 보내는데 그 값이 비어있는 경우에 Bad Request 처리를 해보자.
현재는 정해진 입력값대로 보내긴 하는데, 그 안에 값이 비어진 채로 보내면 201 처리가 된다. 이를 Bad Request를 반환하도록 수정해보자.
EventDto
@Data @Builder
@NoArgsConstructor @AllArgsConstructor
public class EventDto {
@NotEmpty
private String name;
@NotEmpty
private String description;
@NotNull
private LocalDateTime beginEnrollmentDateTime;
@NotNull
private LocalDateTime closeEnrollmentDateTime;
@NotNull
private LocalDateTime beginEventDateTime;
@NotNull
private LocalDateTime endEventDateTime;
private String location; // (optional) 이게 없으면 온라인 모임
@Min(0)
private int basePrice; // (optional)
@Min(0)
private int maxPrice; // (optional)
@Min(0)
private int limitOfEnrollment;
}
EventController.js
@Controller
@AllArgsConstructor
@RequestMapping(value = "/api/events/", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
private final EventRepository eventRepository;
private final ModelMapper modelMapper;
@PostMapping
public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
Event event = modelMapper.map(eventDto, Event.class);
Event newEvent = eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(event);
}
}
- Controller에서 @Valid 어노테이션을 통해 EventDto에 바인딩을 할 때 어노테이션들(@NotEmpty, @NotNull 등)을 참고해 검증을 수행한다. 그리고 검증 수행 결과를 Errors 타입의 객체에 담아 반환한다.
EventControllerTests.java
@Test
public void createEvent_Bad_Request_Empty_Input() throws Exception {
EventDto eventDto = EventDto.builder().build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.andExpect(status().isBadRequest());
}
- 값이 빈 EventDto를 만들어 BadRequest가 반환되는지 확인한다.
행사 종료 날짜가 시작 날짜보다 이른 경우와 같이 어노테이션만으로는 검증할 수 없는 것들은 어떻게 검증하면 좋을까? Validator를 따로 만들어서 검증해보자!
EventValidator.js
@Component
public class EventValidator {
public void validate(EventDto eventDto, Errors errors) {
if (eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() > 0) {
errors.rejectValue("basePrice", "wrongValue", "BasePrice is wrong.");
errors.rejectValue("maxPrice", "wrongValue", "MaxPrice is wrong.");
}
LocalDateTime endEventDateTime = eventDto.getEndEventDateTime();
if (endEventDateTime.isBefore(eventDto.getBeginEventDateTime()) ||
endEventDateTime.isBefore(eventDto.getCloseEnrollmentDateTime()) ||
endEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime())) {
errors.rejectValue("endEventDateTime", "wrongValue", "endEventDateTime is wrong.");
}
LocalDateTime beginEventDateTime = eventDto.getBeginEventDateTime();
if (beginEventDateTime.isAfter(eventDto.getEndEventDateTime()) ||
beginEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime()) ||
beginEventDateTime.isBefore(eventDto.getCloseEnrollmentDateTime())) {
errors.rejectValue("beginEventDateTime", "wrongValue", "beginEventDateTime is wrong.");
}
LocalDateTime beginEnrollmentDateTime = eventDto.getBeginEnrollmentDateTime();
if (beginEnrollmentDateTime.isAfter(eventDto.getBeginEventDateTime()) ||
beginEnrollmentDateTime.isAfter(eventDto.getEndEventDateTime()) ||
beginEnrollmentDateTime.isAfter(eventDto.getCloseEnrollmentDateTime())) {
errors.rejectValue("beginEnrollmentDateTime", "wrongValue", "beginEnrollmentDateTime is wrong.");
}
LocalDateTime closeEnrollmentDateTime = eventDto.getCloseEnrollmentDateTime();
if (closeEnrollmentDateTime.isAfter(eventDto.getBeginEventDateTime()) ||
closeEnrollmentDateTime.isAfter(eventDto.getEndEventDateTime()) ||
closeEnrollmentDateTime.isBefore(eventDto.getBeginEnrollmentDateTime())) {
errors.rejectValue("closeEnrollmentDateTime", "wrongValue", "closeEnrollmentDateTime is wrong.");
}
}
}
- EventValidator를 만들어 비즈니스 로직을 위반하는 input값을 검증한다.
EventControllerTests.js
@Test
public void createEvent_Bad_Request_Wrong_Input() throws Exception {
EventDto eventDto = EventDto.builder()
.name("Spring")
.description("REST API Development with Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 23, 30))
.closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 11, 23, 59)) // 등록 종료 일자가 더 빠르다
.beginEventDateTime(LocalDateTime.of(2023, 11, 25, 12, 30))
.endEventDateTime(LocalDateTime.of(2023, 11, 24, 18, 30)) // 행사 종료 일자가 더 빠르다
.basePrice(10000)
.maxPrice(5000) // max값이 base보다 작다
.limitOfEnrollment(100)
.location("공덕역 프론트원")
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.andExpect(status().isBadRequest());
}
- 날짜나 max, basePrice에 대해 비즈니스 로직에 위반되는 데이터를 가진 EventDto를 만들어 BadRequest를 반환하는지 테스트한다.
2.7 Bad Request 응답
Bad Request의 본문에 메세지를 담아 보내도록 수정해보자
EventController.js
@PostMapping
public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
if (errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}
eventValidator.validate(eventDto, errors);
if (errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}
Event event = modelMapper.map(eventDto, Event.class);
Event newEvent = eventRepository.save(event);
URI createdUri = linkTo(EventController.class).slash(newEvent.getId()).toUri();
return ResponseEntity.created(createdUri).body(event);
}
- 발생한 에러의 정보가 담긴 errors 내용을 body에 담아 보내고 싶은데, event를 body에 담으면 JSON 객체로 변환된 것에 반해 errors는 body에 담으면 에러가 발생한다. 이는 errors 객체는 자바 빈 스펙을 준수하지 않기 때문이다. Controller에서 event 객체를 JSON으로 변환할 때 ObjectMapper를 사용하는데, ObjectMapper는 자바 빈 스펙을 따르고 있어 body에 그냥 담아도 JSON으로 변환되어 나가는 것이다.
- errors 객체를 body에 담아 JSON으로 변환해 나가도록 하기 위해, 커스텀 JSON Serializer를 만들어보자!
ErrorsSerializer.java
@JsonComponent
public class ErrorsSerializer extends JsonSerializer<Errors> {
@Override
public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
gen.writeStartArray();
errors.getFieldErrors().forEach(e -> {
try {
gen.writeStartObject();
gen.writeStringField("field", e.getField());
gen.writeStringField("objectName", e.getObjectName());
gen.writeStringField("code", e.getCode());
gen.writeStringField("defaultMessage", e.getDefaultMessage());
Object rejectedValue = e.getRejectedValue();
if (rejectedValue != null) {
gen.writeStringField("rejectedValue", rejectedValue.toString());
}
gen.writeEndObject();
} catch (IOException e1) {
e1.printStackTrace();
}
});
errors.getGlobalErrors().forEach(e -> {
try {
gen.writeStartObject();
gen.writeStringField("objectName", e.getObjectName());
gen.writeStringField("code", e.getCode());
gen.writeStringField("defaultMessage", e.getDefaultMessage());
gen.writeEndObject();
} catch (IOException e1) {
e1.printStackTrace();
}
});
}
}
- errors는 Filed Error와 Global Error로 나뉜다. Validation에서 reject를 할 때, 한 필드에 해당하는 에러일 경우에 field errror이고, 여러 개의 값이 조합되어 에러가 발생한 경우에는 global error에 해당한다. 따라서 errors에 대한 커스텀 serializer를 만들 때는 filed error, global error 두 가지를 모두 매핑해줘야 한다.
- 위와 같이 커스텀 errors serializer를 만들어 줬으면, JSON으로 serialize 해주는 ObjectMapper에 등록해줘야 하는데, 이 때 스프링 부트가 제공하는 @JsonComponent를 활용하면 쉽게 등록할 수 있다. 이렇게 하면 ObjectMapper는 errors 객체를 serialize할 때 ErrorsSerializer를 사용한다.
2.8 비즈니스 로직 적용
Event의 비즈니스 로직을 테스트하는 코드를 작성해보자!
- BasePrice값과 MaxPrice값을 활용해 행사의 유료/무료 여부를 판단하는 로직과, location값을 활용해 행사의 온라인/오프라인 여부를 판단하는 로직을 테스트해보자.
EventTest.java
@Test
public void testFree() {
// Given
Event event = Event.builder()
.basePrice(0)
.maxPrice(0)
.build();
// When
event.update();
// Then
assertThat(event.isFree()).isTrue();
// Given
event = Event.builder()
.basePrice(100)
.maxPrice(0)
.build();
// When
event.update();
// Then
assertThat(event.isFree()).isFalse();
// Given
event = Event.builder()
.basePrice(0)
.maxPrice(100)
.build();
// When
event.update();
// Then
assertThat(event.isFree()).isFalse();
}
@Test
public void testOffline() {
// Given
Event event = Event.builder()
.location("공덕 프론트원")
.build();
// When
event.update();
// Then
assertThat(event.isOffline()).isTrue();
// Given
event = Event.builder()
.build();
// When
event.update();
// Then
assertThat(event.isOffline()).isFalse();
}
Event
public void update() {
this.free = (this.basePrice == 0 && this.maxPrice == 0) ? true : false;
this.offline = (this.location == null || this.location.isBlank()) ? false : true;
}
2.9 매개 변수를 이용한 테스트
매개 변수를 활용해 테스트 코드의 중복을 줄여보자!
EventTest.java
@Test
@Parameters({
"0, 0, true",
"100, 0, false",
"0, 100, false"
})
public void testFree(int basePrice, int maxPrice, boolean isFree) {
// Given
Event event = Event.builder()
.basePrice(basePrice)
.maxPrice(maxPrice)
.build();
// When
event.update();
// Then
assertThat(event.isFree()).isEqualTo(isFree);
}
- 위와 같은 방식으로 JUnitParams로 파라미터를 사용해 코드의 중복을 줄일 수 있다(JUnit4를 사용하는 경우). 혹시 파라미터값들을 문자열로 쓰지 않는 방법은 없을까?
JUnit 5 Parameterized Tests
https://www.baeldung.com/parameterized-tests-junit-5
@ParameterizedTest
@MethodSource
public void testFree(int basePrice, int maxPrice, boolean isFree) {
// Given
Event event = Event.builder()
.basePrice(basePrice)
.maxPrice(maxPrice)
.build();
// When
event.update();
// Then
assertThat(event.isFree()).isEqualTo(isFree);
}
private static Stream<Arguments> testFree() {
return Stream.of(
Arguments.of(0,0, true),
Arguments.of(100, 0, false),
Arguments.of(0, 100, false),
Arguments.of(100, 200, false)
);
}
@ParameterizedTest
@MethodSource
public void testOffline(String location, boolean isOffline) {
// given
Event event = Event.builder()
.location(location)
.build();
// when
event.update();
// then
assertThat(event.isOffline()).isEqualTo(isOffline);
}
private static Stream<Arguments> testOffline() {
return Stream.of(
Arguments.of("공덕 프론트원", true),
Arguments.of(null, false),
Arguments.of(" ", false)
);
}
- @ParameterizedTest 어노테이션을 붙여주고, arguments로 사용할 값들을 따로 만들어준다. 이 때, @MethodSource에 아무값도 넣지 않으면 기본적으로 테스트 이름과 동일한 메소드를 찾는다.