상세 컨텐츠

본문 제목

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

23-24/Spring 3

by Igumi 2024. 12. 27. 10:00

본문

728x90

 

7. 테스트 코드 작성하기

스프링부트 애플리케이션을 개발하면서 테스트 코드를 통해 우리가 개발한 코드를 어떻게 테스트할 수 있는지 알아보겠습니다. 

 

📌 테스트 코드

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

 

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

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

 

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

테스트 대상 범위를 기준으로 테스트 방법을 구분하면 크게 단위 테스트와 통합 테스트.

 

📌 단위 테스트(Unit Test)

애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식

일반적으로 메서드 단위로 테스트를 수행하게 되며, 메서드 호출을 통해 의도한 결괏값이 나오는지 확인하는 수준으로 테스트를 진행한다. 단위 테스트는 테스트 비용이 적게 들기 때문에 테스트 피드백을 빠르게 받을 수 있다.

 

📌 통합 테스트(Integration Test)

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

단위 테스트는 특정 모듈에 대한 테스트만 진행하기 때문에 데이터베이스나 네트워크 같은 외부 요인들을 제외하고 진행하는 데 비해 통합 테스트는 외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 온전히 동작하는지를 테스트하게 된다. 다만 테스트를 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점이 있다.

 

Tip. 테스트 비용이란?

금전적인 비용을 포함해서 시간, 인력과 같은 개발에 필요한 것들을 포괄한다. 통계적으로 하나의 서비스를 개발할 때 개발 과정에서 60%, 테스트 과정에서 40%의 비용이 든다고 알려져 있다.

 

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

테스트 코드를 작성하는 방법은 다양하다. 그중 많은 사람들이 사용하는 'Given-When-Then' 패턴과 'F.I.R.S.T' 전략을 소개한다.

 

Given-When-Then 패턴

 

일반적으로 단위 테스트보다는 비교적 많은 환경을 포함해서 테스트하는 인수 테스트에 적합하다고 알려져 있다. 단위 테스트에서도 유용하게 활용할 수 있지만 불필요하게 코드가 길어질 수 있어 잘 사용하지 않는다. 이 패턴을 통해 테스트 코드를 작성하면 '명세' 문서의 역할을 잘 수행한다는 측면에서 많은 도움이 된다.

 

아래와 같이 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성한다. 

Given
   - 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계
   - 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동 정의

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

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

 

좋은 테스트를 작성하는 5가지 속성 (F.I.R.S.T)

 

F.I.R.S.T는 테스트 코드를 작성하는데 도움이 될 수 있는 5가지 규칙. 대체로 단위 테스트에 적용할 수 있다.

빠르게(fast)
- 테스트는 빠르게 수행되어야 함
- 테스트가 느리면 코드를 개선하는 작업이 느려져 코드의 품질이 떨어질 수 있음

- 테스트 속도에 절대적인 기준은 없지만 목적을 단순하게 설정해서 작성하거나 외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있음

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

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

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

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

 

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

JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트, 통합 테스트를 위한 도구를 제공한다. 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원하기 때문에 몇 개의 어노테이션만으로 간편하게 테스트 코드를 작성할 수 있게 해준다. 또한 단정문(assert)을 통해 테스트 기댓값이 정상적으로 도출됐는지 검토할 수 있다는 장점이 있다.

 

7.4.1 JUnit의 세부 모듈

JUnit 5는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성된다. 

 JUnit Platform
- JVM에서 테스트를 시작하기 위한 뼈대 역할
- 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진의 인터페이스를 가지고 있음

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

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

 

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

 

7.4.2 스프링 부트 프로젝트 생성

프로젝트를 생성할 때 아래의 의존성을 추가한다.

Developer Tools : Lombok, Spring Configuration Processor
Web : Spring Web
SQL : Spring Data JPA, Maria DB Driver

 

6장에서 만들었던 프로젝트의 코드를 가져오되, 이번 장에서는 DAO 레이를 제외하고 진행한다. 이에 따라 ProductServiceImpl 클래스, Product 엔티티 클래스를 수정한다. 

@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);
    }
}

 

@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;
}

 

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>

 

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();
    }

    @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 어노테이션이 지정된 테스트는 실행되지 않는다. 다만 테스트 메서드로 인식되고 있어 test3() 메서드가 비활성화됐다는 로그가 출력된다.

7.4.6 컨트롤러 객체의 테스트

컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞는 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공해서 클라이언트에게 응답하는 역할을 수행한다. 즉, 애플리케이션을 구성하는 여러 레이어 중 가장 웹 가까이에 있는 모듈이다. ProductController를 대상으로 getProduct()와 createProduct() 메서드에 대한 테스트 코드 작성 예시를 살펴보자.

 

- ProductController의 getProduct() 메서드

@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 객체 활용해야 함

- getProduct() 메서드에 대한 테스트

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; // 컨트롤러의 API를 테스트하기 위해 사용된 객체

    @MockBean // -> ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
    ProductServiceImpl productService; 

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

        // Mock 객체에는 테스트 과정에서 맡을 동작을 정의해야 함 -> given() 메서드를 통해
        이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해
        어떤 결과를 리턴할 것인지 정의하는 구조로 코드 작성
        // given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
        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()) 
                .andExpect(jsonPath("$.name").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print()); // 요청과 응답의 전체 내용 확인할 수 있는 메서드

        // verify() : 지정된 메서드가 실행됐는지 검증하는 역할(일반적으로 given()에 정의된 동작과 대응)
        verify(productService).getProduct(123L);
    }
}

 

- 예제에서 사용된 어노테이션

@WebMvcTest(테스트 대상 클래스.class)
- 웹에서 사용되는 요청과 응답에 대한 테스트 수행 가능
- 대상 클래스만 로드해 테스트 수행
- 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의 컨트롤러 관련 빈 객체가 모두 로드됨
- @SpringBootTest보다 가볍게 테스트하기 위해 사용

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

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

@DisplayName
- 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현 정의할 수 있음

 

- ProductController의 createProduct() 메서드

@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {

    ProductResponseDto productResponseDto = productService.saveProduct(productDto);

    return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
  • createProduct() 메서드는 @RequestBody로 값을 받음

- createProduct() 메서드에 대한 테스트

@Test // getProduct() 테스크 코드와 유사한 구성
    @DisplayName("Product 데이터 생성 테스트")
    void createProductTest() throws Exception {
        // Mock 객체에서 특정 메소드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
        // given() 메서드를 통해 메서드의 동작 규칙 설정
        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 메서드를 통해 URL 구성
                                .content(content) // @RequestBody의 값을 넘겨주기 위해 content() 메서드에 DTO의 값 담아서 테스트 진행
                                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.number").exists()) // jsonPath().exists() -> POST 요청을 통해 도출된 결과값에 대해 각 항목이 존재하는지 검증
                .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 문자열을 자바 객체로 변환하는 역할

- pom.xml 파일에 Gson 라이브러리 추가

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

 

7.4.7 서비스 객체의 테스트

이번에는 서비스 레이어에 해당하는 ProductService 객체를 테스트한다. 

 

- getProduct() 메서드의 단위 테스트

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(Product.class)로 동작 설정, 일반적으로 given()으로 정의된 Mock 객체의 메서드 동작 감지는 매개변수의 비교를 통해 이뤄지나 
        레퍼런스 변수의 비교는 주솟값만으로 이뤄지기 때문에 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 객체의 동작을 정의하거나 검증하는 단계에서
    조건으로 특정 매개변수의 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용함

7.4.8 리포지토리 객체의 테스트

리포지토리는 개발자가 구현하는 레이어 중에서 가장 데이터 베이스와 가깝다. 그리고 JpaRepository를 상속받아 기본적인 쿼리 메서드를 사용할 수 있다. 리포지터리의 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문에 findById(), save() 같은 기본 메서드에 대한 테스트는 의미가 없다. H2 DB를 사용하여 테스트 코드 작성해야 하기 때문에 pom.xml 파일에 의존성을 추가한다.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

 

- 데이터베이스 저장 테스트

@DataJpaTest
public class ProductRepositoryTestByH2 {

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

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

	// 데이터베이스 저장 테스트 코드
    @Test
    void saveTest() {
        // given - 테스트에서 사용할 Product 엔티티 만들고
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);

        // when - 생성된 엔티티를 기반으로 save() 메서드를 호출해서 테스트 진행
        Product savedProduct = productRepository.save(product);

        // then - 정상적인 테스트가 이뤄졌는지 체크
        assertEquals(product.getName(), savedProduct.getName());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());
    }

 

📌 @DataJpaTest가 제공하는 기능

- JPA와 관련된 설정만 로드해서 테스트 진행

- 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백 진행

- 기본값으로 임베디드 데이터베이스를 사용. 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능함

 

- 데이터베이스 조회 테스트

@DataJpaTest
public class ProductRepositoryTestByH2 {

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

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

	// 데이터베이스 조회 테스트 코드
    @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());
    }
}

 

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

  • 코드커버리지: 소프트웨어의 테스트 수준이 충분한지 표현하는 지표
    테스트를 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용됨
  • 커버리지를 확인하는 가장 보편적인 도구가 JaCoCo
  • JaCoCo는 JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트함
  • JaCoCo는 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작
  • 리포트는 HTML, XML, CSV 같은 다양한 형식으로 확인 가능

7.5.1 JaCoCo 플러그인 설정

pom.xml 파일에서 설정한다. (코드 생략)

  • <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(기본값) : 커버된 비율, Value 속성을 지정하지 않는 경우의 기본값

 

7.5.2 JaCoCo 테스트 커버리지 확인

  • JaCoCo 플러그인으로 테스트 커버리지를 측정하려면 메이븐의 테스트 단계가 선행되어야 함
  • JaCoCo는 test 단계 뒤에 있는 package 단계에서 실행 가능
  • package를 더블클릭 -> 빌드를 진행하면 target 폴더 내 site → jacoco 폴더 생성
    (동작되지 않을 시 프로젝트 경로에 한글이 없는지 확인)
  • 각 칼럼의 의미
Element : 우측 테스트 커버리지를 측정한 단위를 표현 & 링크를 따라 들어가면 세부 사항을 볼 수 있음
Missed Instructions - Cov.(Coverage) : 테스트를 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식 제공
Missed Branches - Cov.(Coverage) : 분기에 대한 테스트 커버리지를 퍼센티지와 바 형식으로 제공
Missed - Cxty(Complexity) : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수를 제공
Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수를 제공
Missed - Methods : 테스트 대상 메서드 수와 커버되지 않은 메서드 수를 제공
Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 메서드 수를 제공

 

Method가 Element의 기준이 되고, 메서드 클릭하면 코드 레벨에서의 테스트 커버리지 확인 가능

  • 각 코드 라인은 초록색과 빨간색으로 표시
  • 초록색은 테스트에서 실행됐다는 의미
  • 빨간색은 테스트 코드에서 실행되지 않은 라인을 의미
  • 조건문의 경우 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. ____________ 패턴은 일반적으로 단위 테스트보다는 인수 테스트에 적합하다고 알려져 있으며,  '명세' 문서의 역할을 잘 수행한다는 측면에서 많은 도움이 된다. 
  3. 생명주기와 관련해서 테스트 순서에 관여하는 어노테이션 중 ____________는 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의한다.
  4. ____________는 웹에서 사용되는 요청과 응답에 대해 테스트 수행 가능한 어노테이션으로, @SpringBootTest보다 가볍게 테스트하기 위해 사용한다.
  5. JPA와 관련된 설정만 로드해서 테스트를 진행하며, 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백 진행하는 어노테이션은 ____________  이다. 
  6. ____________ 는 소프트웨어의 테스트 수준이 충분한지 표현하는 지표로, 테스트를 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용된다. ____________  는 커버리지를 확인하는 가장 보편적인 도구이다. 
  7. 반복 테스트를 이용한 소프트웨어 개발 방법론인 ____________는 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복한다.

 

PROGRAMMING QUIZ

1. 각 필드에 알맞은 어노테이션을 추가하세요.

// 알맞은 어노테이션 추가
class ProductControllerTest {

    // 알맞은 어노테이션 추가
    private MockMvc mockMvc; // 컨트롤러의 API를 테스트하기 위해 사용된 객체

    // 알맞은 어노테이션 추가 
    ProductServiceImpl productService; 

    // 알맞은 어노테이션 추가
    @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);
    }
}

 

2. 각 주석에 들어갈 코드를 작성해 데이터베이스를 조회하는 코드를 완성하세요.

// 알맞는 어노테이션을 추가해 리포지터리 정상 주입 
public class ProductRepositoryTestByH2 {

    @Autowired
    private ProductRepository productRepository;

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

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

		// 알맞은 코드를 작성해 데이터베이스 조회 테스트를 위해 Given 절에서 객체를 데이터베이스에 저장

        
        // when
        // 조회 메서드를 호출해서 테스트를 진행하고 이후 코드에서 데이터를 비교하며 검증 수행


        // then
        // 특정 메서드를 사용해 데이터 값 비교로 검증 수행
        // 이름
        // 가격
        // 재고
    }
}

정답

더보기

QUIZ

단위 테스트, Given-When-Then 패턴, @BeforeEach,  @WebMvcTest, @DataJpaTest, 코드커버리지, JaCoCo, 테스트 주도 개발(TDD)

 

PROGRAMMING QUIZ

1.

@WebMvcTest(ProductController.class)
class ProductControllerTest {

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

    @MockBean // -> ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
    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()); // 요청과 응답의 전체 내용 확인할 수 있는 메서드

    }
}

 

2. 

@DataJpaTest
public class ProductRepositoryTestByH2 {

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

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

	// 데이터베이스 조회 테스트 코드
    @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());
    }
}

 


[출처] 장정우, 「스프링부트 핵심 가이드」 7장

 

Corner Spring 3

ⓒ Nini

728x90

관련글 더보기