개발/JUnit

[JUnit 5] Spring boot 의 MVC 모델 테스트 - Controller

YJ_Lee 2023. 11. 13. 17:01

Environment


  • Spring boot 3.0.12
  • Spring framework 6
  • Java 17
  • JUnit 5

Dependencies


implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.modelmapper:modelmapper:3.2.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

Documentation


Introduction


Spring 의 디자인 패턴은 Controller - Service - Repository 간의 상관관계가 얽혀 있기 때문에 유닛테스트가 쉽지 않다.
특히 Controller 는 웹 요청, 요청 Body에서 Java object로 바인딩, 요청 값 검증 등 여러 기능이 존재하기 때문에 더욱 어렵다.
그렇기 때문에 Spring MVC Test framework 은 spring-boot-starter-test 패키지에 포함된 JUnit과 기타 라이브러리를 이용하여 이를 돕는다.
이 글에서는 spring-boot-starter-test 를 이용한 Controller 단의 테스트의 기본 사용법을 배운다.

구현 코드


Controller

@RestController
@RequiredArgsConstructor
public class PersonController {

    private final PersonService personService;

    @GetMapping("/{personId}")
    public ResponseEntity<ResponsePersonDto> getPerson(@PathVariable Long personId) {
        return ResponseEntity.ok(personService.getPerson(personId));
    }

    @PostMapping("/")
    public ResponseEntity<ResponsePersonDto> postPerson(@RequestBody @Valid CreatePersonDto personDto) {
        return ResponseEntity.ok(personService.savePerson(personDto));
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<String> exceptionHandler(Exception e) {
        if (e instanceof EntityNotFoundException) {
            return ResponseEntity.notFound()
                    .build();
        } else if (e instanceof MethodArgumentNotValidException) {
            return ResponseEntity.badRequest().build();
        }

        return ResponseEntity.internalServerError()
                .body("Unknown Server Error");
    }
}

엔드포인트는 Get, Post 두 개가 있으며, Post는 RequestBody로 json 형태의 요청을 받아 CreatePersonDto 에 매핑을 시도한다.
예외 처리는 Entity가 존재하지 않을 때, 요청 값이 유효하지 않을 때, 두 가지만이 존재한다.
적절한 예외처리 방식은 아니지만 테스트 설명을 위해 간단히 작성하였다.

DTO

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreatePersonDto {
    @NotBlank
    private String name;

    @Max(200)
    @Min(0)
    private int age;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponsePersonDto {
    private long personId;
    private String name;
    private int age;
} 

CreatePersonDto 에는 요청 값의 유효성 검증을 위한 @NotBlank... 등 어노테이션이 추가되어 있다.

테스트 클래스 생성 및 사전준비


테스트를 생성하고 싶은 클래스의 이름에 커서를 가져대 댄 뒤, Alt + Enter 를 누르면 테스트 클래스를 생성할 수 있다. 이 방식으로 클래스를 생성하면 자동으로 src/test 위치에 해당 클래스와 동일한 패키지 경로에 생성이 되어 편하게 관리할 수 있다.

@Before.. 등의 어노테이션이 붙은 메서드를 생성가능하며, 아래쪽에서 테스트를 생성할 메서드를 지정할 수 있다. 물론 하나의 메서드당 하나의 테스트만 가능한 것은 아니므로 체크하지 않고 그대로 생성하여도 관계가 없다.

@WebMvcTest(PersonController.class)
class PersonControllerTest {

    private static ObjectMapper objectMapper;

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    PersonService personService;

    @BeforeAll
    static void init() {
        objectMapper = new ObjectMapper();
    }

    @Test
    void personId_로_Get_요청() throws Exception {

    }
}

@WebMvcTest
Application context가 Web 계층만 인스턴스화 하도록 하는 어노테이션이며, MockMvc 를 주입받기 위한 필수 어노테이션이다. 특정 Controller 클래스를 전달하여 정확히 해당 클래스만 인스턴스화 하도록 할 수 있다.


MockMvc.class
Spring Controller를 테스트하기 위한 모의 객체로, 정상 Spring 서버와 같이 내부적으로 DespatcherServlet을 호출하여 비슷한 환경으로 테스트 할 수 있도록 돕는다.


@MockBean
우리는 PersonController을 대상으로 유닛 테스트를 하기 때문에 PersonService 는 실제로 실행되지 않으며, 인스턴스 조차 생성되지 않는다. 만약 해당 필드가 없이 테스트를 진행하게 되면,

Description:
Parameter 0 of constructor in xyz.practicespring.web.PersonController required a bean of type 'xyz.practicespring.web.PersonService' that could not be found.

위와 같은 예외를 마주하게 된다. 때문에 우리는 테스트를 위한 모의 객체를 생성해 주어야 하는데, 그 역할을 하는 것이 @MockBean이다.

그렇다면 모의 객체란 무엇을 의미하는 것인가?
다시말하지만 우리는 PersonService가 제대로 동작하는지 테스트 할 필요가 없다. 따라서 PersonService의 특정 메서드를 실행하였을 때, 이런 반환값을 던져준다. 라는 행위만을 정의해 주면 된다. PersonService의 코드는 실제로 실행되지 않는다.


@BeforeAll
해당 테스트 클래스에 작성된 모든 테스트를 하기에 앞서 한 번만 실행되는 메서드를 정의한다. ObjectMapper를 해당 테스트 클래스 전체에서 사용할 것이므로, 인스턴스를 생성해 주는 용도로 사용하였다.


@Test
테스트를 진행할 메서드이다. 메서드 이름이 테스트 결과 목록에 표시되므로 알아보기 쉽게 한글과 언더스코어로 작성하였다.


@BeforeEach
@BeforeAll 과 다르게 해당 클래스의 각각의 테스트를 실행 전에 실행하는 메서드를 정의한다. @Test가 3개 있으면, @BeforeEach 메서드는 세 번, @BeforeAll 메서드는 한 번 실행된다.


@DisplayName
메서드 이름을 한글로 하기 싫다면 각각의 테스트에 @DisplayName("Get 테스트") 와 같이 어노테이션을 추가하여, 테스트 이름을 바꿀 수 있다. 하지만 필자는 굳이 추가적인 어노테이션을 붙이는걸 싫어하기 때문에 메서드 이름 자체를 바꾼다.

Get 테스트 작성(1) - 정상요청


@Test
void personId_로_Get_요청() throws Exception {
    long personId = 1L;
    ResponsePersonDto expectedDto = new ResponsePersonDto(personId, "Lee", 30);
    String expectedJson = objectMapper.writeValueAsString(expectedDto);

    when(personService.getPerson(personId))
            .thenReturn(expectedDto);

    mockMvc.perform(get("/{personId}", personId))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().json(expectedJson));
}

expectedDto
PersonService.getPerson()을 실행하였을 때의 반환값이며, 아래의 Json을 생성하기 위한 객체이다.


expectedJson
요청에 대해 해당 Json 이 응답될 것이라고 정의한 변수이다.


when()
테스트하고자 하는 엔드포인트가 PersonService.getPerson()을 실행하므로, 해당 메서드를 실행하였을 때, 반환값을 지정해 주어야 한다.
when 파라미터로 실행하고자 하는 메서드를 지정하고, thenReturn 파라미터로 반환값을 지정한다.
이렇게 세팅하면 테스트시 PersonController가 personService.getPerson(1L)을 실행할 때, 해당 메서드의 코드와는 관계 없이 무조건 expectedDto 객체를 반환하게 된다.


get()
MockMvcRequestBuilders.class 의 정적메서드로, String 타입의 url과 url variable을 가변인자로 전달하면 해당 정보를 토대로 MockHttpServletRequestBuilder 를 반환해 준다. 이는 아래의 perform() 메서드에 전달된다.


perfrom()
실제로 요청을 하는 메서드이다. 매개변수 타입은 RequestBuilder이며, 해당 파라미터를 토대로 MockHttpServletRequest 객체를 생성하여 DispatcherServlet 에 전달한다. DispatcherServlet.doService() 는 매개변수로 HttpServletRequest 타입을 받으며, MockHttpServletRequest 는 HttpServletRequest 인터페이스를 구현하였기 때문에 전달이 가능해진다.


andDo()
요청 후의 실행내용을 기술한다.


print()
테스트 후에 MockHttpServletRequest와 MockHttpServletResponse 내용을 보여 준다. OutputStream, Writer 등 전달하여 데이터를 다른곳으로 보낼 수 있으며, 매개변수를 전달하지 않으면 데이터를 System.out 에 전달하여 콘솔로 결과를 보여주게 된다.


andExpect()
요청에 대한 예상 응답을 작성한 것으로 요청에 대한 테스트를 여기서 진행하게 된다.
MockMvcResultMatchers.class 의 정적 메서드를 통해 status, content, redirectedUrl, cookie 등 다양한 응답을 테스트 할 수 있다.

Get /{personId} 엔드포인트는 PersonService.getPerson() 메서드의 응답을 body에 담아서 반환하는게 로직의 전부이다. 따라서

  • 해당 엔드포인트가 존재하는가?
  • 정상 요청 결과 200 Ok 응답을 하는가?
  • Service 로부터 받은 DTO가 HttpEntity.Body에 저장되여 응답되는가?

위 사항이 테스트 되었다.

Get 테스트 작성(2) - 잘못된 요청


잘못된 요청에 대해 적절한 응답을 하는 지 또한 테스트해야 할 요소이다.

@Test
void 존재하지_않는_PersonId_요청() throws Exception {
    long personId = 1L;
    String requestUrl = "/" + personId;

    when(personService.getPerson(personId))
            .thenThrow(EntityNotFoundException.class);

    mockMvc.perform(get(requestUrl))
            .andDo(print())
            .andExpect(status().isNotFound());
}

위의 테스트와 달라진건 personService.getPerson() 에 대한 응답 뿐이다.
존재하지 않는 personId를 요청하면 getPerson()은 EntityNotFoundException을 던진하고 정의를 한다.
PersonController는 해당 예외를 잡으면 404 Not Found로 응답하기 때문에 andExpect() 에서 해당 응답을 예측한다.

  • 해당 엔드포인트가 존재하는가?
  • 존재하지 않는 personId 를 요청 시 404 Not Found 응답을 하는가?

위 사항이 테스트 되었다.

Post 테스트 작성(1) - 정상 요청


@Test
void CreatePersonDto_로_Post_요청() throws Exception {
    CreatePersonDto requestDto = new CreatePersonDto("Lee", 30);
    ResponsePersonDto responseDto = new ResponsePersonDto(1L, "Lee", 30);
    String expectedJson = objectMapper.writeValueAsString(responseDto);

    when(personService.savePerson(requestDto))
            .thenReturn(responseDto);

    mockMvc.perform(post("/")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(requestDto)))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().json(expectedJson));
}

POST / 엔드포인트는 personService.savePerson() 을 호출하므로 행위를 정의해준다.
요청은 RequestBody에 Json으로 전달되므로 요청 contentType()에 미디어 타입을 전달하여 설정하며, content()에 json을 전달해 주면 Post 요청을 할 수 있다.

  • 해당 엔드포인트가 존재하는가?
  • Application/Json 요청형태를 받아들이는가?
  • 요청 Json 이 CreatePostDto 에 정상적으로 매핑되는가?
  • 정상 요청 결과 200 Ok 응답을 하는가?
  • Service 로부터 받은 DTO가 HttpEntity.Body에 저장되여 응답되는가?

위 사항이 테스트 되었다.

Post 테스트 작성(2) - 잘못된 요청


@Test
void CreatePersonDto_필드_유효성_검증() throws Exception {
    CreatePersonDto emptyNameDto = new CreatePersonDto("", 30);
    CreatePersonDto underBoundAge = new CreatePersonDto("Lee", -1);
    CreatePersonDto overBoundAge = new CreatePersonDto("Lee", 201);

    mockMvc.perform(post("/")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(emptyNameDto)))
            .andDo(print())
            .andExpect(status().isBadRequest());

    mockMvc.perform(post("/")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(underBoundAge)))
            .andDo(print())
            .andExpect(status().isBadRequest());

    mockMvc.perform(post("/")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(overBoundAge)))
            .andDo(print())
            .andExpect(status().isBadRequest());
}

CreatePersonDto에 명시된 Validation을 체크하는 테스트이다.
@NotBlank, @Max(200), @Min(0) 세 가지의 위반사항이 존재하므로 하나씩 테스트하면 된다.
Validation을 위반할 경우 컨트롤러는 MethodArgumentNotValidException.class 를 던지며, 핸들러가 이를 잡아 Bad Request로 응답한다.

  • 해당 엔드포인트가 존재하는가?
  • 요청 Json 데이터 유효성 위반 시 400 Bad Request 응답을 하는가?

위 사항이 테스트 되었다.

전체 코드


package xyz.practicespring.web;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.EntityNotFoundException;
import org.junit.jupiter.api.BeforeAll;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;


@WebMvcTest(PersonController.class)
class PersonControllerTest {

    private static ObjectMapper objectMapper;

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    PersonService personService;

    @BeforeAll
    static void init() {
        objectMapper = new ObjectMapper();
    }

    @Test
    void personId_로_Get_요청() throws Exception {
        long personId = 1L;
        ResponsePersonDto expectedDto = new ResponsePersonDto(personId, "Lee", 30);
        String expectedJson = objectMapper.writeValueAsString(expectedDto);

        when(personService.getPerson(personId))
                .thenReturn(expectedDto);

        mockMvc.perform(get("/", personId))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().json(expectedJson));
    }

    @Test
    void 존재하지_않는_PersonId_요청() throws Exception {
        long personId = 1L;
        String requestUrl = "/" + personId;

        when(personService.getPerson(personId))
                .thenThrow(EntityNotFoundException.class);

        mockMvc.perform(get(requestUrl))
                .andDo(print())
                .andExpect(status().isNotFound());
    }

    @Test
    void CreatePersonDto_로_Post_요청() throws Exception {
        CreatePersonDto requestDto = new CreatePersonDto("Lee", 30);
        ResponsePersonDto responseDto = new ResponsePersonDto(1L, "Lee", 30);
        String expectedJson = objectMapper.writeValueAsString(responseDto);

        when(personService.savePerson(requestDto))
                .thenReturn(responseDto);

        mockMvc.perform(post("/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().json(expectedJson));
    }

    @Test
    void CreatePersonDto_필드_유효성_검증() throws Exception {
        CreatePersonDto emptyNameDto = new CreatePersonDto("", 30);
        CreatePersonDto underBoundAge = new CreatePersonDto("Lee", -1);
        CreatePersonDto overBoundAge = new CreatePersonDto("Lee", 201);

        mockMvc.perform(post("/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(emptyNameDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());

        mockMvc.perform(post("/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(underBoundAge)))
                .andDo(print())
                .andExpect(status().isBadRequest());

        mockMvc.perform(post("/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(overBoundAge)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }
}