REST API 코드 : 깃헙
간단한 HTTP API 를 생성하고 요청을 받은 경우 created(201) 상태와 내용을 응답 한다
이번 파트에서는 특별한 내용이 없어 객체와 메시지(메서드) 의 역할만 정리
테스트 작성할 때 강의에서 사용하는 미디어 타입을 아래와 같이 변경함
(Deprecated) MediaType.APPLICATION_JSON_UTF8 -> MediaType.APPLICATION_JSON
RequestMapping - produces : 반환 가능 타입, consumes : 받는 타입 제한
ResponseEntity : HttpStatus 상태 코드를 추가한 HttpEntity 의 확장
HttpEntity : headers(헤더), body(본문) 으로 구성된 HTTP 요청, 응답 Entity
Entity : 사람, 장소, 물건, 사건, 개념 등의 명사로 유용한 정보를 저장 및 관리하는 집합적인 것
linkTo : 아래 소스코드를 예시로 EventController 클래스에 주석이 달린 매핑 기반 WebMvcLinkBuilder 생성
WebMvcLinkBuilder : Spring MVC Controller 를 가리키는 Link 인스턴스 구축 빌더
methodOn : 메소드 레벨에 이벤트 핸들러가 적용되면 사용
slash : object 타입의 문자열을 URI 하위 리소스로 추가
toUri : 인스턴스에서 빌드한 링크 URI 생성
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
@PostMapping
public ResponseEntity<Event> createEvent(@RequestBody Event event) {
//메소드 레벨에 이벤트 핸들러가 적용되는 경우 methodOn 사용 예시
//URI createUri = linkTo(methodOn(EventController.class).createEvent()).slash("{id}").toUri();
URI createUri = linkTo(EventController.class).slash("{id}").toUri();
event.setId(10);
return ResponseEntity.created(createUri).body(event);
}
}
스프링 4.3 부터 생성자가 1개, 주입 받으려는 객체가 이미 빈으로 등록 된 경우 @Autowired 생략 가능
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
private final EventRepository eventRepository;
public EventController(EventRepository eventRepository) {
this.eventRepository = eventRepository;
}
}
테스트
@WebMvcTest 는 웹 계층 슬라이스 테스트이기 때문에 EventRepository 를 빈으로 등록할 수 없다
@MockBean 을 이용하여 EventRepository 를 MockBean 으로 등록하고 Mockito 를 사용하여
event 의 NullPointException 발생을 막을 수 있다
Mockito.when.thenReturn : 특정 메서드가 호출될 때 모의 객체가 특정 값을 반환하도록 할 때 사용
@WebMvcTest
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
EventRepository eventRepository;
@Test
public void createEvent() throws Exception {
//... Event 빌드 코드 생략
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));
}
}
ModelMapper 를 사용할 때 리플렉션으로 인한 성능 이슈가 걱정된다면
필드들을 직접 매핑해주는 것 또한 하나의 방법
ModelMapper maven 추가 및 IOC 컨테이너에 빈 등록
클라이언트 요청 내용을 EventDto 에 매핑하고 ModelMapper.map() 메시지를 사용하여 Event 에 매핑
id 와 같은 입력 받으면 안되는 필드를 EventDto 에서는 제거하여 값을 제한적으로 받는다
EventDto 클래스에서 @Data 를 사용하고 Event 클래스에 여러 lombok 어노테이션을 직접 명시하는
이유는 JPA 를 사용할 때 발생할 수 있는 순환참조를 막기 위함
Event.class
@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 = EventStatus.DRAFT;
}
EventDto.class
@Builder @AllArgsConstructor @NoArgsConstructor
@Data
public class EventDto {
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;
}
테스트
2-3 테스트 소스코드에 있던 repository, mockito 는 삭제되고 @WebMvcTest 는 아래와 같이 변경
이유는 요청, 응답에 사용하던 Event 객체를 EventDto 객체를 통해 매핑하여 사용하게 되며
mocking 대상이 많아지며 코드가 복잡, 난잡해진다
웹 계층 슬라이스 테스트를 전체 테스트로 변경하여 repository 를 직접 사용하는 테스트가 되므로
첫번째 줄의 내용과 같이 소스코드가 변경 삭제 된다
아래 테스트는 입력되면 안되는 값을 넣고 빌드하여 응답에 사용되는지 확인
@SpringBootTest
@AutoConfigureMockMvc
public class EventControllerTests {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
public void createEvent() throws Exception {
Event event = Event.builder()
.id(100)
.eventStatus(EventStatus.PUBLISHED)
//... 이 외 정보 생략
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("eventStatus").value(EventStatus.DRAFT.toString()));
}
}
application.properties 추가
spring.jackson.deserialization.FAIL_ON_UNKNOWN_PROPERTIES=true
properties 를 추가하고 테스트를 실행하면 EventDto 에 없는 필드가 있는 경우 400 상태코드로 응답한다
2-4 에서 사용하는 값 무시하기, 이번 파트에서 사용하는 에러 응답 중 선택하여 사용하자
@Builder @AllArgsConstructor @NoArgsConstructor
@Data
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;
}
@NotNull : null 허용 안함
@NotEmpty : null 허용 안함, 비어있으면 안됨(공백 허용)
@NotBlank : null 허용 안함, 하나 이상의 공백이 아닌 문자 포함, CharSequence를 허용
@PostMapping
public ResponseEntity<Event> createEvent(@RequestBody @Validated EventDto eventDto, Errors errors) {
if (errors.hasErrors()) {
return ResponseEntity.badRequest().build();
}
... 생략
}
테스트
@Test
public void createEvent_bad_request_valid() throws Exception {
EventDto eventDto = EventDto.builder().build();
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.andExpect(status().isBadRequest());
}
@Validated 가 작동하지 않아 201 상태코드를 리턴하여 테스트가 실패하였다 이유는
강의에서 사용한 버전 spring boot 2.1.x 나의 프로젝트에서 사용한 버전 2.6.7
spring boot 2.3.0 버전부터 Validation Starter는 더 이상 Web Starter에 포함되지 않습니다.
Web 및 WebFlux 스타터는 더 이상 기본적으로 유효성 검사 스타터에 의존하지 않습니다. spring-boot-starter-validation
애플리케이션에서 유효성 검사 기능을 사용하는 경우 빌드 파일 에 종속성을 수동으로 다시 추가해야 합니다.
그리하여 spring-boot-starter-validation 의존성 추가하여 문제 해결 자세한 참조
javax.validation 어노테이션을 통한 검증은 통과하지만 값 자체를 더 비교해야할 때
아래와 같은 validator 클래스를 생성하여 빈으로 등록 후 사용
아래 예제는 basePrice 는 maxPrice 를 넘을 수 없는데 사용자가 고의로 basePrice 를 더 크게 준 경우 체크하는 로직
이외에 다양한 로직을 추가하여 검증 가능
@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");
}
... 생략
}
}
사용 방법
빈으로 등록한 validator 객체-메시지를 호출하여 errors 에 검증 실패한 내용이 있는지 확인한다
@PostMapping
public ResponseEntity<Event> createEvent(@RequestBody @Validated EventDto eventDto, Errors errors) {
if (errors.hasErrors()) {
return ResponseEntity.badRequest().build();
}
eventValidator.validate(eventDto, errors);
if (errors.hasErrors()) {
return ResponseEntity.badRequest().build();
}
... 생략
}
Errors 를 ResponseEntity 본문으로 전달하려면 ObjectMapper - BeanSerializer 를 사용하여
json 으로 직렬화 후 전달해야 하는데 ObjectMapper 를 사용하기 위한 조건이 자바 빈 규칙을 준수해야 하며
Errors 는 자바 빈 규칙을 준수하지 못한다
Errors 를 직렬화 하기위해 클래스를 생성하고 ObjectMapper 에 등록하는 과정이 필요한데
Spring 이 지원하는 @JsonComponent 를 사용하여 간단히 해결할 수 있다
@JsonComponent
public class ErrorsSerializer extends JsonSerializer<Errors> {
@Override
public void serialize(Errors errors, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartArray();
errors.getFieldErrors().forEach(e -> {
try {
jsonGenerator.writeStartObject();
jsonGenerator.writeStringField("field", e.getField());
jsonGenerator.writeStringField("objectName", e.getObjectName());
jsonGenerator.writeStringField("code", e.getCode());
jsonGenerator.writeStringField("defaultMessage", e.getDefaultMessage());
Object rejectedValue = e.getRejectedValue();
if (rejectedValue != null) {
jsonGenerator.writeStringField("rejectedValue", rejectedValue.toString());
}
jsonGenerator.writeEndObject();
} catch (IOException e1) {
e1.printStackTrace();
}
});
errors.getGlobalErrors().forEach(e -> {
try {
jsonGenerator.writeStartObject();
jsonGenerator.writeStringField("objectName", e.getObjectName());
jsonGenerator.writeStringField("code", e.getCode());
jsonGenerator.writeStringField("defaultMessage", e.getDefaultMessage());
jsonGenerator.writeEndObject();
} catch (IOException e1) {
e1.printStackTrace();
}
});
jsonGenerator.writeEndArray();
}
}
테스트
.andExpect(jsonPath("$[0].objectName").exists())
jsonPath 표현식으로 응답 배열 0번째에 objectName 필드가 존재를 예상한다는 의미
@Test
@DisplayName("입력 값이 잘못된 경우에 에러가 발생하는 테스트")
public void createEvent_bad_request_wrong_valid() throws Exception {
EventDto eventDto = EventDto.builder()
.basePrice(20000)
.maxPrice(100)
//... 생략
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$[0].objectName").exists())
.andExpect(jsonPath("$[0].defaultMessage").exists())
.andExpect(jsonPath("$[0].code").exists());
}
테스트 bad request json
Errors 에 입력한 값들을 ResponseEntity 본문으로 전달하여 클라이언트가 확인할 수 있다
[{"field":"basePrice","objectName":"eventDto","code":"wrongValue","defaultMessage":"BasePrice is wrong","rejectedValue":"20000"},{"field":"maxPrice","objectName":"eventDto","code":"wrongValue","defaultMessage":"MaxPrice is wrong","rejectedValue":"100"}
클라이언트의 요청 데이터를 확인하여 특정 필드들에 상태값 변경하고 검증하는 로직
특별한 내용 없으므로 확인은 깃헙에서
강의 영상에서 사용하는 JUnit 4
@Test
@Parameters({
"csv ..."
})
or
@Parameters(method = "메서드 명 or 메서드 명을 prefix(parametersFor) + 메서드명 으로 생략 가능 ")
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);
}
Object[] testFree() {
return new Object[] {
new Object[] {0, 0, true},
new Object[] {100, 0, false},
new Object[] {0, 100, false}
};
}
프로젝트 JUnit 5
@Test + @Parameters = @ParameterizedTest 하나로 가능
@methodSource 를 추가하고 메서드명을 직접 입력하거나
동일한 이름의 정적 메서드로 생성하면 MethodSource 로 인식
파일 호출, Enum 사용 가능 자세한 내용 2.15.3 @메소드소스
@ParameterizedTest
@CsvSource({
"0, 0, true",
"100, 0, false",
"0, 100, false"
})
or
@MethodSource
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);
}
static Object[] testFree() {
return new Object[] {
new Object[] {0, 0, true},
new Object[] {100, 0, false},
new Object[] {0, 100, false}
};
}