Test Code 작성법

wjd15sheep·2024년 6월 25일
1

Spring Boot

목록 보기
8/19
post-thumbnail

이전 포스트에서는 Test Code를 작성 이유와 어떻게 작성하는지 포스트했다면 이번 글에서 부터는 실직적으로 Test Code 작성하는 방법에 대해서 포스트 하겠다.

어노테이션 정리

  • @Test : 테스트 코드를 포함한 메서드를 정의합니다.
  • @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드를 정의합니다.
  • @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의합니다.
  • @AfterAll : 테스트를 종료하면서 호출되는 메서드를 정의합니다.
  • @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드를 정의합니다.
  • @Disabled : 지정된 테스트를 실행되지 않게 정의합니다.

예시 코드

package com.springboot.test;

import org.junit.jupiter.api.*;

public class TestLifeCycle {

    @BeforeAll // 
    static void beforeAll() {
        System.out.println("## Before All Annotation 호출 ##");
        System.out.println();
    }

    @AfterAll
    static void afterAll() {
        System.out.println("## After All Annotation ##");
        System.out.println();
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("## Before Each Annotation ##");
        System.out.println();
    }

    @AfterEach
    void afterEach() {
        System.out.println("## After Each Annotation ##");
        System.out.println();
    }

    @Test
    void test1() {
        System.out.println("## test1 시작 ##");
        System.out.println();
    }

    @Test
    void test2() {
        System.out.println("## test2 시작 ##");
        System.out.println();
    }

    @Test
    @Disabled
    void test3() {
        System.out.println("## test3 시작 ##");
        System.out.println();
    }
}

실행 결과

## Before All Annotation 호출 ##

## Before Each Annotation ##

## test1 시작 ##

## After Each Annotation ##

## Before Each Annotation ##

## test2 시작 ##

## After Each Annotation ##

## After All Annotation ##

위의 어노테이션에 따른 TestCode 실행되는 모습으로 더 알기 쉽게 알수 가 있습니다.

  • @BeforeAll과 @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행
  • @BeforeEach와 @AfterEach 어노테이션이지정된 메서드는 각 테스트 실행될 때 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행되는 것을 확인
  • test3()에는 @Disabled 어노테이션을 지정했는데, 이 어노테이션이 지정된 테스트는 실행되니 않는다.

컨트롤러 객체의 테스트

컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고 그 결괏값을 가공해서 클라이언트에게 응답하는 역할을 수행
주의 사항

  • 테스트하는 입장에서 Service는 외부 요인에 해당
  • 독립적인 테스트 코드를 작성하기 위해서는 Mock 객체를 활용

GET 실행 예시 코드

package com.springboot.test.controller;

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.test.web.servlet.MockMvc;

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

@WebMvcTest(ProductController.class)
public class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ProductService productService;

    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProduct() throws Exception {

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

        String productId = "123";

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



        verify(productService).getProduct(123L);
    }
}
  • @WebMvcTest(테스트 대상 클래스.class)
    웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있습니다. 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의 컨트롤러 관련 빈 객체가 모두 로드됩니다. @SpringBootTest보다 가볍게 테스트하기 위해 사용됩니다.
  • @SpringBootTest
    실제 애플리케이션을 자신의 로컬 위에 올려서 포트 주소나 Listening 되어지고, 실제 Database와 커넥션이 붙어지는 상태에서 진행되는 Live 테스트 방법

  • @WebMvcTest
    Controller(API) Layer만을 테스트하기 적합한 테스트 어노테이션으로 전체 애플리케이션을 실행하는 것이 아닌 Controller만을 로드하여 테스트를 진행할 수 있다.

차이점 :

  • @SpringBootTestsms 전체 애플리케이션을 띄워서 실행
  • @WebMvcTest Controller만 띄워서 실행

애플리케이션의 규모가 커지게 되는 경우 테스트 시간이 그만큼 길어지기 때문에 신규 기능이나 버그 패치 등 일부 기능만 테스트하고자 할 때는 WebMvcTest가 적당합니다.

  • MockBean
    실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행합니다. @MockBean이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않습니다. 그렇기 때문에 해당 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 합니다.
  • Test
    테스트 코드가 포함돼 있다고 선언하는 어노테이션이며, JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킵니다.
  • @DisplayName
    테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현을 정의할 수 있습니다.

일반적으로 @WebMvcTest 어노테이션을 사용한 테스트는 슬라이스(Slice) 테스트라고 부릅니다.

슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미입니다.
컨트롤러는 개념상 웹과 맞닿은 레이어로서 외부 요인을 차단하고 테스트하면 의미가 없기 때문에 슬라이스 테스트를 진행하는 경우가 많다.

예저 코드 설명

  • 예제 코드 25~26번 줄에서 @MockBean 어노테이션을 통해 ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체를 주입했습니다. Mock 객체에는 테스트 과정에서 맡을 역할을 32~33번 줄과 같이 Mockito에서 제공하는 given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성
    ⇒ Given에 해당

  • 23번 줄에서 사용된 MockMvc는 컨트롤러의 API를 테스트하기 위해 사용된 객체입니다. 정확하게는 서브릿 컨테이너의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스입니다.

  • perform() 메서드를 이용하면 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있습니다.
    perform() 메서드는 MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용합니다.

    MockMvcRequestBuilders는 GET, POST, PUT, DELETE에 매핑되는 메서드를 제공합니다.

  • 이 메서드는 MockHttpServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게됩니다.
    perform() 메서드의 결괏값으로 ResultActions 객체가 리턴되는데, 39~44번 줄과 같이 andExpect() 메서드를 사용해 결괏값 검증을 수행할 수 있습니다.

    andExpect() 메서드에서는 ResultMatcher를 활용하는데, 이를 위해 MockMvcResultMatchers 클래스에 정의대 있는 메서드를 활용해 생성할 수 있습니다.

요청과 응답의 전체 내용을 확인하려면 45번 줄과 같이 andDo() 메서드를 사용합니다. MockMvc의 코드는 모두 합쳐져 있어 구분하기 애매하지만 전체적인 ‘When-Then’의 구조를 갖추고 있음을 확인할 수 있습니다.

마지막으로 verify() 메서드는 지정된 메서드가 실행됬는지 검증하는 역할입니다. 일반적으로 given()에 정의된 동작과 대응합니다.

POST 실행 예시 코드

test/com.springboot.test.controller/ProductControllerTest.java

package com.springboot.test.controller;

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

@WebMvcTest(ProductController.class)
public class ProductControllerTest {

    /**
     * MockMvc 가짜 Service
     */
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ProductService productService;

    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProduct() 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/post").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());


        // verity() 실행됐는지 검증하는 역할
        verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
    }
}

data/dto/ProductDto.java

package com.springboot.test.data.dto;

import lombok.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ProductDto {

    private String name;

    private int price;

    private int stock;
}
  • Gson을 실행하려면 의존성을 추가해야한다.
    구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문잘열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 한다. (ObjectMapper를 사용해도 무관하나 현업에서 Gson을 많이 사용)
	//Gson 라이브러리
	implementation 'com.google.code.gson:gson:2.9.0'

예시 코드 해설

getProduct()를 테스트하는 코드와 거의 비슷하게 구성돼 있습니다.

  • 35~36번 줄에서 given() 메서드를 통해 ProductService의 saveProduct() 메서드의 동작 규칙을 설정합니다.
  • 38~42번 줄에서는 테스트에 필요한 객체를 생성
  • 47~56번 줄은 실제 테스트를 실행, 리소스 생성 기능을 테스트하기 때문에 post 메서드를 통해 URL을 구성 POST 요청을 통해 도출된 결괏값에(JSON) 대해 각 항목이 존재하는지 jsonPath().exists()
  • 62번 줄에서 검증으로 예상된 값과 실제 나온 값이 일치하는지 검증

예시 Test 실행 결과

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /product/post
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"40"]
             Body = {"name":"pen","price":5000,"stock":2000}
    Session Attrs = {}

Handler:
             Type = com.springboot.test.controller.ProductController
           Method = com.springboot.test.controller.ProductController#createProduct(ProductDto)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"number":12315,"name":"pen","price":5000,"stock":2000}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
> Task :test
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
1:37:08 PM: Execution finished ':test --tests "com.springboot.test.controller.ProductControllerTest"'.

성공적으로 테스트가 실행된 것을 확인할 수 있다.

테스트 결과에 대한 간단한 설명

  • MockHttpServletRequest
    • HTTP Method : 요청이 올바른 HTTP 메소드(POST)를 사용하고 있는지 확인합니다.
    • Request URI : 요청된 URI가 올바른지 확인합니다.
    • Headers : 필요한 헤더가 모두 포함되어 있는지 확인합니다.
    • Body : 요청 본문이 예상한 형식과 데이터를 포함하고 있는지 확인합니다.
  • Handler
    • Type : 요청을 처리하는 컨트롤러가 올바른지 확인합니다.
    • Method : 호출된 메서드가 올바른지 확인합니다.
  • Resolved Exception
    • Type : 예외가 발생하지 않았는지 또는 발생한 예외가 예상된 것인지 확인합니다.
  • MockHttpServletResponse
    • Status : 응답 상태 코드가 200(성공)인지 확인합니다.
    • Headers : 응답 헤더가 올바른지 확인합니다.
    • Content type : 응답의 콘텐츠 타입이 예상한 타입인지 확인합니다.
    • Body : 응답 본문이 예상한 데이터를 포함하고 있는지 확인합니다.
  • Build
    • BUILD SUCCESSFUL : 빌드가 성공적으로 완료되었는지 확인합니다.

[참고]

profile
성장 위해 노력하는 웹 개발자 주니어

0개의 댓글