예약 시스템 API - 카테고리 조회

Seyeong·2023년 3월 15일
0
post-thumbnail

이제부터 실제 API를 개발해보겠습니다.

먼저 카테고리 조회 API 입니다. API 문서는 아래와 같습니다.

카테고리 조회 API 문서

요청

응답

응답으로 값들이 채워져 있지 않은 이유는 실제 코드를 개발하기 전, 테스트 코드로 API 문서를 먼저 생성하기 위해서 아무런 값도 주지 않았기 때문입니다.

우선, 처음엔 이러한 API 문서조차 없기 때문에 API 문서를 만들어줍시다. 필자는 포스팅을 위해서 위와 같은 문서를 먼저 만든 것입니다.

먼저 API 요청을 받아들이기 위해 컨트롤러와 그 요청에 대한 API를 문서화할 테스트 코드를 작성해주겠습니다.

API 문서 작성

ReservationControllerTest

@WebMvcTest(ReservationController.class)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
class ReservationControllerTest {
    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext context;

    @BeforeEach
    void setUp(RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation).snippets()
                                .withTemplateFormat(TemplateFormats.asciidoctor()))
                .build();
    }

    @Test
    void testApiCategories() throws Exception {
        mockMvc.perform(get("/api/categories")) // (1)
                .andExpect(status().isOk())
                .andDo(document("/api/categories", // (2)
                        preprocessResponse(prettyPrint()), // (3)
                        responseFields( // (4)
                                fieldWithPath("size").description("카테고리 개수"),
                                fieldWithPath("items[]").description("카테고리 정보"),
                                fieldWithPath("items[].id").description("카테고리 id"),
                                fieldWithPath("items[].name").description("카테고리 이름"),
                                fieldWithPath("items[].count").description("카테고리에 포함된 전시 상품(display_info)의 수")
                        )));
    }
}

Rest Docs 설정 부분은 이전에 했었기 때문에 따로 설명하지 않겠습니다.

위와 같은 API 문서를 나타내게 해주는 부분은 실제 테스트 코드 부분인데요. 순서대로 설명하면

(1): "/api/categories" 경로로 GET 요청을 보냅니다.
(2): "/api/categories" 스니펫 경로로 Rest Docs 문서를 생성합니다.
(3): Response 결과에 대해서 형식에 알맞게 출력합니다. Request 요청 시 주어지는 데이터가 없기 때문에 Request에 대해서는 설정해주지 않았습니다.
(4): Response 반환시에 존재해야 하는 필드들을 명시합니다.

이대로 테스트 코드를 실행하면 테스트에 실패할 겁니다. 아직 위의 요청을 받을 컨트롤러도, 반환도 설정해주지 않았기 때문이죠.

컨트롤러를 만들어봅시다.

ReservationController

@RestController
public class ReservationController {
    @GetMapping("/api/categories")
    public void getCategories() {
    	
    }
}

이렇게 컨트롤러를 생성한 뒤, 테스트 코드를 실행하면 또 다른 오류가 발생합니다.
바로 실제 Rest Docs가 명시한 필드들이 Response Body에 없다고 말입니다.

한마디로 응답으로 위에서 명시한 바와 같이 나타나야하는데, 해당 컨트롤러의 반환 타입이 void이므로 예외가 발생한 것입니다.

이를 위해 Response Body에 담을 데이터를 만들어주어야 합니다.

CategoriesResponseDto

@Builder
@RequiredArgsConstructor
@Getter
public class CategoriesResponseDto {
    private final int size;
    private final List<CategoryResponseDto> items;
}

카테고리의 정보인 items에 id, name, count 값들이 들어가야 하기때문에 다시 객체로 선언해주었습니다.

CategoryResponseDto

@Builder
@RequiredArgsConstructor
@Getter
public class CategoryResponseDto {
    private final int id;
    private final String name;
    private final int count;
}

이제 컨트롤러에 이를 적용해줍시다.

ReservationController

@RestController
public class ReservationController {
    @GetMapping("/api/categories")
    public CategoriesResponseDto getCategories() {
        return CategoriesResponseDto.builder()
                .size(0)
                .items(List.of(CategoryResponseDto.builder().build()))
                .build();
    }
}

별 다른 데이터는 담지 않은 채 단순히 객체만 생성하여 반환해주었습니다.

이제 테스트 코드를 실행시키면 "/api/categories" 경로에 스니펫들이 생성될 것입니다. 이 스니펫을 이용해 API 문서를 출력해야 하므로 형식을 지정해줍시다.

index.adoc

= Reservation System API
https://github.com/ys200209
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

== 카테고리 조회

=== 요청
include::{snippets}/api/categories/http-request.adoc[]

=== 응답
include::{snippets}/api/categories/http-response.adoc[]
include::{snippets}/api/categories/response-fields.adoc[]

이렇게 형식을 갖춘 뒤, 터미널에 ./gradlew clean build test 을 입력하면 위의 형식을 가진 html을 생성해줄 것이고 애플리케이션을 실행한 뒤 http://localhost:8080/docs/index.html 에 접속하면 API 문서를 확인할 수 있습니다.

+ 각각의 카테고리 관련 DTO들은 따로 테스트 코드를 작성해주었으며, @EqualsAndHashCode 어노테이션을 붙여준 상태입니다!
이후에 위의 어노테이션을 붙이지 않거나, 혹은 equals() 및 hashCode() 메서드를 재정의해주지 않는다면 오류가 발생할 수 있습니다.

카테고리 조회 API 개발

TDD를 하는 방법이 여러개가 있지만 저는 이번에 Repository부터 역순으로 개발해 나가도록 하겠습니다. 그래야 Controller나 Service 계층에서 다른 객체의 의존성에 대해 목 객체를 생성하더라도 실 객체가 제대로 동작한다는 것에 확신할 수 있기 때문입니다.

현재 카테고리 조회 API를 만들기 위해선 아래와 같은 테이블들이 필요합니다.

Category

Product

Display_info

카테고리의 size와 id, name을 알기 위해서는 Category 테이블이,
카테고리별로 전시되고 있는 count를 알기 위해서는 Product와 Display_info 테이블이 필요합니다.

따라서 API 문서에 명시된 대로 응답하기 위한 쿼리를 작성하면 아래와 같습니다.

이를 Repository에 적용해봅시다.

Repository 테스트 코드 작성

JdbcReservationRepositoryTest

class JdbcReservationRepositoryTest {
    private ReservationRepository repository = new JdbcReservationRepository();
    
    @Test
    void testApiCategories() {
        // given
        CategoryResponseDto category1 = new CategoryResponseDto(1, "전시", 10);
        CategoryResponseDto category2 = new CategoryResponseDto(2, "뮤지컬", 10);
        CategoryResponseDto category3 = new CategoryResponseDto(3, "콘서트", 16);
        CategoryResponseDto category4 = new CategoryResponseDto(4, "클래식", 10);
        CategoryResponseDto category5 = new CategoryResponseDto(5, "연극", 13);
        
        CategoriesResponseDto expected = CategoriesResponseDto.builder()
                .size(5)
                .items(List.of(category1, category2, category3, category4, category5))
                .build();

        // when
        CategoriesResponseDto actual = repository.getCategories();

        // then
        assertThat(actual).isEqualTo(expected);
    }
}

코드가 다소 지저분해보이지만 이해하기 어려운 코드는 없을 겁니다. given 의 데이터들은 아까 쿼리를 조회했을때 나온 데이터들을 나타냅니다.

현재 이 코드는 repository.getCategories( ); 메서드를 작성해주지 않았기 때문에 실행하면 컴파일 오류가 날 겁니다.

테스트가 통과하도록 구현해봅시다.

Repository 코드 구현

ReservationRepository

public interface ReservationRepository {
    CategoriesResponseDto getCategories();
}

데이터 접근 계층이 변경될 수 있으니 Repository를 인터페이스로 두고, 이를 구현합시다. 이번 포스팅에서는 Jdbc 를 이용하여 프로젝트를 구성할 것이며, 앞으로 이 부분이 변경 되어도 다른 계층의 코드엔 아무런 영향이 없을 겁니다.

JdbcReservationRepository

@Repository
@RequiredArgsConstructor
public class JdbcReservationRepository implements ReservationRepository {
    private static final String SELECT_CATEGORIES_QUERY = "SELECT category.id as id, name, count(category_id) as count from category, product, display_info where category.id = product.category_id and product.id = display_info.product_id group by category_id;";

    private final JdbcTemplate jdbcTemplate;

    public CategoriesResponseDto getCategories() {
        System.out.println("jdbcTemplate = " + jdbcTemplate);
        List<CategoryResponseDto> results = jdbcTemplate.query(
                SELECT_CATEGORIES_QUERY,
                categoryMapper);
        return new CategoriesResponseDto(results.size(), results);
    }

    private static final RowMapper<CategoryResponseDto> categoryMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        String name = rs.getString("name");
        int count = rs.getInt("count");
        return new CategoryResponseDto(id, name, count);
    };
}

이렇게 코드를 작성해주면 Repository에서 JdbcTemplate을 의존하고 있기 때문에 테스트 코드에도 의존성을 주입해주어야 합니다.

테스트 코드를 수정해줍시다.

ReservationRepositoryTest

@DataJdbcTest // (1)
@AutoConfigureTestDatabase(replace = Replace.NONE) // (2)
class ReservationRepositoryTest {
    @Autowired JdbcTemplate jdbcTemplate; // (3)

    private ReservationRepository repository;

    @BeforeEach
    void setUp() { // (4)
        repository = new JdbcReservationRepository(jdbcTemplate);
    }

    @Test
    void testApiCategories() {
        // given
        CategoryResponseDto category1 = new CategoryResponseDto(1, "전시", 10);
        CategoryResponseDto category2 = new CategoryResponseDto(2, "뮤지컬", 10);
        CategoryResponseDto category3 = new CategoryResponseDto(3, "콘서트", 16);
        CategoryResponseDto category4 = new CategoryResponseDto(4, "클래식", 10);
        CategoryResponseDto category5 = new CategoryResponseDto(5, "연극", 13);

        CategoriesResponseDto expected = CategoriesResponseDto.builder()
                .size(5)
                .items(List.of(category1, category2, category3, category4, category5))
                .build();

        // when
        CategoriesResponseDto actual = repository.getCategories();

        // then
        assertThat(actual).isEqualTo(expected);
    }
}

위에서부터 순서대로

  1. JdbcTemplate을 포함한 Data Jdbc와 관련된 빈들만 로딩하는 어노테이션입니다. 추가로 default 값으로 설정된 데이터베이스는 H2와 같은 인메모리 데이터베이스로 설정되어 있는데, 이를 사용하려면 @SpringBootTest를 함께 적용해주어야 합니다. 하지만, 저희는 단위테스트로 작성을 할 것이기 때문에 데이터베이스를 변경해줄 겁니다.

  2. 자동으로 설정되는 데이터베이스 환경을 변경하는 어노테이션입니다. @AutoConfigureTestDatabase(replace = AUTO_CONFIGURED) 가 기본 값으로 설정되어 있습니다. 즉, 데이터베이스가 인메모리로 자동설정 되어 있습니다. 여기선 Replace.NONE을 입력해줌으로써 자동 설정이 아닌, 외부 데이터베이스(MySQL, ORACLE 등) 을 사용하도록 설정해주었습니다. 물론 이걸 위해선 DataBase Connector를 만들기 위해 application.properties 파일에 데이터베이스에 대한 정보(URL, ID, PASSWORD) 정보가 기입되어 있어야 합니다.

  3. @DataJdbcTest로부터 빈으로 등록된 JdbcTemplate을 의존주입 합니다.

  4. 매번 테스트 시작될 때마다 Repository에 JdbcTemplate 구현체를 의존 주입해줍니다.

이제 테스트를 실행하면 정상적으로 Pass할 겁니다.

혹시라도 테스트에 실패한다면 검증에서 두 개의 객체가 같은지를 비교하고 있기 때문에, 카테고리 관련 DTO들에 equals() 와 hashCode()를 재정의해주었는지 다시 한번 확인해주세요.

Service 테스트 코드 작성

Repository가 정상적으로 동작한다는 것을 테스트 코드를 통해 검증하였으니, 이제 Service에서 Repository를 Mock 객체를 생성하여도 원본 객체가 정상적으로 동작한다는 것에 확신을 가질 수 있습니다.

ReservationServiceTest

class ReservationServiceTest {
	// 응답 데이터 준비
    private CategoriesResponseDto categories = JdbcReservationRepositoryTest.categories;

    @Mock // (1)
    private ReservationRepository repository;

    @InjectMocks // (2)
    private ReservationService service;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this); // (3)
    }

    @Test
    void testApiCategories() {
        // when       
       when(repository.getCategories()).thenReturn(categories); // (4)

        CategoriesResponseDto actual = service.getCategories();
        
        // then
        assertThat(actual).isEqualTo(categories);
    }
}

응답 데이터를 준비해주기 위해서 Repository 테스트 코드에서 사용했던 데이터를 아래와 같이 public static 키워드를 이용하여 재사용 해주었습니다.

public class JdbcReservationRepositoryTest {
    private static CategoryResponseDto category1 = new CategoryResponseDto(1, "전시", 10);
    private static CategoryResponseDto category2 = new CategoryResponseDto(2, "뮤지컬", 10);
    private static CategoryResponseDto category3 = new CategoryResponseDto(3, "콘서트", 16);
    private static CategoryResponseDto category4 = new CategoryResponseDto(4, "클래식", 10);
    private static CategoryResponseDto category5 = new CategoryResponseDto(5, "연극", 13);

    public static CategoriesResponseDto categories = CategoriesResponseDto.builder()
            .size(5)
            .items(List.of(category1, category2, category3, category4, category5))
            .build();
        
        
    ...
    
}

위에서부터 설명하면

  1. Repository를 목 객체로 생성해줍니다. 가짜 객체로 생성해주었기 때문에 실제 Repository와의 의존성은 제거되었습니다.

  2. 생성된 Mock 객체를 주입할 객체로 설정해줍니다. Service객체엔 가짜 Repository가 주입되는 겁니다.

  3. 해당 테스트 클래스에서 Mock 어노테이션을 사용할 수 있도록 설정해줍니다.

  4. 가짜 목객체가 수행할 작업에 대한 스텁을 명시합니다. 여기서는 repository에서 getCategories( ) 메서드가 실행된다면 아까 준비해뒀던 categories 가 반환되도록 설정해주었습니다.

테스트를 성공적으로 수행시키기 위해서 실제 Service 객체를 구현해주겠습니다.

Service 코드 구현

ReservationService

@Service
@RequiredArgsConstructor
public class ReservationService {
    private final ReservationRepository repository;

    public CategoriesResponseDto getCategories() {
        return repository.getCategories();
    }
}

위와 같이 Service 코드를 작성해주면 테스트가 통과될 겁니다. 목 객체로 repository.getCategories( ) 를 호출하면 categories를 반환하도록 스텁을 작성했기 때문입니다.

이제 컨트롤러를 작성해봅시다.

Controller 테스트 코드 작성

컨트롤러에 대한 테스트는 API 문서를 생성하면서 통과하게끔 작성해놓았습니다. 하지만 통과했다고 해서 현재 컨트롤러 테스트가 정상적으로 작성된 것은 아닙니다. 이전에 작성했던 코드를 봐봅시다.

class ReservationControllerTest {
	
    ...

    @Test
    void testApiCategories() throws Exception {
        mockMvc.perform(get("/api/categories"))
                .andExpect(status().isOk())
                .andDo(document("/api/categories",
                        preprocessResponse(prettyPrint()),
                        responseFields(
                                fieldWithPath("size").description("카테고리 개수"),
                                fieldWithPath("items[]").description("카테고리 정보"),
                                fieldWithPath("items[].id").description("카테고리 id"),
                                fieldWithPath("items[].name").description("카테고리 이름"),
                                fieldWithPath("items[].count").description("카테고리에 포함된 전시 상품(display_info)의 수")
                        )));
    }
}

코드를 보면 컨트롤러로부터 반환될 categories 객체에 대한 검증이 전혀 없는 것을 볼 수 있습니다. Repository 테스트에서 생성했었던 그 categories 말입니다.

따라서 이 부분을 검증해주어야 합니다.

ReservationControllerTest

class ReservationControllerTest {
	
    ...

    @Test
    void testApiCategories() throws Exception {
        mockMvc.perform(get("/api/categories"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.size").exists())
                .andExpect(jsonPath("$.items").isArray())
                .andExpect(jsonPath("$.items[0].id").value(1))
                .andExpect(jsonPath("$.items[0].name").value("전시"))
                .andExpect(jsonPath("$.items[0].count").value(10))
                .andExpect(jsonPath("$.items[4].id").value(5))
                .andExpect(jsonPath("$.items[4].name").value("연극"))
                .andExpect(jsonPath("$.items[4].count").value(13))
                
                // Rest Docs 문서 생성부분
                ...
    }
}

검증할 json 문자열을 명시해주었습니다.

검증한 내용으로는,
size값이 존재해야 하고 items가 배열이어야 합니다.
또한 items 하위의 필드들인 id, name, count에서 0번 인덱스와 4번 인덱스에 해당하는 카테고리 정보가 일치해야 합니다.

이대로 테스트를 실행시키면 테스트가 실패합니다. 이전에 컨트롤러에서 API 문서를 만들기 위해서 아무 값도 넣지 않은 categories 정보를 반환하기 때문입니다.

이제 컨트롤러를 구현해줍시다.

Controller 코드 구현

ReservationController

@RestController
@RequiredArgsConstructor
public class ReservationController {
    private final ReservationService service;

    @GetMapping("/api/categories")
    public CategoriesResponseDto getCategories() {
        return service.getCategories();
    }
}

Controller가 Service를 의존하게 되었습니다. 따라서 테스트 코드에서도 Controller에 Service를 주입해주어야 합니다.

@WebMvcTest(ReservationController.class)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
class ReservationControllerTest {
    @MockBean
    ReservationService service;
 	
    
    ...
    
}

ReservationService를 @MockBean으로 설정해주었습니다.
이렇게 하게 되면 ReservationService가 스프링 컨텍스트에 빈으로 등록되면서 Controller에 주입될 겁니다.

참고로 Controller에 대한 빈은 @WebMvcTest에서 파라미터로 주는 Controller 클래스로부터 등록됩니다.

이제 테스트를 실행하면 성공하며 스니펫을 생성할 겁니다. 이제 다시 터미널에서 ./gradlew clean build test 을 입력해주면 새로운 스니펫으로부터 갱신된 HTML을 생성할 것이고, 서버를 실행한 뒤 문서를 확인해보면 다음과 같이 변경됩니다.

이제 제대로 된 API 문서가 작성되었습니다.

여기까지가 카테고리 조회 API 였습니다.

0개의 댓글