Spring/테스트 코드

[Spring] 권한이 필요한 API 통합 테스트 설계 방법에 대해 알아보자

신민석 2024. 7. 16. 17:36

🎯 통합테스트 (Integration Test) 란 무엇일까?


 

통합 테스트란 애플리케이션의 여러 구성 요소들을 함께 테스트해 이들이 함께 동작하는지 확인하는 과정입니다. 단위 테스트와 달리, 여러 모듈이나 레어이가 상호작용하는 방식을 검증합니다. 보통의 Spring 구조는 Controller -> Service -> Repository 순서로 레이어 계층을 지나가며 필요한 데이터를 저장하거나, 조회합니다. 이러한 통합적인 과정을 테스트 코드로 구현해 서비스의 안정성을 확보하고 유지 보수성을 향상시키는 것을 목표로 잡아야 합니다.

 

그렇다면 통합테스트는 어디까지 테스트 해야할지도 정해야합니다. 보통 단위 테스트를 할때 메서드 단위로 해당 기능이 정상적으로 동작하는지 확인하기 위해 다른 클래스들과의 의존성을 끊기도 합니다. 예를 들어 Mock 과 같은 기능을 사용해 테스트하고자 하는 기능만을 테스트 하기 위해 설계 합니다.

 

하지만 통합 테스트는 사용자 요청의 전체적인 흐름이 정상적으로 동작하는지 확인해야 하기에 레이어간의 의존성을 유지한채 기능을 테스트 해야한다 생각합니다. 

 

🎯 MockMvc 란?


REST API 를 구현했다고 가정해봅시다. 그렇다면 보통 사용자의 HTTP 요청이 들어오면 Controller -> Service -> Repository 로 계층간 이동을 하며 개발자가 구현한 로직이 실행될겁니다. 

 

이처럼 구현한 기능을 테스트 하기 위해선, Tomcat 으로 띄우고 로컬 환경에서 사이트 또는 어플로 접속한뒤 해당 페이지에서 요청을 직접 보내야합니다. 또는 Postman 을 이용해 직접 요청을 보내는 방법도 있습니다. 하지만 테스트할 페이지가 구현되어 있지않으면 테스트를 하지 못 하고 반복해야하는 작업이 많습니다. 때문에 이를 테스트할 가짜 요청 을 만든다면 보다 편리하게 테스트할 수 있습니다. 이를 MockMvc 를 통해 구현할 수 있습니다.

 

💡  MockMvc 란 개발한 웹 프로그램을 실제 서버에 배포하지 않고도 테스트를 위한 요청을 제공하는 수단입니다. HTTP 메서드 요청을 만들어 보낼 수 있고 이에 대한 응답을 예상해 테스트 코드를 구현할 수 있습니다.

 

이러한 MockMvc 를 이용한 테스트 코드는 다음과같은 목적을 가져야 합니다.

 

MockMvc 를 이용하여 컨트롤러의 동작을 테스트 합니다. 컨트롤러의 엔드포인트를 호출해 HTTP 클라이언트의 요청을 모방하고 적절한 응답을 확인하기 위해 테스트를 수행합니다.

 

이러한 테스트 과정을 통해 애플리케이션의 서비스 로직이나 API 엔드포인트가 의도한 대로 동작하는지 확인하고, 버그를 발견해 수정하는것을 목적으로 설계 해야합니다.

 

🎯 MockMvc 을 이용한 Controller 통합 테스트 구현 방법


(진행중인 프로젝트를 참고한 내용이라 설명에 불 필요한 코드가 있을 수 있습니다)

 

아래와 같이 집 게시글을 생성하는 API 가 있습니다. 이 API 는 "v1/api/homes" url 로 post 요청을 보내는데 HomeGeneratorRequest 와 List<MultipartFile> 을 @RequestPart 로 담아 보내야합니다. 이 API 가 잘 작동하는지 테스트해보려면

(1) url 로 요청이 잘 보내지는지, (2) 집 게시물이 DB 에 잘 저장되는지, (3) API 응답이 잘 넘어오는지  를 테스트 해야합니다.

 

이 3가지를 테스트 하기 위해서는 MockMvc 를 이용해 통합테스트를 만듭니다.

 

HomeController 

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/api/homes")
public class HomeController {
    private final HomeService homeService;
    private final LocationService locationService;

    /**
     * 집 게시글 등록 api
     */
    @PostMapping
    public ResponseEntity<?> saveHome(@RequestPart HomeGeneratorRequest homeCreateDto,
                                      @RequestPart("images") List<MultipartFile> images) throws IOException, IllegalAccessException {
        //주소 -> 위도, 경도 변환
        LatLng location = locationService.getLatLngFromAddress(homeCreateDto.getHomeAddress());
        Long homeId = homeService.save(homeCreateDto, images, location);
        SuccessResponse response = new SuccessResponse(true, SuccessHomeMessages.HOME_POST_SUCCESS, homeId);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

 


(여기서부터 테스트 코드입니다.)

 

HomeHelper

package com.api.helper;

import com.common.home.request.HomeAddressGeneratorRequest;
import com.common.home.request.HomeGeneratorRequest;
import com.common.home.request.HomeUpdateRequest;
import com.core.api_core.home.model.*;
import com.core.api_core.user.model.Gender;

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

public class HomeHelper {

    /**
     * 집 주소 요청 객체 생성 메서드
     */
    public static HomeAddressGeneratorRequest generateHomeAddressGeneratorReqeust() {
        return  HomeAddressGeneratorRequest.builder()
                .state("NSW")
                .city("Sydney")
                .postCode(2000)
                .detailAddress("401호")
                .streetCode("123")
                .streetName("King Street")
                .build();
    }


    /**
     * 집 엔티티 생성 메서드
     */
    public static Home generateHomeEntity(){
        return Home.builder()
                .id(1L)
                .images(generateHomeImages())
                .bathRoomCount(10)
                .bedroomCount(1)
                .dealSavable(true)
                .options("TABLE,DESK,CHAIR")
                .bond(3000)
                .gender(Gender.MALE)
                .type(HomeType.SHARED_ROOM)
                .introduce("dasdasd")
                .bill(10)
                .rent(300)
                .homeStatus(HomeStatus.FOR_SALE)
                .homeAddress(generateHomeAddressEntity())
                .build();
    }

    /**
     * 집 주소 엔티티 생성 메서드
     */
    private static HomeAddress generateHomeAddressEntity() {
        return HomeAddress.builder()
                .id(1L)
                .state("WAC")
                .city("Sydney")
                .postCode(3000)
                .detailAddress("401호")
                .longitude(-35.443)
                .latitude(151.1234)
                .streetCode("500")
                .streetName("Street Name")
                .build();
    }

    /**
     * 집 엔티티에 저장될 집 이미지 생성 메서드
     */
    private static List<HomeImage> generateHomeImages() {
        List<HomeImage> images = new ArrayList<>();

        for (int i = 1; i < 5; i++) {
            images.add(HomeImage.builder()
                    .imageUrl("URL" + i)
                    .build());
        }
        return images;
    }


    /**
     * 집 주소 생성 요청 메서드
     */

    public static HomeGeneratorRequest generateHomeGeneratorRequest() {
        return HomeGeneratorRequest.builder()
                .userIdx(1L)
                .homeAddress(generateHomeAddressRequest())
                .bathRoomCount(5)
                .bedroomCount(1)
                .dealSavable(true)
                .bond(3000)
                .gender(Gender.MALE)
                .type(HomeType.HOME_STAY)
                .introduce("This is a beautiful home")
                .bill(10)
                .rent(300)
                .options("TABLE,DESK,CHAIR")
                .build();
    }

    public static HomeUpdateRequest generateHomeUpdateRequest() {
        return HomeUpdateRequest.builder()
                .homeId(1L)
                .homeAddress(generateHomeAddressRequest())
                .bathRoomCount(100)
                .dealSavable(false)
                .bedroomCount(2)
                .bond(2000)
                .gender(Gender.FEMALE)
                .type(HomeType.SHARED_ROOM)
                .introduce("INTRODUCEE")
                .bill(3000)
                .rent(500)
                .options("asdasdasd")
                .build();
    }

    private static HomeAddressGeneratorRequest generateHomeAddressRequest() {
        return HomeAddressGeneratorRequest.builder()
                .state("NSW")
                .city("Sydney")
                .postCode(2000)
                .detailAddress("401호")
                .streetCode("10")
                .streetName("BridgeStreet")
                .build();
    }

}

 

POST API 요청은 DTO 를 전달받고, GET API 요청은 DTO를 응답하는 상황이 많습니다. 때문에 테스트를 위해 DTO 가 필요한 경우가 많기 때문에 xxxHelper 라는 클래스를 만들어 테스트에 필요한 DTO 를 관리하면 편하기때문에 HomeHelper 라는 클래스를 만들어 테스트 코들르 만들때 사용합니다. (개인 취향)

 

 

 

HomeControllerIntegrationTest

@SpringBootTest
@AutoConfigureMockMvc
public class HomeControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private HomeRepository repository;

    @Autowired
    private LocationServiceImpl locationService;

    @MockBean
    private JwtService jwtService;

    @Autowired
    private ObjectMapper objectMapper;

    private String token;

    @BeforeEach
    public void setUp() {
        token = "Bearer your-jwt-token";
        repository.save(generateHomeEntity());
    }

    @Test
    @WithMockUser(roles = "PROVIDER")
    public void 집_게시글_생성_테스트() throws Exception {
        // given
        HomeGeneratorRequest homeGeneratorRequest = generateHomeGeneratorRequest();
        MockMultipartFile jsonFile = new MockMultipartFile("homeCreateDto", "", "application/json",
                objectMapper.writeValueAsBytes(homeGeneratorRequest));
        MockMultipartFile image1 = new MockMultipartFile("images", "image1.jpg", "image/jpeg", "image1".getBytes());
        MockMultipartFile image2 = new MockMultipartFile("images", "image2.jpg", "image/jpeg", "image2".getBytes());

        // when
        mockMvc.perform(MockMvcRequestBuilders.multipart("/v1/api/homes")
                        .file(jsonFile)
                        .file(image1)
                        .file(image2)
                        .header(HttpHeaders.AUTHORIZATION, token)
                        .contentType(MediaType.MULTIPART_FORM_DATA))
                //then
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(content().json(objectMapper.writeValueAsString(new SuccessResponse(true, "집 게시글 등록 성공", 11L))));
    }
}

 

먼저 사용된 애노테이션에 대해 알아봅시다.

 

 

@SpringBootTest : Spring Boot 애플리케이션 전체 컨텍스트를 로드해 테스트를 수행할 수 있게 하는 애노테이션

 

@AutoConfigureMockMvc : MockMvc 를 자동 구성해 의존성을 주입하고 사용할 수 있게 합니다.

 

@WithMockUser : 테스트에서 인증된 사용자로 시뮬레이션 할 수 있도록 합니다.

 

 

일단 테스트하고자 하는 "집 게시글 생성 API" 는 사용자의 권한이 필요합니다. 해당 프로젝트에선 Spring Security 를 사용하고 있어 API 요청이 들어오면 우선적으로 시큐리티 필터가 동작하기 때문에 이러한 시큐리티 관련 로직을 Mock 하는 과정이 필요합니다.

 

@BeforeEach
public void setUp() {
    token = "Bearer your-jwt-token";
    repository.save(generateHomeEntity());
}

 

@BeforeEach 에서 token 을 가상의 토큰을 만들고 집 게시글 생성 API 요청을 보낼때 넣어줄겁니다. 해당 테스트 코드에서 시큐리티 관련 기능은 Mock 을 적용했습니다. API 기능이 정상적으로 동작하는지 테스트하는 것이 가장 주된 목표이고 권한을 검증하는 로직은 통합 테스트 목적을 저해한다 생각했습니다.

 

그럼이제 집 게시글 생성 테스트에 대해 자세히 알아봅시다.

 

먼저 @WithMockUser(roles =  "PROVIDER")Spring Security 에서 제공하는 테스트 지원 기능입니다. 테스트 시에 가상의 사용자로 인증을 수행할 수 있도록 해줍니다. 이 어노테이션을 사용하면 특정 역할, 권한 등을 가진 사용자로 테스트를 수행할 수 있습니다.

 

테스트할 집 게시물 생성 API 는 "PROVIDER" 라는 권한이 필요하기에 이와 같이 설정했습니다.

 

// given
HomeGeneratorRequest homeGeneratorRequest = generateHomeGeneratorRequest();
MockMultipartFile jsonFile = new MockMultipartFile("homeCreateDto", "", "application/json",
        objectMapper.writeValueAsBytes(homeGeneratorRequest));
MockMultipartFile image1 = new MockMultipartFile("images", "image1.jpg", "image/jpeg", "image1".getBytes());
MockMultipartFile image2 = new MockMultipartFile("images", "image2.jpg", "image/jpeg", "image2".getBytes());

 

먼저 테스트 코드에 필요한 데이터를 given 하는 코드입니다. HomeHelper 에서 구현한 generateHomeGeneratorRequest() 메서드를 호출에 API 요청에 필요한 DTO를 하나 만들었고, 이미지 파일도 함께 보내야 하기 때문에 MockMultipartFile 을 이용해 image1, image2 를 만들었습니다.

 

// when
mockMvc.perform(MockMvcRequestBuilders.multipart("/v1/api/homes")
                .file(jsonFile)
                .file(image1)
                .file(image2)
                .header(HttpHeaders.AUTHORIZATION, token)
                .contentType(MediaType.MULTIPART_FORM_DATA))
        //then
        .andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(content().json(objectMapper.writeValueAsString(new SuccessResponse(true, "집 게시글 등록 성공", 11L))));

 

필드에서 선언한 mockMvc.perform() 안에 MockMvcRequestBuilders.multipart("/v1/api/homes") 은 해당 엔드 포인트로 멀티 파트 요청을 보냅니다. 이후 요청에 필요한 데이터들을 넣어주고 header 에는 token 이 필요하기에 이전에 선언해둔 가상 토큰을 넣었습니다.

 

이후 andExpect 를 이용해 해당 요청에 대한 정상 응답값을 예상해 테스트 코드가 정상적으로 작동하는지 확인합니다.


 

컨트롤러 레벨에서 실제 데이터베이스와의 상호작용을 포함한 전체 애플리케이션의 동작을 검증하는 통합 테스트 코드를 만들어 봤습니다. 이는 애플리케이션의 여러 레이어가 정상 작동하는지 확인할 수 있고 시스템의 전반적인 신뢰성을 높이는 데 중요한 역할을 할 수 있습니다.