본문 바로가기
Spring/SpringBoot

[SpringBoot] Layer별 테스트 코드 작성하기 (3) - Controller 테스트

by J4J 2024. 2. 7.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Layer별 테스트 코드 작성하기 마지막인 Controller 테스트하는 방법에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

관련 글

 

[SpringBoot] MockMVC를 이용하여 API 테스트하기

 

 

반응형

 

 

이전 글

 

[SpringBoot] Layer별 테스트 코드 작성하기 (1) - JPA를 이용한 Repository 테스트

[SpringBoot] Layer별 테스트 코드 작성하기 (2) - Service 테스트

 

 

 

 

Controller 단위 테스트

 

controller 쪽에서 작성해 볼 수 있는 테스트 방법 중 하나는 단위 테스트입니다.

 

단위 테스트는 이전 글에서 작성된 service 테스트에서 확인할 수 있는 것처럼 mock 객체를 활용한 테스트 방식이 될 수 있습니다.

 

mock 객체를 사용하는 이유에 대해 다시 언급드리면 controller 만을 위한 테스트를 구성하고 싶은데 controller와 상관없는 모든 layer 구간들의 설정까지 이루어지는 것은 테스트를 무겁게 만드는 원인이 됩니다.

 

spring의 전체 동작에 대한 설정을 하는 것은 테스트의 속도를 낮춤과 동시에 리소스 낭비를 발생시키기 때문에 이상적인 단위 테스트 방법은 mock 객체를 활용하여 controller 관련 설정만 이루어진 상태로 테스트하는 것입니다.

 

 

 

 

repository에서는 @DataJpaTest, service에서는 mockito가 있는 것처럼 controller에서도 controller와 관련된 설정만 될 수 있도록 도와주는 @WebMvcTest가 존재합니다.

 

@WebMvcTest를 사용하면 @Controller, @ControllerAdvice 등과 같이 spring mvc와 관련된 테스트를 할 수 있는 환경만 구성됩니다.

 

다른 말로는 controller에서 의존하고 있는 service layer와 관련된 코드는 설정이 되지 않기 때문에 기존 로직대로 테스트가 실행되면 에러가 발생되고, 이를 보완할 수 있도록 도와주는 것이 mock 객체가 됩니다.

 

 

 

이를 테스트하기 위해 간단하게 service에서 반환해주는 데이터를 활용하여 API를 제공해 주는 코드를 작성해 보겠습니다.

 

그리고 테스트 코드 내부에서는 소스 코드의 결과를 반환하는 것이 아닌, 테스트 내부에서 정의된 값을 반환하게 하여 테스트가 올바르게 동작될 수 있도록 구현해 보겠습니다.

 

 

 

 

[ 1. Service 클래스 확인 ]

 

테스트 목적이기 때문에 간단하게 데이터를 반환하는 구조를 작성해 보겠습니다.

 

package com.jforj.controllertest.service;

import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.List;

@Service
public class StudentService {

    public List<String> searchNames() {
        return Arrays.asList("service1", "service2");
    }
}

 

 

 

[ 2. Controller 클래스 작성 ]

 

package com.jforj.controllertest.controller;

import com.jforj.controllertest.service.StudentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequiredArgsConstructor
public class StudentController {
    private final StudentService studentService;

    @GetMapping("/names")
    public ResponseEntity<List<String>> getNames() {
        List<String> names = studentService.searchNames();
        return ResponseEntity.ok(formatNames(names));
    }

    /**
     * name의 prefix에 controller를 붙이도록 formatting 수행
     *
     * @param names 이름 목록
     * @return formatting된 이름 목록
     */
    private List<String> formatNames(List<String> names) {
        return names
                .stream()
                .map(name -> "controller".concat(name))
                .collect(Collectors.toList());
    }
}

 

 

 

 

[ 3. 테스트 코드 작성 ]

 

package com.jforj.controllertest.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jforj.controllertest.service.StudentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// controller 테스트를 위한 사용 환경 설정
@WebMvcTest
class StudentControllerTest {

    // test에서만 사용되는 가짜 객체
    @MockBean
    private StudentService studentService;

    // api 호출을 위해 사용되는 mockMvc
    @Autowired
    private MockMvc mockMvc;

    // api 호출 응답값 구성을 위한 objectMapper
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getNamesTest() throws Exception {
        // given
        List<String> givenNames = Arrays.asList("test1", "test2", "test3");
        // service searchNames의 결과값을 test에서만 givenNames으로 변경
        given(studentService.searchNames()).willReturn(givenNames);

        // when
        MvcResult mvcResult = mockMvc
                .perform(
                        get("/names")
                )
                .andDo(print()) // api 수행내역 로그 출력
                .andExpect(status().isOk()) // response status 200 검증
                .andReturn();

        // response 값을 List 구조로 변환
        List<String> names = objectMapper.readValue(mvcResult.getResponse().getContentAsString(Charset.defaultCharset()), List.class);

        // then
        assertEquals(3, names.size()); // 문자열 길이 검증
        for (String name : names) {
            assertFalse(name.contains("service")); // 기존 로직에서 확인 가능한 service 문자열 존재 검증
            assertTrue(name.contains("controller")); // controller 내부 로직인 formatting 수행 검증
        }
    }
}

 

 

 

[ 4. 테스트 결과 확인 ]

 

controller 테스트 결과 확인

 

 

 

 

통합 테스트

 

지금까지 repository, service, controller에서 수행할 수 있는 단위 테스트들에 대해서 확인해 봤습니다.

 

그래서 이번에는 단위 테스트가 아닌 통합 테스트에 대해 작성해 보겠습니다.

 

 

 

단위 테스트와 통합 테스트의 차이점은 간단합니다.

 

단위 테스트는 각각의 layer에 존재하는 메서드에 대해서만 테스트를 하는 것이라면 통합 테스트는 API를 호출했을 때 흘러갈 수 있는 controller - service - repository의 모든 비즈니스 로직에 대한 테스트를 의미합니다.

 

즉, 통합 테스트는 단위 테스트보다 더 많은 비용이 필요하다는 것을 의미하기 때문에 테스트가 무거워지는 단점이 있지만 API가 호출되었을 때 실제로 발생되는 전체적인 흐름에 대한 파악을 할 수 있다는 장점이 존재합니다.

 

그래서 단위 테스트와 통합 테스트는 무엇이 더 좋다고 표현할 수 없는 구조이고 현재 테스트하고자 하는 목적에 맞게 구분하여 테스트 코드를 작성해 주시면 됩니다.

 

 

 

 

통합 테스트를 수행하는 코드를 간단하게 작성해 보겠습니다.

 

controller 단위 테스트와 통합 테스트의 작성되는 코드 차이는 크게 2가지입니다.

 

첫 번째는 spring 전체 구조에 대한 설정을 하기 위해 @SpringBootTest를 사용하는가와 controller 구조에 대한 설정만 하기 위해 @WebMvcTest를 사용하는지입니다.

 

두 번째는 mock 객체를 사용하지 않는 것과 mock 객체를 사용하여 반환 값을 테스트 내부에서 정의하는지입니다.

 

통합 테스트에서는 단위 테스트와 다르게 @SpringBootTest 어노테이션을 활용하여 spring의 전체 구조 설정을 진행하며 mock 객체를 사용하지 않습니다.

 

그래서 위의 단위 테스트 코드에서 변경점에 대해서만 다룬다면 다음과 같이 코드를 작성해 볼 수 있습니다.

 

 

 

 

[ 1. 테스트 코드 변경 ]

 

package com.jforj.controllertest.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jforj.controllertest.service.StudentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import java.nio.charset.Charset;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// spring 통합 테스트를 위한 사용 환경 설정
@SpringBootTest
// mockMvc 사용을 위한 설정
@AutoConfigureMockMvc
class StudentControllerTest {

    // service에서 mock 객체를 사용하지 않고 실제 service 객체를 사용
    @Autowired
    private StudentService studentService;

    // api 호출을 위해 사용되는 mockMvc
    @Autowired
    private MockMvc mockMvc;

    // api 호출 응답값 구성을 위한 objectMapper
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getNamesTest() throws Exception {
        // given
        // service searchNames 반환값 설정 제거

        // when
        MvcResult mvcResult = mockMvc
                .perform(
                        get("/names")
                )
                .andDo(print()) // api 수행내역 로그 출력
                .andExpect(status().isOk()) // response status 200 검증
                .andReturn();

        // response 값을 List 구조로 변환
        List<String> names = objectMapper.readValue(mvcResult.getResponse().getContentAsString(Charset.defaultCharset()), List.class);

        // then
        assertEquals(2, names.size()); // 문자열 길이 검증
        for (String name : names) {
            assertTrue(name.contains("service")); // 기존 로직에서 확인 가능한 service 문자열 존재 검증
            assertTrue(name.contains("controller")); // controller 내부 로직인 formatting 수행 검증
        }
    }
}

 

 

 

[ 2. 테스트 결과 확인 ]

 

테스트 결과를 확인해 보면 동일한 API를 테스트하는 것이지만 통합 테스트가 단위 테스트보다 더 많은 시간이 소요되는 것을 확인할 수 있습니다.

 

통합 테스트 결과 확인

 

 

 

 

테스트를 위한 커스텀 어노테이션

 

이전 글들에서 repository, service에서 사용될 수 있는 커스텀 어노테이션을 구성한 것처럼 controller에서도 커스텀 어노테이션을 구성해 볼 수 있습니다.

 

통합 테스트 코드를 기준으로 controller에서 설정되는 어노테이션들은 다음과 같이 있습니다.

 

// spring 통합 테스트를 위한 사용 환경 설정
@SpringBootTest
// mockMvc 사용을 위한 설정
@AutoConfigureMockMvc
class StudentControllerTest {

    ...
    
}

 

 

 

controller 또한 설정을 위한 어노테이션을 매번 모든 통합 테스트 클래스에 추가하는 것은 번거롭고 단순 반복적인 작업이 될 수 있습니다.

 

그래서 이런 불편함을 해소하기 위해 controller에서 사용되는 커스텀 어노테이션을 구성해볼 수 있고 테스트 코드를 작성하기 위한 설정을 진행할 때 보다 편리하게 활용할 수 있습니다.

 

 

 

 

커스텀 어노테이션을 생성하는 경우 위에서 작성된 코드를 기준으로 다음과 같이 변경해 볼 수 있습니다.

 

[ 1. test 폴더 하위에 커스텀 어노테이션 추가 ]

 

package com.jforj.controllertest.annotation;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;

import java.lang.annotation.*;

// annotation이 class, interface, record 등의 선언에 사용될 수 있도록 설정
@Target(ElementType.TYPE)
// annotation이 runtime 단계에서 사용될 수 있도록 설정
@Retention(RetentionPolicy.RUNTIME)
// annotation이 javadoc 문서화에 표현될 수 있도록 설정
@Documented
// annotation이 부모 클래스에 선언되어 있을 때 자식 클래스에도 상속되도록 설정
@Inherited
// spring 통합 테스트를 위한 사용 환경 설정
@SpringBootTest
// mockMvc 사용을 위한 설정
@AutoConfigureMockMvc
public @interface ControllerTest {
}

 

커스텀 어노테이션 구조

 

 

 

 

[ 2. 테스트 코드 변경 ]

 

package com.jforj.controllertest.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jforj.controllertest.annotation.ControllerTest;
import com.jforj.controllertest.service.StudentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import java.nio.charset.Charset;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// 모든 설정을 커스텀 어노테이션으로 한 개로 대체
@ControllerTest
class StudentControllerTest {

    // service에서 mock 객체를 사용하지 않고 실제 service 객체를 사용
    @Autowired
    private StudentService studentService;

    // api 호출을 위해 사용되는 mockMvc
    @Autowired
    private MockMvc mockMvc;

    // api 호출 응답값 구성을 위한 objectMapper
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getNamesTest() throws Exception {
        // given
        // service searchNames 반환값 설정 제거

        // when
        MvcResult mvcResult = mockMvc
                .perform(
                        get("/names")
                )
                .andDo(print()) // api 수행내역 로그 출력
                .andExpect(status().isOk()) // response status 200 검증
                .andReturn();

        // response 값을 List 구조로 변환
        List<String> names = objectMapper.readValue(mvcResult.getResponse().getContentAsString(Charset.defaultCharset()), List.class);

        // then
        assertEquals(2, names.size()); // 문자열 길이 검증
        for (String name : names) {
            assertTrue(name.contains("service")); // 기존 로직에서 확인 가능한 service 문자열 존재 검증
            assertTrue(name.contains("controller")); // controller 내부 로직인 formatting 수행 검증
        }
    }
}

 

 

 

[ 3. 테스트 결과 확인 ]

 

커스텀 어노테이션 테스트 결과 확인

 

 

 

 

 

 

 

 

이상으로 Layer별 테스트 코드 작성하기 마지막인 Controller 테스트하는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글