<TDD>테스트 코드 작성하기 -(2)

윤재열·2022년 12월 26일
0

Spring

목록 보기
69/72
post-custom-banner

Code

Entity

package codej.todo_list.demo.todo.entity;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.GenericGenerator;

import java.time.LocalDateTime;

@Entity
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ProductEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pno;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    private LocalDateTime createDate;
    private LocalDateTime updateDate;


}

Dto

package codej.todo_list.demo.todo.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDto {
    private String name;
    private int price;
    private int package codej.todo_list.demo.todo.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class ProductResponseDto {

    private Long pno;
    private String name;
    private int price;
    private int stock;

    public ProductResponseDto(Long pno, String name, int price, int stock) {
        this.pno = pno;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }
}
stock;

}

ResponseDto

package codej.todo_list.demo.todo.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class ProductResponseDto {

    private Long pno;
    private String name;
    private int price;
    private int stock;

    public ProductResponseDto(Long pno, String name, int price, int stock) {
        this.pno = pno;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }
}

Service

package codej.todo_list.demo.todo.service;

import codej.todo_list.demo.todo.dto.ProductDto;
import codej.todo_list.demo.todo.dto.ProductResponseDto;

public interface ProductService {

    ProductResponseDto getProduct(Long number);
    ProductResponseDto setProduct(ProductDto dto);
    ProductResponseDto changeProductName(Long number,String name);
    void deleteProduct(Long number);

}

ServiceImpl

package codej.todo_list.demo.todo.service;

import codej.todo_list.demo.todo.dto.ProductDto;
import codej.todo_list.demo.todo.dto.ProductResponseDto;
import codej.todo_list.demo.todo.entity.ProductEntity;
import codej.todo_list.demo.todo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService{

    private final ProductRepository productRepository;

    @Override
    public ProductResponseDto getProduct(Long number) {

        ProductEntity product = productRepository.findById(number).get();

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setPno(product.getPno());
        productResponseDto.setName(product.getName());
        productResponseDto.setPrice(product.getPrice());
        productResponseDto.setStock(product.getStock());

        return productResponseDto;
    }

    @Override
    public ProductResponseDto setProduct(ProductDto dto) {
        ProductEntity product = new ProductEntity();
        product.setName(dto.getName());
        product.setPrice(dto.getPrice());
        product.setStock(dto.getStock());

        ProductEntity savedProduct = productRepository.save(product);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setPno(savedProduct.getPno());
        productResponseDto.setName(savedProduct.getName());
        productResponseDto.setPrice(savedProduct.getPrice());
        productResponseDto.setStock(savedProduct.getStock());

        return productResponseDto;

    }

    @Override
    public ProductResponseDto changeProductName(Long number, String name) {
        ProductEntity foundProduct = productRepository.findById(number).get();
        foundProduct.setName(name);
        ProductEntity changedProduct = productRepository.save(foundProduct);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setName(changedProduct.getName());
        productResponseDto.setPno(changedProduct.getPno());
        productResponseDto.setPrice(changedProduct.getPrice());
        productResponseDto.setStock(changedProduct.getStock());

        return productResponseDto;
    }

    @Override
    public void deleteProduct(Long number) {
        productRepository.deleteById(number);
    }
}

Controller

package codej.todo_list.demo.todo.controller;

import codej.todo_list.demo.todo.dto.ProductResponseDto;
import codej.todo_list.demo.todo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final ProductService productService;

    @GetMapping()
    public ResponseEntity<ProductResponseDto> getProduct(Long number) {
        ProductResponseDto result = productService.getProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body(result);
    }


}

Repository

package codej.todo_list.demo.todo.repository;

import codej.todo_list.demo.todo.entity.ProductEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<ProductEntity,Long> {
}

JUnit을 활용한 테스트 코드 작성

JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트를 위한 도구를 제공합니다.
또한, 단위 테스트 뿐만 아니라 통합 테스트를 할 수 있는 기능도 제공합니다.

  • JUnit의 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원합니다.
  • 즉, JUnit을 사용하면 몇개으 ㅣ어노테이션만으로도 간편하게 테스트 코드를 작성할 수 있습니다.
  • 또한 Junit을 활용하면 단정문(Assert)를 통해 테스트 케이스의 기대값이 정상적으로 도출되었는지 검토 할 수 있다는 장점이 있습니다.

JUnit의 세부 모듈

  • JUnit5 는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성됩니다.

JUnit Platform

  • JUnit Platform은 JVM에서 테스트를 시작하기 위한 뼈대 역할을 합니다.
  • 테스트 계획을 생성하는테스트엔진의 인터페이스를 가지고 있습니다.
  • 테스트 엔진은 테스트를 발견하고 테스트를 수행하며, 그 결과를 보고하는 역할을 수행합니다.
  • 또한 각종 IDE 와의 연동을 보조하는 역할을 수행합니다.

JUnit Jupiter

  • 테스트 엔진 API의 구현체를 포함하고 있으며, JUnit 5에서 제공하는 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있습니다.
  • 테스트의 실제 구현체는 별도 모듈의 역할을 수행하는데, 그중 하나가 Jupiter Engine 입니다.
  • Jupiter Engine은 Jupiter API를 활용하여 작성한 테스트 코드를 발견하고 실행하는 역할을 합니다.

JUnit Vintage

  • JUnit 3,4 에대한 테스트 엔진API를 구현하고 있습니다.
  • 기존에 작성된 JUit3,4 버전의 테스트 코드를 실행할 때 사용되며 Vintage Engine을 포함하고 있습니다.

이처럼 JUnit은 하나의 Platform 모듈을 기반으로 Jupiter,Vintage 모듈이 구현체의 역할을 수행합니다.

JUnit의 생명주기

생명주기와 관련되어 테스트 순서에 관여하게 되는 대표적인 어노테이션은 다음과 같습니다.

  • @Test : 테스트 코드를 포함한 메서드를 정의합니다.

  • @BeforAll : 테스트를 시작하기전에 호출되는 메서드를 정의합니다.

  • @BeforEach : 각 테스트 매서드가 실행되기 전에 동작하는 메서드를 정의합니다.

  • @AfterAll : 테스트를 종료하면서 호출되는 메서드를 정의합니다.

  • @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드를 정의합니다.

  • @BeforeAll 과 @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행됩니다.

  • @BeforeEach 와 @AfterEach는 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행됩니다.

Controller 객체의 테스트

GET 방식 테스트

  • 컨트롤러는 클라이언트로부터 요청을 받아 요청에 맞는 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공해서 클라이언트에게 응답하는 역할을 수행합니다.
  • 즉, 애플리케이션을 구성하는 여러레이어 중 가장 웹에 가까이 있는 모듈이라고 볼 수 있습니다.
@RestController
@RequestMapping("/product")
@RequiredArgsConstructor
public class ProductController {
    
    private final ProductService productService;
    
    @GetMapping()
    public ResponseEntity<ProductResponseDto> getProduct(Long number) {
        ProductResponseDto result = productService.getProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body(result);
    }
    
    
}
  • ProductController는 ProductService의 객체를 의존성 주입받습니다.
  • 테스트하는 입장에서 ProductController만 테스트 하고 싶다면 ProductService는 외부 요인에 해당합니다.
  • 독립적인 테스트 코드를 작성하기 위해서는 Mock 객체를 활용해야합니다.
  • 테스트 클래스는 test 패키지니에 controller 패키지를 생성하고 ProductControllerTest.java 파일로 생성합니다.

import codej.todo_list.demo.todo.controller.ProductController;
import codej.todo_list.demo.todo.dto.ProductResponseDto;
import codej.todo_list.demo.todo.service.ProductServiceImpl;
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.BDDMockito.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)
// 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있습니다.
// 대상 클래스만 로드해 테스트를 수해앟며, 만약 대상을 클래스에 추가하지 않으면 @Controller,@RestController,@ControllerAdvice
// 등의 컨트롤러 관련 빈객체가 모두 로드 됩니다. @SpringBootTest 보다 가렵게 테스트하기 위해 사용됩니다.
public class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    // @MockBean은 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행합니다.
    // @MockBean이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않습니다.
    // 그렇기 때문에 해당 객체는 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 합니다.
    ProductServiceImpl productService;


    @Test
    //  테스트 코드가 포함되어 있다고 선언하는 어노테이션이며, JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킵니다.
    @DisplayName("MockMVC를 통한 Product 데이터 가져오기 테스트")
    // 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현을 정의할 수 있습니다.
    void getProductTest() 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("$.pno").exists())
                .andExpect(jsonPath("$.name").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print());

        verify(productService).getProduct(123L);



    }

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

  • 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미입니다.

  • 단위 테스트를 수행하기 위해서는 모든 외부 요인을 차단하고 테스트를 진행해야 하지만 컨트롤러는 개념상 웹과 맞닿은 레이어로서 외부 요인을 차단하고 테스트하면 의미가 없기 때문에 슬라이스 테스트를 진행하는 경우가 많습니다.

  • @MockBean 어노테이션을 통해 ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체를 주입하였습니다.

  • Mock 객체에는 테스트 과정에서 맡을 동작을 정의해야합니다.

  • given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성합니다.

  • 메서드 이름에서 알 수 있듯이 이 부분의 코드가 앞에서 설명한 Given에 해당합니다.

  • MockMvc는 컨트롤러의 API를 테스트하기 위해 사용된 객체입니다.

  • 정확하게는 서블릿 컨테이너의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스입니다.

  • perform() 메서드를 통해서 서버로 URL을 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트 할 수 있습니다.

    • perform() 메서드는 MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용합니다.
    • MockMvcRequestBuilders는 GET,POST,PUT,DELETE에 매핑되는 메서드를 제공합니다.
    • 이 메서드는 MockHttpServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게 됩니다.
  • 그리고 perform() 메서드의 결과 값으로 ResultActions 객체가 리턴되는데, andExpect() 메서드를 사용해 결과값 검증을 수행할 수 있습니다.

    • andExpect() 에서는 ResultMatcher를 활용하는데, 이를 위해 MockMvcResultMatchers 클래스에 정의되어 있는 메서드를 활용해 설정할 수 있게 됩니다.
  • 요청과 응답의 전체 내용을 확인하려면 andDo() 메서드를 사용합니다.

    • MockMvc 의 코드는 모두 합쳐저 있어 구분하기 애매하지만 전형적인 "When-Then" 의 구조를 갖추고 있습니다.
  • 마지막으로 verify() 메서드는 지정된 메서드가 실행되었는지 검증하는 역할입니다.

    • 일반적으로 given()에 정의된 동작과 대응합니다.

슬라이스 테스트를 위해 사용할 수 있는 대표적인 어노테이션(필요한 경우에 선택적으로 사용하면 됩니다.)

  • @DataJdbcTest
  • @DataJpaTest
  • @DataMongoTest
  • @DataRedisTest
  • @JdbcTest
  • @JooqTest
  • @JsonTest
  • @RestClientTest
  • @WebFluxTest
  • @WebMvcTest
  • @WebServiceClientTest

POST 방식 테스트

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

        ProductDto productDto = ProductDto.builder()
                .name("pen")
                .price(5000)
                .stock(200)
                .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("$.pno").exists())
                .andExpect(jsonPath("$.name").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print());

        verify(productService).setProduct(new ProductDto("pen",5000,200));
    }

  • 이 테스트 코드를 실행하려면 pom.xml 또는 build.gradle 파일에 Gson에 대한 의존성을 추가해주어야 합니다.
  • Gson 은 구글에서 개발한 JSON 파싱 라이브러리로써 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 합니다.
  • ObjectMapper를 사용해도 무방하지만 여기서는 현업에서 많이 사용하는 라이브러리를 사용했습니다.
  • 리소스 생성 기능을 테스트하기 때문에 post() 메서드를 통해 URL을 구성합니다. 그리고 @RequestBody의 값을 넘겨주기 위해 content() 메서드에 DTO의 값을 담아 테스트를 진행합니다.
  • 마지막으로 POST요청을 통해 도출된 결과값에 대해 각 항목이 존재하는지 jsonPath().exists()를 통해 검증합니다.
profile
블로그 이전합니다! https://jyyoun1022.tistory.com/
post-custom-banner

0개의 댓글