Spring & Spring boot

[Springboot] 테스트 코드 - 6 - Controller Unit Test 컨트롤러 단위 테스트

jh4dev 2024. 8. 7. 10:21
본 포스팅은, 단위 테스트 (Unit Test) 와 관련된 내용입니다.
작성된 내용 외에도 다양한 방법이 있으며, 정답은 없습니다.
<목차>

1. 컨트롤러 단위 테스트
2. 테스트 코드
3. GitHub

 

SpringBoot 환경에서 전체적인 비즈니스 로직이 정상적으로 동작하는지 테스트 하기 위해서는 통합 테스트를 해야 하고, 각 모듈을 테스트 하고 싶다면, 단위 테스트를 진행해야 한다.

일반적인 Spring MVC 를 사용하는 구조라면, 일부 레이어에서만 단위 테스트를 수행하는게 무의미하거나 어려운 경우가 많다.

하지만! Mock 객체를 활용하여, 하위 레이어와 무관하게 동작하도록, 레이어 별 단위 테스트 코드를 작성해보도록 하겠다. 

 

앞서 JPA Example 에서 작성한 소스를 기반으로 작성된 포스팅이며,
Controller - Service - DAO - Repository 구조에서, DAO 를 제외한 구조로 재구성 하였다.

2024.08.04 - [Programming/Spring & Spring boot] - [Spring Boot] JPA - 4. CRUD Example

 

[Spring Boot] JPA - 4. CRUD Example

1. Environment2. Sources3. Test Springboot - Basic JPA CRUD API Example JPA를 바로 사용하기 보단, 동작 원리에 대해 알고 사용하는 것이 도움이 된다.JPA 관련 포스팅https://jh4dev.tistory.com/24 [Spring Boot] JPA - 1. ORM

jh4dev.tistory.com

 

[컨트롤러 테스트]

  • Controller 는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공하여 클라이언트에게 응답하는 역할을 수행한다.
  • 일반적인 구조에서, 요청 값을 통해 서비스 메서드를 호출하고 응답을 리턴하는 구조이다.
  • Spring 환경의 Controller 테스트에서 고려할 부분으로는 아래 내용이 있다. 
    • 단위 테스트를 위해, 필요한 대상 클래스만 로드
      • @WebMvcTest 어노테이션을 활용한 Slice Test 진행
    • Service 객체 DI (의존성 주입)
      • @MockBean 어노테이션을 활용하여 Mock 객체를 생성하여 주입
    • 컨트롤러를 호출하는 액션
      • MockMvcRequestBuilders.perform() 메서드 사용

[테스트 코드]

테스트를 진행할 Controller 는 다음과 같다.

    import com.springboot.test.data.dto.ChangeProductNameDto;
    import com.springboot.test.data.dto.ProductDto;
    import com.springboot.test.data.dto.ProductResponseDto;
    import com.springboot.test.service.ProductService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;

    @Slf4j
    @RestController
    @RequestMapping("/product")
    public class ProductController {

        private final ProductService productService;

        @Autowired
        public ProductController(ProductService productService) {
            this.productService = productService;
        }

        @GetMapping
        public ResponseEntity<ProductResponseDto> getProduct(Long number) {
            log.info("ProductController.getProduct() 호출!!");
            ProductResponseDto productResponseDto = productService.getProduct(number);
            return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
        }

        @PostMapping
        public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
            log.info("ProductController.createProduct() 호출!!");
            ProductResponseDto productResponseDto = productService.saveProduct(productDto);

            return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
        }
    }

 

테스트할 ProductController 를 살펴보자.

  • DI (의존성 주입)
    • ProductService 를 주입받아 사용하고 있다.
  • getProduct()
    • Product Entity 의 Id 값인 number 필드를 통해, Product 인스턴스를 조회하여 Response 로 리턴하는 컨트롤러 메서드
  • createProduct()
    • JSON 형태의 Request를 ProductDto 로 받아 DB에 저장한 후, 저장된 Product Entity 를 Response 로 리턴하는  컨트롤러 메서드

 

 

이러한 ProductController 에 대하여, Mock 객체를 사용하는 단위 테스트 코드는 아래와 같이 작성할 수 있다.

    package com.springboot.test.controller;

    import com.google.gson.Gson;
    import com.springboot.test.data.dto.ProductDto;
    import com.springboot.test.data.dto.ProductResponseDto;
    import com.springboot.test.service.ProductService;
    import org.junit.jupiter.api.DisplayName;
    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;

    import static org.mockito.Mockito.verify;
    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.jsonPath;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

    import static org.mockito.BDDMockito.given;

    @WebMvcTest(ProductController.class)
    public class ProductControllerTest {

        @Autowired
        private MockMvc mockMvc;

        @MockBean
        ProductService productService;

        @Test
        @DisplayName("MockMvc 를 통한 Product 데이터 조회 테스트")
        void getProductTest() throws Exception {

            given(productService.getProduct(123L)).willReturn(new ProductResponseDto(123L, "pen", 5000, 200));

            String productId = "123";

            mockMvc.perform(get("/product?number=" + productId))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.number").exists())
                    .andExpect(jsonPath("$.price").exists())
                    .andExpect(jsonPath("$.stock").exists())
                    .andDo(print());

            verify(productService).getProduct(123L);

        }

        @Test
        @DisplayName("Product 데이터 생성 테스트")
        void createProductTest() throws Exception {
            //Mock 객체에서 특정 메소드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
            given(productService.saveProduct(new ProductDto("pen", 5000, 2000)))
                    .willReturn(new ProductResponseDto(12315L, "pen", 5000, 2000));

            ProductDto productDto = ProductDto.builder()
                    .name("pen")
                    .price(5000)
                    .stock(2000)
                    .build();

            Gson gson = new Gson();
            String content = gson.toJson(productDto);

            mockMvc.perform(
                            post("/product")
                                    .content(content)
                                    .contentType(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.number").exists())
                    .andExpect(jsonPath("$.name").exists())
                    .andExpect(jsonPath("$.price").exists())
                    .andExpect(jsonPath("$.stock").exists())
                    .andDo(print());

            verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
        }
    }

 

작성된 테스트 코드에 대해 하나씩 살펴보자.

  1. @WebMvcTest 어노테이션
    • Web에서 사용되는 Request와 Response에 대한 테스트를 수행할 수 있다.
    • 기본적으로 @Controller / @RestController / @ControllerAdvice 등 컨트롤러 관련 빈 객체를 모두 로드하며, @WebMvcTest(ProductContoller.class) 와 같이 테스트할 대상 클래스를 지정하면, 지정한 클래스만 로드하여 테스트를 수행한다.
  2. @MockMvc 어노테이션
    • MockMvc는 컨트롤러의 API를 테스트하기 위해 사용된 객체이다.
    • 서블릿 컨테이너 구동 없이, 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
    • 41번 라인 / 68번 라인 을 보면, perfrom() 메서드를 활용하여 서버로 요청을 보내는 것처럼 통신 테스트 코드를 작성할 수 있다.
    • perform() 메서드는 MockMvcRequestBuilders 에서 제공하는 HTTP 메서드로, GET/POST/PUT/DELETE 에 매핑되는 메서드를 제공한다.
    • ResultActions 객체를 리턴하며, 42번 라인 / 72번 라인과 같이 andExpect() 메서드로 결과 검증을 수행할 수 있다.
    • 요청과 응답의 전체 내용을 확인하려면, andDo() 메서드를 사용한다.
  3. @MockBean 어노테이션
    • @MockBean 은 실제 Bean 객체가 아닌 Mock 객체를 생성하여 주입하는 역할을 수행한다.
      어디까지나 가짜 객체이기 때문에, 실제 행위를 수행하지 않으며, Mockito의 when() 또는 given() 메서드를 통하여 해당 Mock객체의 동작을 정의해야 한다
    • 37번 라인 / 58번 라인 을 보면, given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 파라미터는 무엇인지 "가정"한 후, willReturn() 메서드를 통해 메서드가 리턴하는 결과를 "직접 지정"하는 구조로 작성한다.
  4. verify() 메서드
    • 지정된 메서드가 실행되었는지 검증하는 역할을 수행한다.
    • 일반적으로 given() 에 정의된 동작과 대응한다.

<테스트 결과>

  • getProductTest()

 

  • createProductTest()


[GitHub]

전체 소스
ProductController

 


[참고 도서]

 

<스프링 부트 핵심 가이드>
저자 : 장정우

출판사 : 위키북스