Blog

카카오맵 토이 프로젝트 1: 장소 저장과 WebMvcTest

카카오맵을 쓰는 토이 프로젝트를 2023년 6월 18일부터 24일까지 진행했습니다. 프론트엔드에서는 지도 API를, 백엔드에서는 지도에 표시할 데이터와 외부 API 연동을 연습했습니다. 이후에는 테스트 코드와 리팩터링을 이어 갔습니다.

이 글은 장소 데이터와 컨트롤러 테스트를 다룹니다. 기상청 API를 WebClient로 조회한 내용은 다음 글에 정리했습니다.

사용한 환경

Java로 작성했고 프레임워크와 데이터 접근 기술로는 Spring Boot, JPA, WebFlux를 사용했습니다. 데이터베이스는 H2와 MariaDB였고 배포 관련 도구로는 AWS와 Docker를 썼습니다. Lombok, H2, MariaDB JDBC, QueryDSL, Netty, JSON-Simple도 사용했습니다.

지도에 표시할 장소 저장

이 프로젝트에서 백엔드는 건물 이름과 상세 정보, 그리고 지도에 표시할 좌표를 저장했습니다. 그래서 Store에는 이름, 설명, X·Y 좌표를 두었습니다.

Java
package com.list.kakaoMap.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Store {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "store_id")
    private Long id;

    @Column
    private String name;
    @Column
    private String detail;
    @Column
    private double posX;
    @Column
    private double posY;

}

기본 CRUD에 이름 검색을 더했습니다. QueryDSL의 contains(name)으로 입력한 문자열을 포함하는 장소를 찾습니다.

Java
public List<Store> findStore(String name) {
        QStore qStore = new QStore("store");
        return jpaQueryFactory
                .selectFrom(qStore)
                .where(qStore.name.contains(name))
                .fetch();
}

컨트롤러 요청 테스트

컨트롤러를 테스트하려고 @WebMvcTest를 사용했습니다. 다음은 당시 작성한 StoreControllerTest 전체 코드입니다.

Java
package com.list.kakaoMap.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.list.kakaoMap.entity.Store;
import com.list.kakaoMap.service.StoreService;
import jakarta.transaction.Transactional;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.ArrayList;
import java.util.List;

import static com.list.kakaoMap.dto.StoreDto.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.when;
import static org.springframework.http.RequestEntity.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(StoreController.class)
class StoreControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockBean
    StoreService storeService;

    @Test
    @DisplayName("가게 등록 테스트")
    public void addTest() throws Exception {
        StoreRequest storeRequest = new StoreRequest("testId", "testDetail", 11.01, 27.09);
        when(storeService.addStore(any())).thenReturn(new Store(1L, "testId", "testDetail", 11.01, 27.09));

        ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/store")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(storeRequest)));

        resultActions
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.success").value(true))
                .andDo(print());
    }

    @Test
    @DisplayName("가게 조회 테스트")
    public void findTest() throws Exception {
        //given
        Store storeResponse1 = new Store(1L, "test_7e6b067e0783", "test_c5d923cc5984", 97.45, 72.58);
        Store storeResponse2 = new Store(2L, "test_1123klnsas", "test_29sne215231", 23.56, 28.65);
        Store storeResponse3 = new Store(3L, "test_24901hsksa", "test_32kjba9e1", 55.27, 81.21);
        List<Store> storeResponseList = new ArrayList<>();
        storeResponseList.add(storeResponse1);
        storeResponseList.add(storeResponse2);
        storeResponseList.add(storeResponse3);

        when(storeService.findStore(any())).thenReturn(storeResponseList);

        MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
        param.add("name", "test");

        //when
        ResultActions perform = mockMvc.perform(MockMvcRequestBuilders.get("/store")
                .param("name","test"));
        //then
        perform.andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.result").exists())
                .andDo(print());
    }

    @Test
    @DisplayName("가게 정보 수정 테스트")
    public void editTest() throws Exception {
        //given
        StoreRequest storeRequest = new StoreRequest("test_12523as", "test_29sne215231", 23.56, 28.65);
        Store store = Store.builder()
                .id(2L)
                .name("test_12523as")
                .detail("test_29sne215231")
                .posX(23.56)
                .posY(28.65)
                .build();

        when(storeService.editStore(any(),any())).thenReturn(store);
        //when
        ResultActions perform = mockMvc.perform(MockMvcRequestBuilders.put("/store/" + 2)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(storeRequest)));
        //then
        perform.andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.name").value("test_12523as"))
                .andDo(print());
    }

    @Test
    @DisplayName("가게 삭제 테스트")
    public void deleteTest() throws Exception {
        //given
        given(storeService.deleteStore(1L))
                .willReturn(true);
        //when
        ResultActions perform = mockMvc.perform(MockMvcRequestBuilders.delete("/store/" + 1));
        //then
        perform.andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(jsonPath("$.success").value(true));
    }
}

등록은 JSON 요청과 성공 여부를 확인합니다. 조회는 성공 여부와 함께 결과 필드가 있는지 봅니다. 수정은 응답 이름까지 비교하고 삭제는 성공 여부를 확인합니다.

컨트롤러와 서비스의 경계

Java
@WebMvcTest(StoreController.class)
// StoreController 를 테스트 하기 위한 Test
@MockBean
// 가짜 빈을 등록하여 실제 로직처럼 행동하는 빈을 등록

StoreController를 테스트 대상으로 지정하고 이 컨트롤러가 의존하는 StoreService는 mock으로 등록했습니다. 서비스의 실제 저장 로직을 실행하지 않고 테스트에 필요한 반환값을 미리 정해 둔 것입니다.

Java
when(storeService.addStore(any())).thenReturn(new Store(1L, "testId", "testDetail", 11.01, 27.09));

위 설정대로라면 addStore()가 호출될 때 지정한 Store가 그대로 반환됩니다. 그래서 이 테스트만으로는 데이터베이스에 저장됐는지 확인할 수 없습니다.

요청 객체를 JSON으로 변환

요청 본문에는 Java 객체 자체가 아니라 JSON 문자열을 넣어야 해서 Jackson의 ObjectMapper로 변환했습니다. 요청을 보내는 부분만 보면 다음과 같습니다.

Java
StoreRequest storeRequest = new StoreRequest("testId", "testDetail", 11.01, 27.09);

...

ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/store")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(storeRequest)));
Java
objectMapper.writeValueAsString(storeRequest); // storeRequest 라는 JAVA 객체르 JSON 형태의 문자열로 변환 

반대로 JSON 문자열을 DTO로 읽는 방법도 함께 적어 두었습니다. 아래는 호출 형태를 설명하는 메모이며 실제 테스트에 사용한 코드는 아닙니다. 두 번째 인자는 실제 코드에서 대상 클래스 등의 타입 정보로 지정해야 합니다.

Java
objectMapper.readValue(Json문자열, 변환할 DTO) // DTO에 맞춰서 JSON 문자열을 JAVA 객체로 변환