Hyun's Wonderwall

[GDSC Study] 스프링 기반 REST API 개발 - 4주차 | 섹션 4. 이벤트 조회 및 수정 REST API 개발 본문

Study/Java, Spring

[GDSC Study] 스프링 기반 REST API 개발 - 4주차 | 섹션 4. 이벤트 조회 및 수정 REST API 개발

Hyun_! 2023. 11. 28. 05:11

GDSC Ewha 5기_ Spring Boot 스터디

  • 스터디 커리큘럼: 백기선, "스프링 기반 REST API 개발"
  • 4주차 과제 - 섹션 4. 이벤트 조회 및 수정 REST API 개발
    4. (1) 이벤트 목록 조회 API 구현
    4. (2) 이벤트 조회 API 구현
    4. (3) 이벤트 수정 API 구현
    4. (4) 테스트 코드 리팩토링

섹션 4. 이벤트 조회 및 수정 REST API 개발

4. (1) 이벤트 목록 조회 API 구현

강의 내용 정리에 앞서... createEvent_Bad_Request_Wrong_Input() 메소드의 결과 검증 부분 실수를 고쳤다.

Status가 400이 나와야 하지만 500이 나왔는데 Spring 버전 이슈 때문이었다.

ErrorsSerializer 클래스에 다음과 같이 gen.writeFieldName("errors") 부분을 추가하고,

//ErrorsSerializer.java
public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
    gen.writeFieldName("errors"); //버전 업데이트 이슈
    gen.writeStartArray();

EventControllerTests 클래스의 jsonPath부분을 다음과 같이 errors[0]로 적으니 해결되었다.

//EventControllerTests.java
this.mockMvc.perform(post("/api/events")
	    .contentType(MediaType.APPLICATION_JSON)
            .content(this.objectMapper.writeValueAsString(eventDto))
            )
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("errors[0].objectName").exists())
            .andExpect(jsonPath("errors[0].defaultMessage").exists())
            .andExpect(jsonPath("errors[0].code").exists())
            .andExpect(jsonPath("_links.index").exists())
;

+ 참고: https://velog.io/@haewonny/spring-rest-api-03


EventControllerTests.java - queryEvents(), generateEvent()
EventController.java - queryEvents(Pageable pageable, PagedResourcesAssembler assembler)

EventControllerTests - queryEvents()

30개의 이벤트를 10개씩 조회, 두번째 페이지(1) 조회하는 테스트이다. (두번째 페이지의 크기는 10임)

//EventControllerTests.java의 queryEvents()
    @Test
    @TestDescription("30개의 이벤트를 10개씩 두번째 페이지 조회하기")
    public void queryEvents() throws Exception {
        //Given
        IntStream.range(0, 30).forEach(this::generateEvent); //메소드 레퍼런스. 람다식.

        //When
        this.mockMvc.perform(get("/api/events")
                    .param("page", "1")
                    .param("size", "10")
                    .param("sort", "name,DESC")
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("page").exists())
                .andExpect(jsonPath("_embedded.eventList[0]._links.self").exists())
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.profile").exists())
                .andDo(document("query-events"));
    }

이벤트 30개를 만드는 방법: IntStream.range(0, 30).forEach(this::generateEvent);
-  메소드 레퍼런스 람다식 적용

주어진 코드는 Java의 스트림 API(IntStream)를 사용하여 0부터 29까지의 정수 범위를 생성하고, 각 정수에 대해 forEach 메서드를 호출하여 현재 클래스 generateEvent 메서드를 실행하는 코드입니다. this::generateEvent는 메서드 레퍼런스(Method Reference)로, 특정 객체의 메서드를 가리킵니다. 즉, 각각의 정수에 대해 generateEvent 메서드를 현재 클래스의 인스턴스에서 호출하는 것이 이 람다식이 하는 일입니다. (함수형 프로그래밍 스타일) - by ChatGPT

- generateEvent에 대한 부분은 아래에

 

this.mockMvc

  • .perform
    - get 요청을 보내면 이벤트를 조회할 수 있다. "/api/events"
    - .param으로 파라미터를 준다. page, size, sort (sort 시 name,DESC으로 주면 name기준 내림차순 정렬)
  • .andDo
    - .andDo(print())는 출력
    - .andDo(document("query-events"))로 문서화
  • .andExpect()로 기대하는 반응 같은 걸 보냄
    - status가 ok(200)인지
    - jsonPath에 적어서 리소스가 잘 존재하는지 확인

EventControllerTests - generateEvent(int index)

이벤트 1개 생성 메서드 (테스트x)

//EventControllerTests.java의 eventRepository와 generateEvent(int index)
public class EventControllerTests extends BaseControllerTest {

    @Autowired
    EventRepository eventRepository;
    
    /*생략...*/
    
	private Event generateEvent(int index) {
        Event event = Event.builder()
                .name("event" + index)
                .description("test event")
                .beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 8, 0))
                .closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 22, 0))
                .beginEventDateTime(LocalDateTime.of(2023, 11, 15, 19, 0))
                .endEventDateTime(LocalDateTime.of(2023, 11, 15, 21, 0))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("이대역")
                .free(false)
                .offline(true)
                .eventStatus(EventStatus.DRAFT)
                .build();
        return this.eventRepository.save(event);
    }
}

event들을 저장할 eventRepository가 필요해서 위에서 필드로 만들어뒀다. 이 메서드는 event 객체를 1개 생성해 필드 eventRepository에 저장한다.
빌더 패턴을 보면 이벤트 이름을 .name("event" + index) 으로 index를 넘겨주었으므로 받은 파라미터값에 따라 이벤트 이름이 event1, event2, event3... 이렇게 된다.



이제 EventController에 메서드 만들자 (TDD 하는중~!)

EventController - queryEvents(Pageable pageable, PagedResourcesAssembler assembler)

+ Pageable에 대해 추가 공부 필요

profile로 가는 링크를 잘 걸어주자.

//EventController.java
    @GetMapping
    public ResponseEntity queryEvents(Pageable pageable, PagedResourcesAssembler<Event> assembler) {
        Page<Event> page = this.eventRepository.findAll(pageable);
        var pagedResources = assembler.toModel(page, e -> new EventResource(e));
        pagedResources.add(Link.of("/docs/index.html#resources-events-list").withRel("profile"));
        return ResponseEntity.ok(pagedResources);
    }


EventControllerTest에서 다시 테스트하면 잘 되는 것을 확인할 수 있다!

- http-request.adoc, http-response.adoc 문서 확인 가능

 

테스트 시의 JSON(접은글)

더보기

{
  "_embedded": {
    "eventList": [
      {
        "id": 27,
        "name": "event26",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/27"
          }
        }
      },
      {
        "id": 26,
        "name": "event25",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/26"
          }
        }
      },
      /*
      생략...
      */
      {
        "id": 21,
        "name": "event20",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/21"
          }
        }
      },
      {
        "id": 3,
        "name": "event2",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/3"
          }
        }
      },
      {
        "id": 20,
        "name": "event19",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/20"
          }
        }
      },
      {
        "id": 19,
        "name": "event18",
        "description": "test event",
        "beginEnrollmentDateTime": null,
        "closeEnrollmentDateTime": null,
        "beginEventDateTime": null,
        "endEventDateTime": null,
        "location": null,
        "basePrice": 0,
        "maxPrice": 0,
        "limitOfEnrollment": 0,
        "offline": false,
        "free": false,
        "eventStatus": null,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/events/19"
          }
        }
      }
    ]
  },
  "_links": {
    "first": {
      "href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc"
    },
    "prev": {
      "href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc"
    },
    "self": {
      "href": "http://localhost:8080/api/events?page=1&size=10&sort=name,desc"
    },
    "next": {
      "href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc"
    },
    "last": {
      "href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc"
    },
    "profile": {
      "href": "/docs/index.html#resources-events-list"
    }
  },
  "page": {
    "size": 10,
    "totalElements": 30,
    "totalPages": 3,
    "number": 1
  }
}


4. (2) 이벤트 조회 API 구현

EventControllerTests.java - getEvent(), getEvent404()
EventController.java - getEvent(@PathVariable Integer id)

EventControllerTests - getEvent()

기존의 이벤트를 하나 조회하는 테스트. event의 id를 받아와 해당 id의 event를 조회하고 리소스가 잘 존재하는지 확인한다. (위의 queryEvents 메서드와 거의 비슷한 코드)

//EventControllerTests.java
    @Test
    @TestDescription("기존의 이벤트를 하나 조회하기")
    public void getEvent() throws Exception {
        // Given
        Event event = this.generateEvent(100);
        // When & Then
        this.mockMvc.perform(get("/api/events/{id}", event.getId()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("name").exists())
                .andExpect(jsonPath("id").exists())
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.profile").exists())
                .andDo(document("get-an-event"));
    }

EventControllerTests - getEvent404()

존재하지 않는 이벤트를 조회하는 테스트. status가 isNotFound()인지 확인한다.

//EventControllerTests.java
    @Test
    @TestDescription("없는 이벤트는 조회했을 때 404 응답받기")
    public void getEvent404() throws Exception {
        this.mockMvc.perform(get("/api/events/11883"))
                .andExpect(status().isNotFound());
    }

EventController - getEvent(@PathVariable Integer id)

Optional에 대한 추가 공부 필요

링크를 잘 걸어주었다

//EventController.java
    @GetMapping("/{id}")
    public ResponseEntity getEvent(@PathVariable Integer id) {
        Optional<Event> optionalEvent = this.eventRepository.findById(id);
        if (optionalEvent.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        Event event = optionalEvent.get();
        EventResource eventResource = new EventResource(event);
        eventResource.add(Link.of("/docs/index.html#resources-events-get").withRel("profile"));
        return ResponseEntity.ok(eventResource);
    }

4. (3) 이벤트 수정 API 구현

EventControllerTests.java - updateEvent(), updateEvent400_Empty(), updateEvent400_Wrong(), updateEvent404()
EventController.java - updateEvent(@PathVariable Integer id, @RequestBody @Valid EventDto eventDto, Errors errors)

EventControllerTests - updateEvent()

이벤트를 정상적으로 수정한 경우의 테스트.

//EventControllerTests.java
    @Test
    @TestDescription("이벤트를 정상적으로 수정하기")
    public void updateEvent() throws Exception {
        //Given
        Event event = this.generateEvent(200);

        EventDto eventDto = this.modelMapper.map(event, EventDto.class);
        String eventName = "Updated Event";
        eventDto.setName(eventName);
        //When & Then
        this.mockMvc.perform(put("/api/events/{id}", event.getId())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(this.objectMapper.writeValueAsString(eventDto)) //데이터 보내기
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("name").value(eventName))
                .andExpect(jsonPath("_links.self").exists())
                .andDo(document("update-event"));
    }

EventControllerTests - updateEvent400_Empty(), updateEvent400_Wrong(), updateEvent404()

이벤트를 정상적으로 수정하지 못한 경우 - 입력값이 비어 있는 경우/입력값이 잘못된 경우/존재하지 않는 이벤트

(입력값이 잘못된다란? 로직에 값 자체가 없음 or 로직상 잘못됨)

//EventControllerTests.java
    @Test
    @TestDescription("입력값이 비어 있는 경우에 이벤트 수정 실패")
    //입력값이 잘못된다란? 로직에 값 자체가 없음 or 로직상 잘못됨
    public void updateEvent400_Empty() throws Exception {
        //Given
        Event event = this.generateEvent(200);

        EventDto eventDto = new EventDto();
        //When & Then
        this.mockMvc.perform(put("/api/events/{id}", event.getId())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(this.objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @TestDescription("입력값이 잘못된 경우에 이벤트 수정 실패")
    //입력값이 잘못된다란? 로직에 값 자체가 없음 or 로직상 잘못됨
    public void updateEvent400_Wrong() throws Exception {
        //Given
        Event event = this.generateEvent(200);

        EventDto eventDto = this.modelMapper.map(event, EventDto.class);
        eventDto.setBasePrice(20000);
        eventDto.setMaxPrice(1000);
        //When & Then
        this.mockMvc.perform(put("/api/events/{id}", event.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @TestDescription("존재하지 않는 이벤트 수정 실패")
    //입력값이 잘못된다란? 로직에 값 자체가 없음 or 로직상 잘못됨
    public void updateEvent404() throws Exception {
        Event event = this.generateEvent(200);
        EventDto eventDto = this.modelMapper.map(event, EventDto.class); //데이터 유효
        //When & Then
        this.mockMvc.perform(put("/api/events/123456")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(this.objectMapper.writeValueAsString(eventDto)))
                .andDo(print())
                .andExpect(status().isNotFound());
    }

EventController - updateEvent(@PathVariable Integer id,
                                      @RequestBody @Valid EventDto eventDto,
                                      Errors errors)

구현~

//EventController.java
    @PutMapping("/{id}")
    public ResponseEntity updateEvent(@PathVariable Integer id,
                                      @RequestBody @Valid EventDto eventDto,
                                      Errors errors) {
        Optional<Event> optionalEvent = this.eventRepository.findById(id);
        if (optionalEvent.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        if(errors.hasErrors()) {
            return badRequest(errors);
        }
        this.eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()) { //로직상 문제인 경우
            return badRequest(errors);
        }
        Event existingEvent = optionalEvent.get();
        this.modelMapper.map(eventDto, existingEvent);
        Event savedEvent = this.eventRepository.save(existingEvent);

        EventResource eventResource = new EventResource(savedEvent);
        eventResource.add(Link.of("/docs/index.html#resources-events-update").withRel("profile"));

        return ResponseEntity.ok(eventResource);
    }

4. (4) 테스트 코드 리팩토링

IndexControllerTest.java, EventControllerTests.java 일부 -> BaseControllerTest.java

BaseControllerTest 클래스

현재 상태에서 IndexControllerTest와 EventControllerTests를 비교해 보면 중복이 많다. 상속을 이용해 중복 제거!

package me.lsh.restapidemo.common;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Ignore;
import org.junit.runner.RunWith;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

@RunWith(SpringRunner.class) //코드블럭 때문에 주석->{}
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class) //코드블럭 때문에 주석->{}
@ActiveProfiles("test")
@Ignore //테스트를 가지고 있는 클래스로 간주되지 않도록(실행하려고 하면 안 됨)
public class BaseControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected ModelMapper modelMapper;

}

동일한 애노테이션들과 mockMvc 필드를 옮겼다. EventControllerTests의 objectMapper, modelMapper 필드도 공용으로 쓰일 수 있는 것들이므로 옮겼다.

Ignore 애노테이션을 붙여 이 클래스가 Run되지 않도록 했다. 전체 패키지 Run시에 BaseControllerTest가 잘 Ignore 되는 것을 확인할 수 있다.