상세 컨텐츠

본문 제목

[스프링1] 7장. 테스트 코드 작성하기

23-24/Spring 1

by 숨니 2023. 11. 17. 10:00

본문

728x90

7.1 테스트 코드를 작성하는 이유

  • 개발 과정에서 문제를 미리 발견 할 수 있습니다.
  • 리팩토링의 리스크가 줄어듭니다.
  • 애플리케이션을 가동해서 직접 테스트하는 것 보다 테스트 빠르게 수행 가능합니다.
  • 하나의 명세 문서로서의 기능을 합니다.(협업 : 코드 합칠때 편리)
  • 몇가지 프레임워크에 맞춰 테스트 코드 작성 시 좋은 코드 생산 할 수 있습니다.
  • 코드가 작성된 목적을 명확하게 표현 가능, 불필요한 내용이 추가되는 것을 방지합니다.

7.2 단위 테스트와 통합 테스트

- 단위 테스트 : 애플리케이션의 개별 모듈을 독립적으로 테스트

- 통합 테스트 : 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도하는 대로 동작 하는지 테스트 하는 방식

 

(1) 단위 테스트 특징

단위테스트는 메서드 단위로 테스트를 수행하게 되며, 메서드 호출을 통해 의도한 값이 나오는지 확인합니다.

 

(2) 통합 테스트 특징

통합 테스트는 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지 확인합니다.
단위 테스트는 DB나 Network같은 외부 요인을 제거하고 진행하는 데 반해 통합 테스트는 외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 온전히 동작하는지 테스트하게 됩니다.

테스트 비용이 크다는 단점이 있습니다.

 

7.3 테스트 코드를 작성하는 방법

(1) Given-when-Then 패턴

- Given : 테스트를 수행하기 전에 필요한 환경을 설정하는 단계이고, 테스트에 필요한 변수 정의, Mock객체를 통해 특정 상황에 대한 행동을 정의합니다.

- When : 테스트의 목적을 보여주는 단계, 실제 테스트 코드 포함되며, 테스트를 통한 결과값을 가져옵니다.

- Then : 테스트의 결과를 검증하는 단계, When 단계에서나온 결과값 검증합니다. 결과값이 아니더라도 테스트를 통해 나온 결과를 검증해야 하는 부분 또한 포함합니다.

- BDD(Behavior-Driven-Development, 행위주도개발)를 통해 탄생한 테스트 접근 방식

 

(2) 좋은 테스트 코드의 5가지 속성(F,I,R,S,T)

보통 단위테스트에 적용할 수 있는 규칙

  • Fast : 테스트가 느리면 개선 작업이 늦어져 코드 품질 떨어집니다.
    ▶ 목적을 단순하게 설정해서 작성, 외부 산경 사용하지 않는 단위 테스트 작성합니다.
  • Isolated : 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 합니다.
    ▶  다른 테스트코드와 상호작용하거나, 관리할 수 없는 외부 소스 사용 시 테스트 수행이 안될수도 있습니다.
  • Repeatable : 어떤 환경에서도 반복 가능하도록 작성합니다.(개발환경, 네트워크 연결 여부)
  • Self-Validating: 테스트 자체만으로도 테스트 검증이 완료되어 합니다.
    ex) 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야, 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드
  • Timely : 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 합니다.

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

자바에서 사용되는 대표적인 테스트 프레임워크(단웨 테스트 및 통합 테스트 위한 도구 제공)입니다.

어노테이션 기반의 테스트 방식을 지원합니다.

단정문(Assert)를 통해 테스트 케이스의 기대값이 정상적으로 도출되었는지 검토할 수 있다는 장점이 있습니다.

 

(1) JUnit의 세부 모듈

Jupiter, Platfrom, Vintage

  • JUnit Platform : JVM 테스트를 시작하기 위한 뼈대 역할, 테스트를 계획하고 생성하는 테스트 엔진 인터페이스 가집니다.
테스트 엔진은 테스트를 발견하고 테스트를 수행하여, 결과를 보고하는 역할입니다.
  • JUnit Jupiter : 테스트 엔진 API의 구현체 포함하고 Jupiter 기반 테스트를 실행하기 위한 테스트 엔진을 가집니다. 그중 하나가 Jupiter Engine이고 Jupiter API를 활용해 작성한 테스트 코드를 발견하고 실행하는 역할을 수행합니다.
  • JUnit Vintage : JAVA 3,4에 대한 테스트 엔진 API를 구현하고 있습니다.

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

 

(2) 스프링 부트 프로젝트 생성

DAO 레이어 제외하고 서비스 레이어에서 바로 리포지토리를 사용하는 구조로 진행하겠습니다.

 

1. ProductServiceImpl 클래스 수정

@Service
public class ProductServiceImpl implements ProductService {
    private final Logger LOGGER= LoggerFactory.getLogger(ProductServiceImpl.class);
    private final ProductRepository productRepository;

    @Autowired
    public ProductServiceImpl(ProductRepository productRepository){
        this.productRepository=productRepository;
    }

    @Override
    public ProductResponseDto getProduct(Long number) {
        LOGGER.info("[getProduct] input number : {}", number);
        Product product = productRepository.findById(number).get();

        LOGGER.info("[getProduct] product number : {}, name : {}", product.getNumber(), product.getName());

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(product.getNumber());
        productResponseDto.setName(product.getName());
        productResponseDto.setPrice(product.getPrice());
        productResponseDto.setStock(product.getStock());

        return productResponseDto;
    }

    @Override
    public ProductResponseDto saveProduct(ProductDto productDto) {
        LOGGER.info("[getProduct] productDTO : {}", productDto.toString());
        Product product = new Product();
        product.setName(productDto.getName());
        product.setPrice(productDto.getPrice());
        product.setStock(productDto.getStock());

        Product saveProduct = productRepository.save(product);
        LOGGER.info("[saveProduct] saveProduct : {}", saveProduct);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(saveProduct.getNumber());
        productResponseDto.setName(saveProduct.getName());
        productResponseDto.setPrice(saveProduct.getPrice());
        productResponseDto.setStock(saveProduct.getStock());

        return productResponseDto;
    }

    @Override
    public ProductResponseDto changeProductName(Long number, String name) throws Exception {
        Product foundProduct = productRepository.findById(number).get();
        foundProduct.setName(name);
        Product changeProduct = productRepository.save(foundProduct);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(changeProduct.getNumber());
        productResponseDto.setName(changeProduct.getName());
        productResponseDto.setPrice(changeProduct.getPrice());
        productResponseDto.setStock(changeProduct.getStock());

        return productResponseDto;
    }

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

2. Product 엔터티 클래스 수정

@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString(exclude = "name")
@Table(name = "product")

public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    private LocalDateTime createdAt;

    private LocalDateTime updateAt;


}

 

(3) 스프링 부트의 테스트 설정

- spring-boot-starter-test 프로젝트를 pom.xml 파일에 관련 의존성을 추가해 줍니다.

 

< 스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리 > 

  • JUnit 5 : 자바 애플리케이션의 단위 테스트 지원
  • Spring Test & Spring Boot test : 스프링 부트 애플리케이션에 대한 유틸리티와 통합 테스트 지원
  • AssertJ : 다양한 Assert문을 지원하는 라이브러리
  • Hamcrest : Matcher를 지원하는 라이브러리
  • Mockito : 자바 Mock 객체를 지원하는 프레임웍
  • JSONassert : JSON용 단정문 라이브러리
  • JsonPath : JSON용 XPath 지원

(4) JUnit의 생명주기

< 관련 어노테이션>

  • @Test : 테스트 코드를 포함한 메서드 정의
  • @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드 정의
  • @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드 정의
  • @AfterAll : 테스트를 종료하면서 호출되는 메서드 정의
  • @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드 정의
(5) 스프링 부트에서의 테스트
테스트 방식이 다양하니 레이어 별로 사용하기 적합한 방식의 테스트를 구성해야 합니다.
(6) 컨트롤러 객체의 테스트
컨트롤러는 클라이언트로 부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공해서 클라이언트에게 응답을 하는 역할입니다.
1. ProductController의 getProduct() 메서드 작성하기
@RestController
@RequestMapping("/product")
public class ProductCotroller {
    private final ProductService productService;

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

    @GetMapping()
    public ResponseEntity<ProductResponseDto> getProduct (Long number){
        ProductResponseDto productResponseDto = productService.getProduct(number);

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

 

- ProductController는 ProductService의 객체를 의존성 주입을 받습니다.

- 테스트하는 입장에서 ProductController만 테스트 하고 싶다면 ProductService는 외부 요인에 해당합니다.
-> 독립적인 테스트 코드 작성을 위해서는 Mock 객체를 활용 해야 합니다.

 

2. getProduct() 메서드에 대한 테스트 코드 작성하기

@WebMvcTest(ProductCotroller.class)
public class ProductControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    ProductServiceImpl productService;

    @Test
    @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(
                        "$.number").exists())
                .andExpect(jsonPath("$.name").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print());

        verify(productService).getProduct(123L);
    }
}
  • @WebMvcTest(테스트 대상 클래스.class) : 웹에서 사용되는 요청과 응답에 대한 테스트를 수행 할 수 있습니다. 인자를 넣으면 대상 클래스만 로드 해 테스트 수행하며, 추가 하지 않으면  @Controller, @RestController, @ControllerAdivce등의 컨트롤러 관련 빈 객체가 모두 로드됩니다.
  • @MockBean : 실제 객체가 아닌 Mcok 객체를 생성해서 주입하는 역할을 수행합니다.
    -> 행위를 수행하지 않아 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 합니다.
  • @Test : 테스트 코드가 포함돼 있다고 선언하는 어노테이션, JUnit Jupiter에서 이 어노테이션을 감지하여 테스트 계획에 포함 시킵니다.
  • @DisplayName : 메서드 이름 복잡할 시 테스트에 대한 표현을 정의할 수 있습니다.

@WebMvcTest 어노테이션을 사용하는 테스트는 슬라이스 테스트라 부릅니다.

 

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

 

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

 

<코드적용>

  • @MockBean 어노테이션을 통해 ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체를 주입합니다. @MockBean은 스프링 컨텍스트에 Mock객체를 등록하게 되고 스프링 컨텍스트에 의해 @Autowired가 동작할 때 등록된 Mock 객체를 사용할 수 있도록 동작합니다.
  • given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터가 주입받는지 가정 후 willReturn()를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성합니다.
  • MockMvc는 컨트롤러의 API를 테스트하기 위해 사용하는 객체입니다.
    -> 서블릿 컨테이너의 구동 없이 가상 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
  • perform() 메서드를 이용하면 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트 할 수 있습니다.
    -> MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 GET, POST, PUT, DELETE에 매핑되는 메서드 제공하며 MockHttpServletRequestBuilder 객체를 리턴, HTTP 요청 정보 설정 가능
  • perform()의 결과값은 andExpect()메서드를 사용해 결과값 검증을 수행합니다.
  • andExpect 안에서 MockMvcResultmatcher의 메서드들을 활용합니다.
  • 결과 확인을 위해서는 andDo() 메서드 사용합니다. 
  • verify()메서드는 지정된 메서드가 실행되었는지 검증하는 역할을 합니다.

3. ProductController의 createProduct() 메서드 작성하기

@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct (@RequestBody ProductDto productDto){
    ProductResponseDto productResponseDto = productService.saveProduct(productDto);

    return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
4. createProduct() 메서드 테스트 코드 작성하기
@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);


            //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));
    }
}
  • given을 통해 ProductService의 saveProduct()의 동작 규칙 설정합니다.
  • 테스트에 필요한 객체 생성합니다.
  • 리소스 생성 기능을 테스트하기에 post메서드를 통해 url 구성합니다.
  • @RequestBody의 값을 넘겨주기 위해 content() 메서드에 DTO의 값을 담아 테스트를 진행합니다.
  • POST 요청을 통해 도출된 결과값에 각 항목이 존재하는지 jsonPath.exists()를 통해 검증합니다.

- ProductDto

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ProductDto {
    private String name;
    private int price;
    private int stock;

}
5. 테스트 코드 실행을 위해 Gson 의존성을 pom.xml에 추가

Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 합니다.

 

(7) 서비스 객체의 테스트

<아무 의존성을 주입받지 않은 상태에서 단위 테스트를 작성하였을 때>

public class ProductServiceTest {
    private ProductRepository productRepository=Mockito.mock(ProductRepository.class);
    private ProductServiceImpl productService;

    @BeforeEach
    public void setUpTest(){
        productService = new ProductServiceImpl(productRepository);
    }

    @Test
    void getProductTest(){
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

        Mockito.when(productRepository.findById(123L))
                .thenReturn(Optional.of(givenProduct));

        ProductResponseDto productResponseDto = productService.getProduct(123L);

        Assertions.assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
        Assertions.assertEquals(productResponseDto.getName(), givenProduct.getName());
        Assertions.assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
        Assertions.assertEquals(productResponseDto.getStock(), givenProduct.getStock());

        verify(productRepository).findById(123L);
    }
    @Test
    void saveServiceTest(){
        Mockito.when(productRepository.save(any(Product.class)))
                .then(returnsFirstArg());

        ProductResponseDto productResponseDto = productService.saveProduct(
                new ProductDto("펜",1000,1234)
        );

        Assertions.assertEquals(productResponseDto.getName(), "펜");
        Assertions.assertEquals(productResponseDto.getPrice(), 1000);
        Assertions.assertEquals(productResponseDto.getStock(), 1234);

        verify(productRepository).save(any());
    }

}

< getProductTest에 대한 설명>

  • Mockito의 mock()메서드를 통해 Mock객체로 ProductRepository를 주입받습니다.
  • 각 테스트 전에 ProductService 객체를 초기화하여 사용합니다.
  • Given-When-Then 패턴에서 Given 구문에서는 테스트에 사용될 Product 엔티티를 생성합니다.
  • getProduct 메서드를 호출하여 동작을 테스트합니다.
  • 리턴 받은 객체에 대해 Assertion을 통해 값을 검증합니다.
  • 검증 보완을 위해 verify() 메서드에 대한 테스트 코드 작성합니다.

<saveProduct()에 대한 설명>

  • any()는 Mockito의 ArgumentMatchers에서 제공하는 메서드로 Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용합니다.
  • 일반적으로 given()으로 정의된 Mock 객체의 메서드 동작 감지는 매개변수의 비교를 통해 이뤄지나, 레퍼런스 변수의 비교는 주솟값으로 이뤄지기 때문에 any()를 사용해 클래스만 정의하는 경우도 있습니다.

< Mock객체를 직접 생성하지 않고 @MockBean 어노테이션을 사용해 스프링 컨테이너에 Mock객체를 주입받는 방법>

@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {

    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;
        ...
}

이 전에는 Mockito를 통해 리포지토리를 Mock객체로 대체하는 작업을 수행하고 서비스 객체를 직접 초기화하였습니다.

반면 위에서는 테스트 어노테이션을 통해 Mock 객체를 생성하고 의존성을 주입받고 있습니다.

  • 둘의 차이: 스프링의 기능에 의존을 하는지 안하는지
  • 두 예제 다 Mock객체를 활용한 테스트 방식인 것은 동일하나 @MockBean을 사용하는 방식은 스프링에 Mock객체를 직접 주입받는 방법이며 Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식입니다.
  • 보통은 Mock객체를 직접 생성하는 것이 더 빠릅니다.
  • 스프링에서 객체를 주입받기 위해 ExtendsWith(SpringExtension.class)를 사용해 JUnit5의 테스트에서 스프링 테스트 컨텍스트를 사용하도록 설정합니다.
  • 이후 @Autowired 어노테이션으로 주입받는 ProductService를 주입받기 위해 직접 클래스를 @Import 어노테이션을 사용합니다.

(8) 리포지토리 객체의 테스트

리포지토리는 개발자가 구현하는 레이어 중 가장 DB와 가깝습니다.

JpaRepository를 상속받아 기본적인 쿼리 메서드 사용할 수 있어, 리포지토리 테스트는  구현 목적에 대해 특별히 고민하고 작성해야 합니다.

findById(), save()와 같은 기본 메서드에 대한 테스트는 이미 검증을 마치고 제공되는 것이므로 별 의미 없습니다.

 

DB는 외부 요인인데 만약 단위 테스트를 고려한다면 제외하기도 하고 테스트 용으로 다른 DB를 사용하기도 합니다.
-> DB를 사용하는 테스트는 테스트 과정에서 DB에 테스트 데이터가 적재되기 때문입니다.

그래서 DB를 연동한 테스트는 테스트 데이터를 제거하는 코드까지 포함해서 작성하는 것이 좋습니다.

하지만 코드가 실행되면서 발생하는 사이드 이펙트 까지 고려시 연동없이 테스트 하는 것이 좋을수도 있습니다.

 

우선 DB를 제외한 테스트 상황을 가정해 테스트 데이터베이스로 H2 DB 사용했습니다.

보통 테스트 환경에서는 별도의 설정이 없다면 임베디드 DB 사용합니다.

1. DB에 대한 의존성 추가

H2를 사용하기 위해 pom.xml에 의존성을 추가합니다.

 

2. 데이터베이스 테스트 작성

<H2를 이용한 테스트 작성>

@DataJpaTest
public class ProductRepositoryTestByH2 {
    @Autowired
    private ProductRepository productRepository;

    @Test
    void saveTest(){
        //given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        //when
        Product saveProduct = productRepository.save(product);

        //then
        assertEquals(product.getName(), saveProduct.getName());
        assertEquals(product.getPrice(), saveProduct.getPrice());
        assertEquals(product.getStock(), saveProduct.getStock());
    }

    @Test
    void selectTest(){
        //given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        Product saveProduct = productRepository.saveAndFlush(product);

        //when
        Product foundProduct = productRepository.findById(saveProduct.getNumber()).get();

        //then
        assertEquals(product.getName(), foundProduct.getName());
        assertEquals(product.getPrice(), foundProduct.getPrice());
        assertEquals(product.getStock(), foundProduct.getStock());
    }
}

 

<@DataJpaTest의 기능>

  • JPA와 관련된 설정만 로드해서 테스트를 진행합니다.
  • 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 DB의 롤백이 진행됩니다.
  • 기본값으로 임베디드 DB사용 -> 다른 DB를 사용할려면 설정을 바꾸어야 합니다.
  • 이 어노테이션을 선언하여 리포지토리를 정상적으로 주입받을 수 있습니다.

<저장 테스트>

1) Given구문: 테스트에서 사용할  Product를 만듭니다.

2) When구문: 생성된 엔터티를 기반으로 save()메서드를 호출해서 테스트 진행합니다.

3) Then구문: assertEquals()를 통해 save()메서드의 리턴 객체와  Given에서 생성한 엔터티 객체의 값이 일치하는지 검증합니다.

 

<조회테스트>

1) Given구문: 테스트에서 사용할  Product를 만듭니다.

2) When구문: 생성된 엔터티를 기반으로 save()메서드를 호출해서 테스트 진행합니다.

3) Then구문: assertEquals()를 통해 save()메서드의 리턴 객체와  Given에서 생성한 엔터티 객체의 값이 일치하는지 검증합니다.

 

< 기존의 마리아DB를 이용한 테스트 작성>

@DataJpaTest
@AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE)
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void save(){
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        Product savedProduct = productRepository.save(product);

        assertEquals(product.getName(), savedProduct.getName());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());

    }
}

 

  • replace의 요소는 @AutoConfigureTestDatabase 어노테이션의 값을 조정하는 작업 수행합니다.
  • replace 속성의 기본값은 Replace.ANY 이며 이 경우 임베디드 메모리 DB를 사용할 수 있습니다.
  • None으로 변경 시 실제로 사용하는 DB로 테스트 가능합니다.

< @DataJpaTest를 사용하지 않고 @SpringBootTest 어노테이션으로 테스트 작성>

@SpringBootTest
public class ProductRepositoryTest2 {

    @Autowired
    ProductRepository productRepository;

    @Test
    public void basicCRUDTest() {
        /* create */
        // given
        Product givenProduct = Product.builder()
                .name("노트")
                .price(1000)
                .stock(500)
                .build();

        // when
        Product savedProduct = productRepository.save(givenProduct);

        // then
        Assertions.assertThat(savedProduct.getNumber())
                .isEqualTo(givenProduct.getNumber());
        Assertions.assertThat(savedProduct.getName())
                .isEqualTo(givenProduct.getName());
        Assertions.assertThat(savedProduct.getPrice())
                .isEqualTo(givenProduct.getPrice());
        Assertions.assertThat(savedProduct.getStock())
                .isEqualTo(givenProduct.getStock());

        /* read */
        // when
        Product selectedProduct = productRepository.findById(savedProduct.getNumber())
                .orElseThrow(RuntimeException::new);

        // then
        Assertions.assertThat(selectedProduct.getNumber())
                .isEqualTo(givenProduct.getNumber());
        Assertions.assertThat(selectedProduct.getName())
                .isEqualTo(givenProduct.getName());
        Assertions.assertThat(selectedProduct.getPrice())
                .isEqualTo(givenProduct.getPrice());
        Assertions.assertThat(selectedProduct.getStock())
                .isEqualTo(givenProduct.getStock());

        /* update */
        // when
        Product foundProduct = productRepository.findById(selectedProduct.getNumber())
                .orElseThrow(RuntimeException::new);

        foundProduct.setName("장난감");

        Product updatedProduct = productRepository.save(foundProduct);

        // then
        assertEquals(updatedProduct.getName(), "장난감");

        /* delete */
        // when
        productRepository.delete(updatedProduct);

        // then
        assertFalse(productRepository.findById(selectedProduct.getNumber()).isPresent());
    }
}
  • @SpringBootTest 어노테이션을 활용하면 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기에 의존성 주입 고민할 필요가 없습니다.
  • 테스트 속도가 느리다는 단점이 있습니다.

7.5 JaCoCo를 활용한 테스트 커버리지 확인

  • 코드 커버리지란 소프트웨어의 테스트 수준이 충분하지를 표현하는 지표입니다.
  • 커버리지를 확인하기 위해 가장 보편적으로 사용하는 도구가 JaCoCo(Java Code Coverage)입니다.
    -> JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트 되었는지 Line, Branch를 기준으로 한 커버리지로 리포트합니다.
  • 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작하며, 리포트는 HTML, XML,CSV와 같은 형식으로 확인 가능합니다.

(1) JaCoCo 플러그인 설정

1. JaCoCo의 플러그인 설정을 위해 의존성을 pom.xml에 추가합니다.

2. pom.xml에 JaCoCo플러그인을 추가합니다.

<plugins>
    <plugin>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-maven-plugin</artifactId>
       <configuration>
          <excludes>
             <exclude>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
             </exclude>
          </excludes>
       </configuration>
    </plugin>
    <!-- 예제 7.20 -->
    <plugin>
       <groupId>org.jacoco</groupId>
       <artifactId>jacoco-maven-plugin</artifactId>
       <version>0.8.7</version>
       <!-- 예제 7.21 -->
       <configuration>
          <excludes>
             <exclude>**/ProductServiceImpl.class</exclude>
          </excludes>
       </configuration>
       <executions>
          <!-- 예제 7.22 -->
          <execution>
             <goals>
                <goal>prepare-agent</goal>
             </goals>
          </execution>
          <execution>
             <id>jacoco-report</id>
             <phase>test</phase>
             <goals>
                <goal>report</goal>
             </goals>
          </execution>
          <execution>
             <id>jacoco-check</id>
             <goals>
                <goal>check</goal>
             </goals>
             <!-- 예제 7.23 -->
             <configuration>
                <rules>
                   <rule>
                      <element>BUNDLE</element>
                      <limits>
                         <limit>
                            <counter>INSTRUCTION</counter>
                            <value>COVEREDRATIO</value>
                            <minimum>0.80</minimum>
                         </limit>
                      </limits>
                      <element>METHOD</element>
                      <limits>
                         <limit>
                            <counter>LINE</counter>
                            <value>TOTALCOUNT</value>
                            <maximum>50</maximum>
                         </limit>
                      </limits>
                   </rule>
                </rules>
             </configuration>
          </execution>
       </executions>
    </plugin>
</plugins>

 

Configuration 태그의 설정은 일부 클래스를 커버리지 측정 대상에서 제외하는 것입니다.

경로와 무관하게 ProductServiceImpl.class를 커버리지 측정 대상에서 제외하였습니다.

 

< excution>

기본적으로 < goal > 을 포함하며, 설정한 값에 따라 추가 설정이 필요한 내용을 < configuration > 과 < rule > 을 통해 작성합니다.

 

< goal > 속성 값

  • help : jacoco-maven-plugin에 대한 도움말을 보여줍니다.
  • prepare-agent : 테스트 중인 애플리케이션에 VM인수를 전달하는 JaCoCo 런타임 에이전트의 속성 준비, 에이전트는 maven-surefire-plugin을 통해 테스트한 결과 가져옵니다.
  • prepare-agent-integration: prepare-agent와 유사하지만 통합 테스트에 적합한 기본값 제공합니다.
  • merge : 실행 데이터 파일 세트(.exec)를 단일 파일로 병합합니다.
  • report : 단일 프로젝트 테스트를 마치면 생성되는 코드 검사 보고서를 다양한 형식(HTML, XML, CSV)중에서 선택할 수 있게 합니다.
  • report-integration : report와 유사하나 통합 테스트에 적합한 기본값 제공합니다.
  • report-aggregate : Refactor 내의 여러 프로젝트에서 구조화된 보고서를 생성, 보고서는 해당 프로젝트가 의존하는 모듈에서 생성합니다.
  • check : 코드 커버리지의 메트릭 충족 여부 검사, 메트릭이란 테스트 커버리지를 측정하는데 필요한 지표, 이는 check가 설정된 < execution > 태그 내 < rule > 을 통해 설정합니다.
  • dump : TCP 서버 모드에서 실행중인 JaCoco 에이전트에서 TCP/IP를 통한 덤프 생성합니다.
  • instrument : 오프라인 측정을 수행하는 명령합니다.
  • restore-instrumeneted-class : 원본 파일 저장 기능을 수행합니다.

Rule 설정

< configuration > 태그 안에 설정

Element는 코드 커버리지를 체크하는데 필요한 범위 기준 설정

  • BUNDLE(기본값) : 패키지 번들(프로젝트 내 모든 파일)
  • PACKAGE : 패키지
  • CLASS : 클래스
  • GROUP : 논리적 번들 그룹
  • SOURCEFILE : 소스 파일
  • METHOD : 메서드

BUNDLE은 Element를 기준으로 < limit> 태그 내 < counter> 과 < value > 를 활용해 커버리지 측정 단위와 방식 설정

Counter는 커버리지를 측정하는 데 사용하는 지표

  • LINE : 빈 줄을 제외한 실제 코드의 라인 수
  • BRANCH : 조건문 등의 분기 수
  • CLASS : 클래스 수
  • METHOD : 메서드 수
  • INSTRUCTION(기본 값) : 자바의 바이트코드 명령 수
  • COMPLEXITY : 복잡도, 멕케이브 순환 복잡도 따름

Value 태그로는 커버리지 지표 설정

  • TOTALCOUNT : 전체 개수
  • MISSEDCOUNT : 커버 되지 않은 개수
  • COVEREDCOUNT : 커버된 개수
  • MISSEDRATIO : 커버되지 않는 비율
  • COVEREDRATIO(기본값) : 커버된 비율

<코드 적용>

1) 패키지 번들 단위로 바이트코드 명령 수를 기준으로 커버리자가 최소한 80프로 달성하는것을 Limit으로 설정합니다.

2) 메서드 단위로 전체 라인 수를 최대 50라인줄로 설정하였습니다.

-> 기준들을 벗어날 시 에러가 발생합니다.

 

(2) JaCoCo 테스트 커버리지 확인

JaCoCo plugin으로 테스트 커버리지를 측정하려면 메이븐의 테스트 단계가 선행되어야 합니다.

메이븐 생명 주기에서 JaCoCo는 test 단계 뒤에 있는 package 단계에서 실행 가능합니다.

package를 더블 클릭해서 빌드를 진행하면 타겟 폴더에 폴더가 생성됩니다.

  • Element : 커버리지를 측정한 단위를 표현합니다.
  • Missed Instructions - Coverage : 테스트를 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식으로 제공합니다.
  • Missed Branches -Cov : 분기에 대한 테스트 커버지를 퍼센트와 바형식으로 제공합니다.
  • Missed - Cxty : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수를 제공합니다.
  • Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수를 제공합니다.
  • Missed - Methods : 테스트 대상 메서드 수와 커버되지 않은 메서드 수 제공합니다.
  • Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 메서드 수를 제공합니다.

7.6 테스트 주도 개발(TDO)

TDD(Test-Driven Development)란 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 방식입니다.

에자일 방법론 중 하나인 익스트림 프로그래밍의 Test-First 개념에 기반을 둔, 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시합니다. 

에자일: 신속한 개발 프로세스를 통해 수시로 변하는 고객의 요구사항에 대응해서 제공하는 서비스의 가치 극대화

 

(1) 테스트 주도 개발의 주기

  • 실패 테스트 작성 : 실패하는 경우의 테스트 코드를 먼저 작성
  • 테스트를 통과하는 코드 작성 : 테스트 코드를 성공 시키기 위한 실제 코드 작성
  • 리팩토링 : 중복 코드를 제거하거나 일반화하는 리팩토링 수행

(2) 테스트 주도 개발의 효과

  1. 디버깅 시간 단축 : 테스트 코드를 기반으로 개발이 진행되기에 문제 발생 시 어디에서 잘못됐는지 확인하기 쉬웁니다.
  2. 생산성 향상 : 테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대해 피드백을 받기에 리팩토링 횟수가 줄고 생산성이 높아집니다.
  3. 재설계 시간 단축 : 작성돼 있는 테스트 코드를 기반으로 코드를 작성하기에 재설계가 필요한 경우 테스트 코드를 조정하는 것으로 재설계 시간 단축 가능합니다.
  4. 기능 추가와 같은 추가 구현 용이 : 테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성하기에 목적에 맞는 코드 작성 용이합니다.

문제

1. 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도하는 대로 동작 하는지 테스트 하는 방식은?

통합테스트

2. given-when-then 패턴에서 테스트의 목적을 보여주는 단계, 실제 테스트 코드 포함되며, 테스트를 통한 결과값을 가져오는 부분은? when

3. 테스트 엔진 API의 구현체 포함하고 Jupiter 기반 테스트를 실행하기 위한 테스트 엔진을 가지는 JUnit 모듈은? JUnit Jupiter

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

5. JUnit 생명주기에서 관련 어노테이션 중 테스트 코드를 포함한 메서드 정의하는 어노테이션은? @Test

6. 테스트하는 입장에서 ProductController만 테스트 하고 싶다면 ProductService는 외부 요인에 해당합니다. 독립적인 테스트 코드 작성을 위해서는 어떤 객체를 활용 해야 할까요? Mock 객체

7. 단위 테스트와 통합 테스트의 중간 개념으로 , 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트 진행하는 의미를 가지는 테스트는? 슬라이스 테스트

8. @MockBean 어노테이션을 사용해 스프링 컨테이너에 Mock객체를 주입받는 방법 으로 테스트를 작성했습니다. [ .. ]에 들어갈 어노테이션은?

@ExtendWith(SpringExtension.class)
@[  ... ]({ProductServiceImpl.class})
class ProductServiceTest2 {

    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;
        ...
}

 

9. 기존의 DB를 이용하여 테스트를 진행하려고 합니다. [...]에 들어갈 어노테이션은?

@DataJpaTest
@[ .... ](replace= AutoConfigureTestDatabase.Replace.NONE)
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void save(){
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        Product savedProduct = productRepository.save(product);

        assertEquals(product.getName(), savedProduct.getName());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());

    }
}

 


해당 포스트는 장정우님,
[스프링부트 핵심가이드 : 스프링 부트를 활용한 애플리케이션 개발 실무] 를 참고하여 작성하였습니다.

아리

728x90

관련글 더보기