상세 컨텐츠

본문 제목

[스프링 3] 07 테스트 코드 작성하기

23-24/Spring 3

by recoday 2023. 11. 17. 10:00

본문

728x90

 

  • 테스트 코드란 작성한 코드나 비즈니스 로직 자체를 테스트하기 위해 작성한 코드를 의미

 

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

7.2 단위 테스트와 통합 테스트
  • 테스트 대상 범위를 기준으로 구분
  • 단위 테스트 : 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식
  • 통합 테스트 : 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식
7.2.1 단위 테스트의 특징
  • 단위테스트는 테스트 대상의 범위를 기준으로 가장 작은 단위의 테스트 방식
  • 일반적으로 메서드 단위로 테스트를 수행하며 메서드 호출을 통해 의도한 결과값이 나오는지 확인하는 수준으로 진행
  • 단위 테스트는 모듈을 독립적으로 테스트
7.2.2 통합 테스트의 특징
  • 통합테스트란 모듈을 통합하는 과정에서의 호환성 등을 포함해 애플리케이션이 정상적으로 동작하는지 확인하기 위해 수행하는 테스트 방식
  • 통합 테스트는 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지 확인

- 단위 테스트와 통합 테스트의 차이

단위 테스트 통합 테스트
모듈을 독립적으로 테스트 여러 모듈을 함께 테스트해서
정상적인 로직 수행이 가능한지 확인
특정 모듈에 대한 테스트만 진행하기 때문에
데이터베이스나 네트워크 같은 외부 요인들을 제외하고 진행
외부 요인들을 포함하고 테스트를 진행하므로
애플리케이션이 온전히 동작하는지 테스트
(단, 테스트를 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점 존재)

7.3 테스트 코드를 작성하는 방법
  • 'Given-When-Then' 패턴과 'F.I.R.S.T' 전략
7.3.1 Given-When-Then 패턴
  • 테스트 코드를 표현하는 방식 중 하나
  • 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성
  • Given-When-Then 패턴은 간단한 테스트로 여겨지는 단위 테스트에서는 잘 사용하지 않음
    (불필요하게 코드가 길어지기 때문)
  • '명세' 문서의 역할을 잘 수행한다는 측면에서 도움
◻️ Given
   - 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계
   - 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의

◻️ When
   - 테스트의 목적을 보여주는 단계
   - 실제 테스트 코드가 포함되며, 테스트를 통한 결괏값을 가져오게 됨

◻️ Then
   - 테스트의 결과를 검증하는 단계
   - 일반적으로 When 단계에 나온 결괏값을 검증하는 작업을 수행
   - 결괏값이 아니더라도 이 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 Then 단계에 포함

 

7.3.2 좋은 테스트를 작성하는 5가지 속성 (F.I.R.S.T)
  • F.I.R.S.T는 테스트 코드를 작성하는데 도움이 될 수 있는 5가지 규칙
  • 대체로 단위 테스트에 적용할 수 있는 규칙
◻️ 빠르게(fast)
   - 테스트가 느리면 코드를 개선하는 작업이 느려져 코드의 품질이 떨어질 수 있어 테스트는 빠르게 수행될 필요 O
   - 테스트 속도에 절대적인 기준은 없지만 목적을 단순하게 설정해서 작성하거나
     외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있음

◻️ 고립된, 독립적(Isolated)
   - 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 함
   - 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면
     외부 요인으로 인해 테스트가 수행되지 않을 수 있음

◻️ 반복 가능한(Repeatable)
   - 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 함
   - 테스트는 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행되어야 함

◻️ 자가 검증(Self-Validating)
   -  테스트는 그 자체만으로도 테스트의 검증이 완료되어야 함
   - 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야 함
     (결과값과 기대값을 비교하는 작업을 코드가 아니라 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드)

◻️ 적시에(Timely)
   - 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 함
     (너무 늦게 작성된 테스트 코드는 정상적인 역할을 수행하기 어려움 & 문제 해결에 소모되는 개발 비용 ↑)
   - 테스트 주도 개발의 원칙을 따르는 테스트 작성 규칙으로, 테스트 주도 개발 기반이 아니라면 생략 가능

7.4 JUnit을 활용한 테스트 코드 작성
  • JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트, 통합 테스트를 위한 도구 제공
  • 어노테이션 기반의 테스트 방식 지원 => 몇 개의 어노테이션만으로 간편하게 테스트 코드 작성 가능
  • JUnit을 활용하면 단정문(assert)을 통해 테스트 케이스의 기대값이 정상적으로 도출됐는지 검토할 수 있는 장점 존재
7.4.1 JUnit의 세부 모듈
  • JUnit 5는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성
  • JUnit은 하나의 Platform 모듈을 기반으로 Jupiter와 Vintage 모듈이 구현체의 역할을 수행
◻️ JUnit Platform
   - JVM에서 테스트를 시작하기 위한 뼈대 역할
   - 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진의 인터페이스를 가지고 있음
   - 테스트 엔진은 테스트를 발견하고 테스트를 수행하며, 그 결과를 보고하는 역할을 수행
   - 각종 IDE와의 연동을 보조하는 역할 수행

◻️ JUnit Jupiter
   - 테스트 엔진 API의 구현체를 포함
   - JUnit 5에서 제공하고 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있음
   - Jupiter Engine은 Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행하는 역할을 수행

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

 

7.4.2 스프링 부트 프로젝트 생성
  • 의존성 추가
◻️ Developer Tools : Lombok, Spring Configuration Processor
◻️ Web : Spring Web
◻️ SQL : Spring Data JPA, Maria DB Driver
  • ProductServiceImpl 클래스, Product 엔티티 클래스 수정 (코드 생략)
7.4.3 스프링 부트의 테스트 설정
  • 스프링 부트는 테스트 환경을 쉽게 설정할 수 있게 spring-boot-starter-test 프로젝트를 지원
      -> pom.xml 파일에 관련 의존성 추가
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
  • 스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ 등 다양한 테스트 도구 제공
◻️ JUnit 5 : 자바 애플리케이션의 단위 테스트를 지원
◻️ Spring Test & Spring Boot Test : 스프링 부트 애플리케이션에 대한 유틸리티와 통합 테스트를 지원
◻️ AssertJ : 다양한 단정문(assert)을 지원하는 라이브러리
◻️ Hamcrest : Matcher를 지원하는 라이브러리
◻️ Mockito : 자바 Mock 객체를 지원하는 프레임워크
◻️ JSONassert : JSON용 단정문 라이브러리
◻️ JsonPath : JSON용 XPath를 지원

 

7.4.4 JUnit의 생명주기
  • 생명주기와 관련해서 테스트 순서에 관여하게 되는 대표적인 어노테이션
◻️ @Test : 테스트 코드를 포함한 메서드를 정의
◻️ @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드를 정의
◻️ @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의
◻️ @AfterAll : 테스트를 종료하면서 호출되는 메서드를 정의
◻️ @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드를 정의
public class TestLifeCycle {

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

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

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

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

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

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

    // Disabled Annotation : 테스트를 실행하지 않도록 하는 어노테이션
    @Test
    @Disabled
    void test3() {
        System.out.println("## test3 시작 ##");
        System.out.println();
    }

}
수행결과

## BeforeAll Annotation 호출 ##

## BeforeEach Annotation 호출 ##

## test1 시작 ##

## AfterEach Annotation 호출 ##

## BeforeEach Annotation 호출 ##

## test2 시작 ##

## AfterEach Annotation 호출 ##

void com.springboot.test.TestLifeCycle.test3() is @Disabled

## AfterAll Annotation 호출 ##
  • @BeforeEach와 @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행
  • @BeforeEach와 @AfterEach 어노테이션이 지정된 메서드는 각 테스트가 실행될 때 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행
  • @Disabled 어노테이션이 지정된 테스트는 테스트 메서드로 인식은 되지만 실행되지 않음
7.4.6 컨트롤러 객체의 테스트
  • 컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞는 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공해서클라이언트에게 응답하는 역할을 수행 => 애플리케이션을 구성하는 여러 레이어 중 가장 웹 가까이에 있는 모듈
  • ProductController를 대상으로 getProduct()와 createProduct() 메서드에 대한 테스트 코드 작성 예시
@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) {
        ProductResponseDto productResponseDto = productService.getProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

 

  • ProductControlller는 ProductService의 객체를 의존성 주입받음
  • ProductController만 테스트한다면 ProductService는 외부 요인에 해당 -> 독립적인 테스트를 위해 Mock 객체 활용
package com.springboot.test.controller;

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.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 com.google.gson.Gson;
import com.springboot.test.data.dto.ProductDto;
import com.springboot.test.data.dto.ProductResponseDto;
import com.springboot.test.service.impl.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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc; //  MockMvc : 컨트롤러의 API를 테스트하기 위해 사용된 객체

    // ProductController에서 잡고 있는 Bean 객체에 대해 Mock 형태의 객체를 생성해줌
    @MockBean
    ProductServiceImpl productService;

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

        // given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
        // given() & willReturn()
        given(productService.getProduct(123L)).willReturn(
                new ProductResponseDto(123L, "pen", 5000, 2000));

        String productId = "123";

        // perform() 메서드 이용 -> 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러 테스트 가능
        // andExcept() 메서드 를 사용해 perform() 메서드의 결과값으로 리턴된 ResultAction 객체 결과값 검증 수행 가능
        mockMvc.perform(
                        get("/product?number=" + productId))
                .andExpect(status().isOk())
                .andExpect(jsonPath(
                        "$.number").exists()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
                .andExpect(jsonPath("$.name").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print()); // 요청과 응답의 전체 내용을 확인하기 위해 andDo() 사용

        // verify() : 지정된 메서드가 실행됐는지 검증하는 역할(일반적으로 given()에 정의된 동작과 대응)
        verify(productService).getProduct(123L);
    }
}
  • Mockito에서 제공하는 given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성
◻️@WebMvcTest(테스트 대상 클래스.class)
   - 웹에서 사용되는 요청과 응답에 대한 테스트 수행 가능
   - 대상 클래스만 로드해 테스트를 수행
   - 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의
     컨트롤러 관련 빈 객체가 모두 로드
   - @SpringBootTest보다 가볍게 테스트하기 위해 사용

◻️ @MockBean
   - 실제 빈 객체가 아닌  Mock(가짜) 객체를 생성해서 주입하는 역할 -> 실제 행위를 수행하지 X
   - 따라서 해당 객체는 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 함

◻️ @Test
   - 테스트 코드가 포함되어 있다고 선언하는 어노테이션
   - JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킴

◻️ @DisplayName
   - 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현 정의 가능
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {

    ProductResponseDto productResponseDto = productService.saveProduct(productDto);

    return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
  • createProduct() 메서드는 @RequestBody로 값을 받음
// createProduct() 메서드 테스트 코드 (getProduct() 테스트 코드와 유사)
    @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));
    }
  • pom.xml 파일에 Gson에 대한 의존성 추가 필요
  • Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할

<Gson 의존성 추가>

<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
</dependency>

 

7.4.7 서비스 객체의 테스트
  • 서비스 레이어에 해당하는 ProductService 객체 테스트
  • 단위 테스트를 수행할 클래스는 test/java/com.springboot.test 내에 service/impl 패키지를 생성 후 ProductServiceTest 파일 생성
package com.springboot.test.service.impl;

import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;

import com.springboot.test.data.dto.ProductDto;
import com.springboot.test.data.dto.ProductResponseDto;
import com.springboot.test.data.entity.Product;
import com.springboot.test.data.repository.ProductRepository;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

public class ProductServiceTest {

    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    // Mockito의 mock() 메서드를 통해 Mock 객체로 ProductRepository 주입
    private ProductServiceImpl productService;

	// 위의 Mock 객체를 기반으로 각 테스트 전 ProductService로 객체를 초기화해서 사용
    @BeforeEach
    public void setUpTest() {
        productService = new ProductServiceImpl(productRepository);
   
    }

    @Test
    void getProductTest() {
        // given -> 테스트에 사용될 Product 엔티티 객체를 생성하고 ProductRepository의 동작에 대한 결과값 리턴 설정
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

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

        // when
        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // then
        // 리턴받은 ProductResponseDto 객체에 대해서 Assertion을 통해 값을 검증함으로 테스트 목적을 달성하는지 확인
        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()로 부가 검증 시도
        verify(productRepository).findById(123L);
    }

    @Test
    void saveProductTest() {
        // given
        // any()
        Mockito.when(productRepository.save(any(Product.class)))
                .then(returnsFirstArg());

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

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

        verify(productRepository).save(any());
    }
}
  • 단위 테스트를 위해서는 외부 요인을 모두 배제하도록 코드를 작성해야 함 -> @...Test 어노테이션 선언 X
  • any()는 Mockito의 ArgumentMatchers에서 제공하는 메서드로 Mock 객체의 동작을 정의하거나 검증하는 단계에서
    조건으로 특정 매개변수의 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용

package com.springboot.test.service.impl;

import ...

// 스프링에서 객체를 주입받기 위해 스프링 테스트 컨텍스트를 사용하도록 설정
@ExtendWith(SpringExtension.class)
	// @Autowired 어노테이션으로 주입받는 ProductService를 주입받기 위해 직접 클래스를 @Import 어노테이션을 통해 사용
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {

    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;
    
    ... 생략 ...
}

@MockBean 어노테이션을 사용한 테스트 환경 설정

  • @MockBean을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입받는 형식
  • Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식
7.4.8 리포지토리 객체의 테스트
  • 리포지토리는 개발자가 구현하는 레이어 중에서 가장 데이터 베이스와 가까움 & JpaRepository를 상속받아 기본적인 쿼리 메서드 사용 가능
  • findById(), save()와 같은 기본 메서드에 대한 테스트는 의미 X
  • H2 DB를 사용하여 테스트 코드 작성
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

 

<H2 DB 의존성 추가>

// @DataJpaTest 어노테이션 선언
@DataJpaTest
public class ProductRepositoryTestByH2 {

	// @DataJpaTest 어노테이션 선언 -> 리포지토리 정상 주입 가능
    @Autowired
    private ProductRepository productRepository;

	// Given-When-Then 패턴으로 작성

	// 데이터베이스 저장 테스트 코드
    @Test
    void saveTest() {
        // given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

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

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


	// 데이터베이스 조회 테스트 코드
    @Test
    void selectTest() {
        // given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

		// 데이터베이스 조회 테스트를 위해 Given 절에서 객체를 데이터베이스에 저장
        Product savedProduct = productRepository.saveAndFlush(product);

        // when
        // 조회 메서드를 호출해서 테스트를 진행하고 이후 코드에서 데이터를 비교하며 검증 수행
        Product foundProduct = productRepository.findById(savedProduct.getNumber()).get();

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

  @DataJpaTest의 기능

◻️ JPA와 관련된 설정만 로드해서 테스트를 진행
◻️ 기본적으로 @Transactional 어노테이션을 포함해 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백 진행
◻️ 기본값으로 임베디드 데이터 베이스를 사용 & 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능

  • Given 구문에서는 테스트에서 사용할 Product 엔티티 생성
  • When 구문에서는 생성된 엔티티를 기반으로 save() 메서드를 호출해서 테스트를 진행
  • 이후 정상적인 테스트가 이뤄졌는지 체크하기 위해 save() 메서드의 리턴 객체와 Given에서 생성한 엔티티 객체의 값이 일치하는지 assertEquals() 메서드를 통해 검증

 

@DataJpaTest
// replace 요소는 @AutoConfigureTestDatabase 어노테이션 값을 조정하는 작업 수행
@AutoConfigureTestDatabase(replace = Replace.NONE)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

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

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

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

}

<테스트 데이터베이스 변경을 위한 어노테이션 추가>

 

  • 지금까지 다룬 @DataJpaTest를 사용하지 않고 @SpringBootTest 어노테이션으로도 테스트 가능
@SpringBootTest
public class ProductRepositoryTest2 {

    @Autowired
    ProductRepository productRepository;

    @Test
    public void basicCRUDTest() {
        // 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());

        // 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(), "장난감");

        // when
        productRepository.delete(updatedProduct);

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

7.5 JaCoCo를 활용한 테스트 커버리지 확인
  • 코드커버리지란 소프트웨어의 테스트 수준이 충분한지 표현하는 지표
    또는 테스트를 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용
  • 커버리지를 확인하는 가장 보편적인 도구가 JaCoCo
  • JaCoCo는 JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트함
  • JaCoCo는 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작
  • 리포트는 HTML, XML, CSV 같은 다양한 형식으로 확인 가능
7.5.1 JaCoCo 플러그인 설정
  • pom.xml 파일에 의존성 추가
<dependency>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.7</version>
</dependency>

 

  • pom.xml 파일에 JaCoCo 플러그인 추가 (pom.xml 파일의 <build> 태그 안에 추가)
<configuration>
    <excludes>
       <exclude>**/ProductServiceImpl.class</exclude>
    </excludes>
</configuration>

<configuration 설정>

  • configuration 설정은 일부 클래스를 커버리지 측정 대상에서 제외하는 것을 의미
  • 위의 코드에서는 경로와 무관하게 ProductServiceImpl.class를 커버리지 측정 대상에서 제외하도록 설정

 

<executions>
    <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>
       <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>

<executions 설정>

  • <execution>은 기본적으로 <goal>을 포함하며, 설정한 값에 따라 추가 설정이 필요한 내용을 <configuration>과 <rule>을 통해 작성
  • <execution>에서 설정할 수 있는 <goal>의 속성값
더보기

◻️ help : jacoco-maven-plugin에 대한 도움말을 보여줌

◻️ prepare-agent : 테스트 중인 애플리케이션에 VM 인수를 전달하는 JaCoCo 런타임 에이전트의 속성을 준비

       에이전트는 maven-surefire-plugin을 통해 테스트한 결과를 가져오는 역할 수행

◻️ prepare-agent-integration : prepare-agent와 유사하지만 통합 테스트에 적합한 기본값을 제공

◻️ merge : 실행 데이터 파일 세트(.exec)를 단일 파일로 병합

◻️ report : 단일 프로젝트 테스트를 마치면 생성되는 코드 검사 보고서를 HTML, XML, CSV 중에서 선택 가능

◻️ report-aggregate : Reactor 내의 여러 프로젝트에서 구조화된 보고서(HTML , XML, CSV)를 생성

        보고서는 해당 프로젝트가 의존하는 모듈에서 생성

◻️ check : 코드 커버리지의 메트릭 충족 여부를 검사

◻️ dump : TCP 서버 모드에서 실행 중인 JaCoCo 에이전트에서 TCP/IP를 통한 덤프를 생성

◻️instrument : 오프라인 측정을 수행하는 명령

      테스트를 실행한 후 restore-instrument-classes Goal로 원본 클래스 파일을 저장해야 함

◻️ restore-instrumented-class : 오프라인 측정 전 원본 파일을 저장하는 기능 수행

  • JaCoCo의 Rule은 <configuration> 태그 안에 설정하며, 다양한 속성 활용 가능
  • Element는 코드 커버리지를 체크하는데 필요한 범위 기준을 설정
◻️ BUNDLE(기본값) : 패키지 번들(프로젝트 내 모든 파일)
       & Element를 기준으로 <limits> 태그 내 <counter>와 <value>를 활용해 커버리지 측정 단위와 방식을 설정
◻️ PACKAGE : 패키지
◻️ CLASS : 클래스
◻️ GROUP : 논리적 번들 그룹
◻️ SOURCEFILE : 소스 파일
◻️ METHOD : 메서드

 

  • Counter는 커버리지를 측정하는데 사용하는 지표 (6가지 측정 단위 존재)
◻️ LINE : 빈 줄을 제외한 실제 코드의 라인 수
◻️ BRANCH : 조건문 등의 분기 수
◻️ CLASS : 클래스 수
◻️ METHOD : 메서드 수
◻️ INSTRUCTION(기본값) : 자바의 바이트 코드 명령 수
◻️ COMPLEXITY : 복잡도 (복잡도는 맥케이브 순환 복잡도 정의를 따름)

 

  • value 태그로는 커버리지 지표를 설정 (측정한 커버리지를 어떤 방식으로 보여주는지 설정 & 5가지 방식 존재)
◻️ TOTALCOUNT : 전체 개수
◻️ MISSEDCOUNT : 커버되지 않은 개수
◻️ COVEREDCOUNT : 커버된 개수
◻️ MISSEDRATIO : 커버되지 않은 비율
◻️ COVEREDRATIO(기본값) : 커버된 비율

 

7.5.2 JaCoCo 테스트 커버리지 확인

 

 

  • JaCoCo 플러그인으로 테스트 커버리지를 측정하려면 메이븐의 테스트 단계가 선행되어야 함
  • 메이븐의 생명주기는 위의 그림과 같이 Maven 탭에서 확인 가능
  • JaCoCo는 test 단계 뒤에 있는 package 단계에서 실행 가능

  • package를 더블클릭해서 빌드를 진행하면 위와 같이 target 폴더 내 site → jacoco 폴더 생성
    (동작되지 않을 시 프로젝트 경로에 한글이 없는지 확인)

  • 기본적으로 JaCoCo 리포트 파일은 HTML, CSV, XML 형식으로 제공
    (일반적으로 보고서를 바로 보기 위해 HTML 파일 이용)
  • 웹 브라우저를 통해 HTML 파일을 열면 위와 같이 리포트 결과를 확인 가능
<각 칼럼의 의미>
◻️ Element : 우측 테스트 커버리지를 측정한 단위를 표현 & 링크를 따라 들어가면 세부 사항을 볼 수 있음
◻️ Missed Instructions - Cov.(Coverage) : 테스트를 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식 제공
◻️ Missed Branches - Cov.(Coverage) : 분기에 대한 테스트 커버리지를 퍼센티지와 바 형식으로 제공
◻️ Missed - Cxty(Complexity) : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수를 제공
◻️ Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수를 제공
◻️ Missed - Methods : 테스트 대상 메서드 수와 커버되지 않은 메서드 수를 제공
◻️ Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 메서드 수를 제공

 

 

  • 각 코드 라인은 초록색과 빨간색으로 표시
  • 초록색은 테스트에서 실행됐다는 의미
  • 빨간색은 테스트 코드에서 실행되지 않은 라인을 의미
  • 조건문의 경우 true와 false에 대한 모든 케이스가 테스트됐다면 초록색 표시, 둘 중 하나만 테스트됐다면 노란색 표시

 

7.6 테스트 주도 개발(TDD)
  • 테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론
  • 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식
  • 애자일 방법론 중 하나인 익스트림 프로그래밍(eXtreme Programming)의 Test-First 개념에 기반
  • 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시
7.6.1 테스트 주도 개발의 개발 주기
  • 실패 테스트 작성(Write a falling test) : 실패하는 경우의 테스트 코드를 먼저 작성
  • 테스트 코드를 통과하는 코드 작성(Make a test pass) : 테스트 코드를 성공시키기 위한 실제 코드 작성
  • 리팩토링(Refactor) : 중복 코드를 제거하거나 일반화하는 리팩토링을 수행
  • 설계를 진행한 후 개발 코드를 작성하고 마지막으로 테스트 코드를 작성하는 흐름의 일반적인 개발 방법과 다르게
    테스트 주도 개발에서는 설계 이후 바로 테스트 코드를 작성하고 개발 코드를 작성한다는 차이가 존재

7.6.2 테스트 주도 개발의 효과
  • 디버깅 시간 단축 : 테스트 코드 기반으로 개발이 진행 -> 문제 발생 시 잘못된 부분을 확인하기 쉬움
  • 생산성 향상 : 테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대한 피드백 -> 리팩토링 횟수가 줄고 생산성이 높아짐
  • 재설계 시간 단축 : 작성되어 있는 테스트 코드를 기반으로 코드를 작성 -> 재설계가 필요할 경우 테스트 코드를 조정하는 것으로 재설계 시간 단축 가능
  • 기능 추가와 같은 추가 구현 용이 : 테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성 -> 목적에 맞는 코드를 작성하는 데 용이

Quiz 
  1. 단위 테스트  )는 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식이며, (  통합 테스트  )는 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식이다.
  2. 테스트 코드를 표현하는 방식 중 하나로, 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성하는 패턴을 (    Given-When-Then 패턴   ) 이라고 한다.
  3. JUnit의 생명주기에서 (    @BeforeEach    )와 (    @AfterAll    ) 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행한다.
  4. (    @MockBean    )을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입받는 형식이고
       (   Mockito.mock()  )을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식이다.
  5. CRUD의 모든 기능을 한 테스트 코드에 작성하며, 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요없이 테스트 가능한 어노테이션은?  답 :  @SpringBootTest
  6.  Java Code Coverage의 약자로, JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를기준으로 한 커버리지로 리포트하는 도구는?  답 : JaCoCo
  7. 반복 테스트를 이용한 소프트웨어 개발 방법론으로 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시하는
        개발 방법론의 이름은?  답 : 테스트 주도 개발 (TDD)
코드 1)
컨트롤러 객체 테스트 시 getProduct()의 테스트 코드를 given()과 willReturn()을 사용하여 빈 코드를 작성하면?

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc; //  MockMvc : 컨트롤러의 API를 테스트하기 위해 사용된 객체
    @MockBean
    ProductServiceImpl productService;

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

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

        String productId = "123";


코드 2)
@MockBean 어노테이션을 사용한 테스트 환경 설정을 위해 작성해야하는 빈칸의 어노테이션 두 가지는?

... 생략 ...
@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})

    class ProductServiceTest2 {
        @MockBean ProductRepository productRepository;
        @Autowired ProductService productService;
         ... 생략 ...
     }

 

 

 

[출처] 장정우, 『스프링 부트 핵심가이드 스프링 부트를 활용한 애플리케이션 개발 실무』, 위키북스(2022), p159-207.

 

ⓒ 다다

728x90

관련글 더보기