일단 씻고 나가자

[스프링 부트 핵심 가이드] 07. 테스트 코드 작성하기 본문

Backend/Spring

[스프링 부트 핵심 가이드] 07. 테스트 코드 작성하기

일단 씻고 나가자 2023. 6. 8. 18:04

(본 포스팅은 해당 도서의 정리 및 개인 공부의 목적 포스팅임을 밝힙니다.)
장정우, 『스프링 부트 핵심 가이드 : 스프링 부트를 활용한 애플리케이션 개발 실무』, 위키북스, 2022

 

 

 

07. 테스트 코드 작성하기

서비스의 규모가 커짐에 따라, 테스트 코드로 로직이 논리적으로 잘 동작하는지 확인에 대한 중요성이 높아지고 있다.

테스트 코드는 말 그대로 작성한 코드가 예상했던대로 정상적으로 작동하는지에 대한 확인 방법이며,

만일 테스트 코드를 사용하지 않는다면 로직을 수정할 때마다 코드를 반영하고, 서버를 다시 띄우고, 다시 클라이언트 측에서 검증해보는 과정이 반복될 것이다.

테스트 코드의 중요성이 높아짐에 따라 Agile 방법론 중 하나인 '테스트 주도 개발(TDD - Test Driven Developement)'도 등장하는 추세이므로, 이번 장에서는 테스트 코드의 작성 방법과 TDD에 대해 간략히 알아보도록 한다.

 

 

 

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

테스트 코드를 작성하는 이유를 간략히 살펴보면 다음과 같다.

 

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

 

이를 풀어서 설명해보자.

 

우선 개발 과정에서 문제를 미리 알 수 있다. 하나의 로직 수행에 있어 입력에 대한 결과가 의도한대로 잘 나오는지, 혹은 해당 로직에서 발생할 수 있는 에러를 테스트함으로써 실제 서비스 이전에 여러 문제를 검증하고 고민하는 과정이 된다.

 

또한 리팩토링의 리스크가 줄어들며, 애플리케이션을 재가동하여 직접 테스트 하는 것보다 효율적이다. 하나의 로직을 작성하면 그 로직을 이용하는 다른 로직을 개발한다든지, 혹은 다른 코드 및 서비스에 영향을 가는 로직을 새로 작성한다든지 하는 일은 개발 중 빈번하게 일어난다. 테스트 코드가 없다면 수정 및 새로운 로직의 작성 시마다 다른 곳에 영향이 가는지를 매번 서버를 재가동하고 일일이 클라이언트 측에서 체크해야 하며, 테스트 코드를 작성함으로 인해 이러한 부작용을 대비할 수 있다.

 

마지막으로 명세 문서의 기능과, 협업하는 다른 개발자에게 해당 로직의 기능을 명확히 보여줄 수 있다. 협업의 과정에서는 서로의 코드를 일부 수정하거나, 가져오거나, 혹은 자신의 코드와 합치는 과정은 빈번하게 일어난다. 남의 코드를 이해하거나 남이 나의 코드를 이해하기는 쉽지 않은데, 이때 나의 코드의 기능과 사용법을 다른 사람에게 쉽게 이해시키는 방법을 테스트 코드가 담당할 수 있다.

 

 

 

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

테스트 방법은 대상의 범위에 따라 단위 테스트(Unit Test)와 통합 테스트(Integration Test)로 구분된다.

간략히 단위 테스트는 개별 기능, 모듈이 논리적으로 잘 동작하는지를 테스트하며, 통합 테스트는 전체 모듈이 결합된 전체 로직이 잘 동작되는지를 테스트한다.

 

 

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 패턴은 TDD에서 파생된 BDD(Behavior-Driven-Development)를 통해 탄생한 테스트 접근 방식으로,

일반적으로 단위 테스트보다는 인수 테스트(많은 환경을 포함한 테스트)에 적합하다고 알려져 있다.

그 이유는 간단한 단위 테스트에 적용할 시 불필요하게 코드가 길어지는 것에 있는데,

그렇다고 단위 테스트에 아예 적용하지 못하는 것은 아니며, 잘 작성한다면 명세 문서의 역할을 수행할 수도 있어진다.

 

Given-When-Then 패턴은 이름에서 알 수 있듯 세 단계로 나누어지며, 각 단계의 목적에 맞게 코드를 작성한다.

 

  • Given : 테스트 수행을 위한 환경 설정 단계. 테스트를 위한 변수 설정, 혹은 Mock 객체를 통한 특정 상황을 전제한다.
  • When : 테스트의 목적을 보이는 단계. 실제 테스트 코드를 작성하며, 테스트 이후의 결괏값을 받아온다.
  • Then : 테스트의 결과를 검증하는 단계. When 단계의 결괏값이 의도한 값과 일치하는지 확인하며, 그 외의 결괏값에서 검증해야 할 부분을 포함한다.

 

 

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

F.I.R.S.T 전략은 테스트 코드 작성에 도움을 주는 5가지 규칙을 의미한다.

대체로 단위 테스트에 적용하기 적합한 규칙을 담고 있다.

 

  • Fast (빠르게) : 빠르게 수행할 수 있는 테스트를 지향한다. 대체로 단순한 목적이거나, 외부 환경을 사용하지 않는 단위 테스트를 빠른 테스트라 할 수 있다.
  • Isolated (독립적이게) : 하나의 목적을 위한 하나의 대상에서만의 수행을 지향한다. 다른 테스트 코드, 혹은 외부 소스를 활용한다면 외부 요인에 의해 테스트 수행이 실패할 수 있다.
  • Repeatable (반복 가능하게) : 어떤 환경에서도 수행할 수 있는 테스트를 지향한다. 개발 환경 혹은 네트워크 연결 여부와 상관 없이 수행되어야 한다.
  • Self-Validating (자가 검증) : 그 자체만으로 검증이 완료되는 테스트를 지향한다. 결괏값과 기댓값의 비교를 코드 내부가 아닌 개발자가 눈으로 직접 확인해선 안 된다.
  • Timely (적시에) : 애플리케이션 코드 구현 전에 테스트 코드의 작성을 지향한다. 다만 이 원칙은 TDD를 전제로 하기 때문에, TDD가 아니라면 이 규칙을 제외하기도 한다.

 

 

 

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

JUnit은 자바에서 활용되는 대표적인 테스트 프레임워크이다.

단위/통합 테스트 기능을 모두 제공하며, 어노테이션 기반 테스트 방식으로 간편히 테스트가 가능하고,

단정문(assert)을 활용하여 기댓값과 테스트 결괏값을 검토할 수 있다는 장점이 있다.

 

본 책에서 활용할 JUnit은 5버전으로, 스프링부트 2.2 버전 이상부터 활용 가능하다.

 

 

7.4.1 JUnit의 세부 모듈

JUnit5는 크게 세 가지 모듈로 구성된다.

하나의 Platform 모듈 기반하여 Jupiter, Vintage 모듈이 구현체의 역할을 수행한다.

 

JUnit Platform

테스트를 발견하고, 테스트를 수행하여, 결과를 보고하는 테스트 엔진(TestEngine) 인터페이스를 갖고 있다.

또한 각종 IDE와의 연동을 보조한다. (IDE 콘솔 출력 등)

TestEngine API, Console Launcher, JUnit 4 Based Runner 등이 포함돼 있다.

 

JUnit Jupiter

테스트 엔진 API 구현체를 포함하며, JUint5 제공의 Jupiter 기반 테스트를 실행하기 위한 테스트 엔진을 갖는다.

실제 구현체는 별도 모듈 역할을 수행하며, 그 중 Jupiter Engine은 Jupiter API 기반 테스트 코드를 발견하고 실행한다.

 

JUnit Vintage

JUnit 3, 4에서 작성된 테스트를 실행할 테스트 엔진을 가지며, Vintage Engine을 포함한다.

 

 

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

이번 장의 실습을 위해 새로운 프로젝트를 생성한다.

start.spring.io에 접속하여 다음과 같이 설정하고 [GENERATE], 압축을 풀어 IDE로 open 한다.

 

 

이후 이전 장에서 작성한 코드 (클래스) 중 일부를 다음과 같이 복사해온다.

 

 

이전 장의 실습에선 계층 구조의 확인을 위해 DAO를 구성했지만,

이번 장에선 보다 간결하고 간단한 테스트를 위해 삭제한다.

따라서 DAO 설정을 위해 Product 클래스와 ProductServiceImpl 클래스를 다음과 같이 수정한다.

package com.springboot.test.data.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDateTime;

@Builder
@ToString(exclude = "name")
@EqualsAndHashCode
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@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 updatedAt;
}
package com.springboot.test.service.impl;

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 com.springboot.test.service.ProductService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@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 = ProductResponseDto.builder()
                                                                .number(product.getNumber())
                                                                .name(product.getName())
                                                                .price(product.getPrice())
                                                                .stock(product.getStock())
                                                                .build();

        return productResponseDto;
    }

    @Override
    public ProductResponseDto saveProduct(ProductDto productDto) {
        LOGGER.info("[saveProduct] productDto : {}", productDto.toString());
        Product product = Product.builder()
                                .name(productDto.getName())
                                .price(productDto.getPrice())
                                .stock(productDto.getStock())
                                .build();

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

        ProductResponseDto productResponseDto = ProductResponseDto.builder()
                                                                .number(product.getNumber())
                                                                .name(product.getName())
                                                                .price(product.getPrice())
                                                                .stock(product.getStock())
                                                                .build();

        return productResponseDto;
    }

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

        ProductResponseDto productResponseDto = ProductResponseDto.builder()
                                                                .number(changedProduct.getNumber())
                                                                .name(changedProduct.getName())
                                                                .price(changedProduct.getPrice())
                                                                .stock(changedProduct.getStock())
                                                                .build();

        return productResponseDto;
    }

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

 

 

7.4.3 스프링 부트의 테스트 설정

스프링 부트 테스트 환경을 쉽게 설정할 수 있도록, 스프링은 spring-boot-starter-test 프로젝트를 지원한다.

pom.xml 파일에 다음과 같이 의존성을 추가한다.

 

 

해당 라이브러리에서 제공하는 대표적인 라이브러리는 다음과 같다.

 

  • JUnit 5 : 자바 애플리케이션 단위 테스트 지원
  • Spring Test & Spring Boot Test : 스프링 부트 애플리케이션에 대한 유틸리티, 통합 테스트 지원
  • AssertJ : 단정문(assert) 지원의 라이브러리
  • Hamcrest : Matcher 지원의 라이브러리
  • Mockito : 자바 Mock 객체 지원의 프레임워크
  • JSONassert : JSON용 단정문 지원의 라이브러리
  • JsonPath : JSON용 XPath 지원

 

 

7.4.4 JUnit의 생명주기

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

 

  • @Test : 테스트 코드를 포함한 메서드 정의
  • @BeforeAll : 테스트 시작 전 호출되는 메서드 정의
  • @BeforeEach : 각 테스트 메서드 실행 전 호출되는 메서드 정의
  • @AfterAll : 테스트 종료 후 호출되는 메서드 정의
  • @AfterEach : 각 테스트 메서드 실행 후 호출되는 메서드 정의

 

정확한 작동 방법을 알기 위해 테스트 해본다.

test/java/com.springboot.test 패키지 내부에 TestLifeCycle 클래스를 다음과 같이 작성한다.

 

static 선언 부와 어노테이션 설정을 유의하길 바란다

 

이제 테스트 실행 결과를 확인해보자.

테스트 실행 방법은 public class TestLifeCycle 옆의 초록색 두 삼각형이 겹쳐진 도형을 누르면 일괄 실행된다.

 

 

@Before/AfterAll 의 경우, 테스트 전체의 실행 전/후 한 번씩만 실행되는 것을 확인할 수 있고,

@Before/AfterEach 의 경우 각각의 테스트 실행 전/후마다 실행되는 것을 확인할 수 있다.

@DisplayName("Test Case 2")으로 설정한 test2() 의 경우, 왼쪽 창 상단에 메서드 명이 아닌 Test Case 2로 표기된다.

@Disabled 으로 선언된 test3() 의 경우엔 실행되지 않았는데, 다만 @Test 으로 선언하였기에 테스트 메서드로 인식되고는 있어서 "void com.springboot.test.TestLifeCycle.test() is @Disabled" 메세지로 비활성화 되었다는 것을 알려주고 있다. 

 

 

7.4.5 스프링 부트에서의 테스트

테스트 기능은 다양하며, 기본적으로 전체적인 테스트로는 통합 테스트, 모듈별 테스트로는 단위 테스트를 진행해야 한다.

하지만 스프링 부트를 활용하는 애플리케이션에서는 자동으로 지원되는 기능들을 다수 사용하고 있기에, 정확하게 일부 모듈별로 진행되는 테스트 수행이 어려운 경우가 생길 수 있다.

따라서 이제부터는 레이어별로 적합한 테스트 방법을 소개하며, 다만 목적에 따라 테스트 구현 방법을 달리 해야할 경우도 있으니 다양한 테스트를 구성하고 경험해야 한다.

 

 

7.4.6 컨트롤러 객체의 테스트

컨트롤러 단계는 웹에 가장 가까운 최상단 계층으로, 대체로 서비스 계층의 객체를 주입받아 기능을 수행한다.

이때 컨트롤러의 기능만을 테스트하고 싶다면 주입받는 서비스 객체 또한 외부 요인에 해당하여 테스트의 의도에 벗어날 수 있다. 따라서 독립적인 테스트를 위해 Mock 객체를 활용하는 방법을 알아본다.

 

ProductController의 getProduct(), createProduct() 메서드에 대한 테스트 코드를 작성한다.

test/src/java/com.springboot.test 패키지 하위에 controller 패키지와, 그 내부에 ProductControllerTest 클래스를 작성한다.

 

 

 

(+) 테스트 작성 관련 유용한 방법

이때 손수 패키지를 만드는 방법도 있지만, 간단한 기능 혹은 단축키를 활용하는 방법도 있다.

(인텔리제이 기준, windows 기준으로 설명한다)

두 방법 모두 테스트하고자 하는 위치(예시에선 ProductController)에서 시작한다.

 

- 기능을 이용하는 방법

 

테스트하고자 하는 클래스 화면 아무 곳에서 [마우스 우클릭] -> [Generate...] -> [Test...] 를 순차적으로 누른다.

 

- 단축키를 이용하는 방법

 

역시 테스트하고자 하는 클래스 화면 아무 곳에서 [Ctrl + Shift + t] 버튼을 누른다.

 

두 방법 모두 다음과 같은 창을 띄우게 되며, 다음 사진과 같이 알아서 클래스 명과 경로 및 경로에 필요한 패키지까지 생성해준다.

아래에 체크박스를 선택하면 선택된 메서드 명의 빈 메서드와 @Test 어노테이션이 함께 선언되어 작성된다.

 

 

 

 

이제 다음과 같이 ProductControllerTest를 작성한다.

import에 유의하며 작성하자. (필자는 자동 import가 먹히지 않아 수기로 작성했다)

package com.springboot.test.controller;

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

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(ProductController.class)
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("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print());

        verify(productService).getProduct(123L);

    }
}

 

먼저 사용된 어노테이션에 관한 설명은 다음과 같다.

 

  • @WebMvcTest(테스트 대상 클래스.class)
    // 웹에서 사용되는 요청 및 응답에 대한 테스트를 수행한다. 대상 클래스를 정해주지 않으면 컨트롤러 관련 빈 객체가 모두 로드되며, 대상이 된 클래스에 의존성을 부여한다.
    @SpringBootTest보다 가볍게 테스트하기 위한 용도로 사용된다.

  • @MockBean
    // 실제 bean 객체가 아닌 가짜의 Mock 객체를 생성하여 주입된다. 가짜 객체이므로 실제 행위를 수행하지 않기에, 개발자가 직접 Mockito의 given() 메서드를 통해 동작을 정의하여야 한다.

  • @Test
    // 테스트 코드가 포함돼 있다고 선언한다. JUnit Jupiter는 해당 어노테이션을 감지하고 테스트 계획에 포함한다.

  • @DisplayName
    // 테스트 메서드의 가독성이 떨어질 우려를 위해 테스트에 대한 표현을 정의할 수 있다.

 

@MockMvcTest 어노테이션을 활용한 테스트는 일반적으로 슬라이스(Slice) 테스트라고 부르며,

이는 단위 테스트와 통합 테스트의 중간 개념으로 레이어별 테스트를 진행하는 의미이다.

단위 테스트의 경우 외부 요인을 차단하고 진행해야 하지만, 컨트롤러 레이어의 경우 외부 요인이 없다면 의미가 사라지기에 슬라이스 테스트로 진행하는 경우가 많다.

 

@MockMvcTest는 일반적으로 When-Then 구조를 활용하며, MockMvc 객체를 주입받아 진행한다.

이때 MockMvc 객체는 컨트롤러 API 테스트 용도의 객체이며, 서블릿 컨테이너의 구동 없이 가상의 MVC 환경에서의 모의 HTTP 서블릿을 요청하는 클래스이다.

 

given()은 GIVEN의 단계로, 어떤 객체에서 어떤 메서드가 어떤 파라미터를 받아 실행될지 가정하며, willReturn()을 통해 해당 결과가 어떤 객체를 return할 것인지 정의한다.

perform()은 THEN의 단계로, MockMvcRequestBuilders에서 제공하는 get/post/put/delete 및 multipart 와 파일 업로드 관련 메서드를 활용하여 URL을 정의할 수 있다. 이후 해당 결괏값을 중심으로 andExpect()를 통해 HttpStatus 및 변수의 유무 (예시에선 json의 경로를 통해) 확인과 andDo()를 통한 응답의 전체 확인이 가능하다.

verify()는 지정된 메서드가 실행되었는지 검증하며, 일반적으로 given()에 정의된 동작과 대응한다.

 

다음으로 createProduct() 테스트도 진행해보자.

 

먼저 createProduct()의 경우, 이전 GET 테스트와 다르게 POST 메서드이기 때문에 request에 body 값(ProductDto)을 담아주어야 하므로, 해당 부분을 도와주는 라이브러리를 추가하여야 한다.

pom.xml 파일에 Gson 의존성을 다음과 같이 추가한다.

 

 

Gson은 특정 객체를 Json 형태의 문자열로 바꾸어주는 편리한 기능을 가진 객체이다.

 

이후 앞서 작성한 ProductControllerTest 클래스의 getProductTest() 하위에 다음과 같이 작성한다. 

 

@Test
@DisplayName("Product 데이터 생성 테스트")
void createProductTest() throws Exception {

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

 

 

 

(+) java.lang.AssertionError: No value at JSON path "$.필드명" 에러

perform 메서드로 테스트를 수행하는 과정에서 해당 에러가 발생했다.

 

콘솔을 살펴보니 response 객체에 담겨져야 할 body가 비어있다.

 

 

확인을 위해 perform 메서드에 붙어 있는 andExpect 부를 모두 주석처리하고 verify만 실행시켜 보았다.

 

 

그러자 콘솔창에 다음과 같이 출력되었다.

 

 

구글링해보니 인자가 서로 달라서 발생한 에러로, 실제 값이 아닌 참조값을 비교하여 같지 않아 발생한 에러라 한다.

무슨 뜻이냐면, ProductDto A = new { "z", 5000, 2000 }; ProductDto B = new { "z", 5000, 2000 } 이라 할 때,

올바른 테스트 (로직) 이라면 서로의 모든 필드 값이 같으므로 같은 결과라고 보여주어야 하지만,

A와 B의 주소값을 비교하여 같은지를 판별했기에 다르다는 에러를 발생한 것이다.

 

구글링한 여러 정보에서는 해당 문제에 대해, 필드 값만을 비교하는 여러 테스트 전용 메서드를 소개해주었지만

나는 이번에 테스트를 처음 접한 것이므로 어느 곳에 올바르게 해당 메서드를 적용해야 할지 엄두가 나질 않았다.

따라서 근본적인 문제 해결을 위해 생각했고,

객체의 비교를 주소 값이 아닌 각 필드의 값으로 처리해주는 어노테이션 @EqualsAndHashCode가 떠올랐다.

 

확인해보니 책의 예시와 다르게 나는 ProductDto에 @Data 어노테이션을 빼놓고 작성했었고,

(@Data는 @EqualsAndHashCode 외에 @ToString, @Getter, @Setter 등의 유용한 어노테이션을 모두 묶은 것)

다음과 같이 ProductDto 상단에 @Data 어노테이션을 작성하므로서 해결할 수 있었다.

 

 

 

7.4.7 서비스 객체의 테스트

같은 방법으로 서비스 단의 테스트를 진행한다.

동일하게 ProductService(Impl) 클래스 화면에서 [Ctrl + Shift + t] -> [Create New Test]으로 테스트 클래스를 만든다.

 

이후 다음과 같이 작성한다.

package com.springboot.test.service.impl;

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 org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Optional;

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

class ProductServiceImplTest {

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

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

    @Test
    void getProductTest() {
        Product givenProduct = Product.builder()
                .number(123L)
                .name("펜")
                .price(1000)
                .stock(1234)
                .build();

        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 saveProductTest() {
        Mockito.when(productRepository.save(any(Product.class)))
                .then(returnsFirstArg());

        ProductResponseDto productResponseDto = productService.saveProduct(
                ProductDto.builder()
                        .name("펜")
                        .price(1000)
                        .stock(1234)
                        .build()
        );

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

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

이번 테스트에선 외부 요인이 모두 배제되어야 할 단위 테스트의 특성을 살리기 위해 @SpringBootTest/WebMvcTest 등의 어노테이션이 선언돼 있지 않다.

 

이후 @BeforeEach를 통해 사용할 ProductService를 주입받았다. 이전 controller에서의 테스트에선 @MockBean으로 주입 받았었다. 하지만 이번 테스트는 @WebMvcTest로 선언되지 않았기 때문에 @MockBean을 사용할 수 없다.

(만일 해당 부분을 적지 않는다면 productService가 null이라는 에러와 함께 테스트가 진행되지 않는다)

같은 이유로 productRepository도 mock bean을 주입 받는 다른 방식으로 주입 받았다.

 

이전의 perform().andExpect() 방식과 다르게 Assertions.assertEquals()를 활용하였다. 기능은 동일하다.

 

또한 given 대신 when으로 설정하였다. given()은 mockito.BDDMockito 객체에서 활용하는 방법이고, when()은 mockito.Mockito 객체에서 활용하는 메서드이다. Mockito에서는 given 부에서 when() 메서드를 활용하여 명칭과 메서드명이 달라 혼동을 줄 수 있었기 때문에, BDDMockito 방식으로 진화하며 동일한 부분에서 동일한 명칭의 메서드를 쓰도록 바뀌었다고 한다.

 

또한 saveProductTest() 에서의 any()는 특정 정확한 매개변수의 전달이 아닌, 메서드의 실행 혹은 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에서 사용된다. 이전의 argument 에러에서 확인했듯이, 일반적으로 given()의 동작 감지는 매개변수의 비교이지만 레페런스의 경우 주솟값으로 이루어지기 때문에 해당 문제를 방어하고자 any()를 통해 클래스만 정의하는 경우도 있다.

returnFirstArgs()의 경우 파라미터 중 첫 번째의 객체만을 return한다는 의미로, 만약 save 메서드로 여러 객체를 받았을 땐 그 중 첫 번째의 객체만 return 값으로 선정된다. 

 

테스트 클래스 위에 @ExtendWith(SpringExtension.class) 어노테이션을 선언한다면 스프링에서 제공하는 테스트 어노테이션을 통해 Mock 객체 생성 및 의존성 주입을 받을 수 있다. 이를 사용하면 스프링의 기능에 의존할 수 있게 되며, 큰 차이는 없지만 스프링 빈에 직접 등록하여 테스트를 진행하는 것은 Mockito.mock() 보다 조금 더 느릴 수 있다.

이러한 방식을 사용하게 될 시, 주입받는 ProductService의 초기화를 위해 클래스의 상단에 @Import({ProductServiceImpl.class})를 추가로 선언해주어야 한다.

 

 

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

리포지토리는 데이터베이스와 가깝고, JPA등으로 저장하기 때문에 테스트의 목적에 대해 고민해야 한다.

 

첫 번째로 JPA가 기본적으로 제공하는 findById(), save() 등의 메서드를 테스트할 필요는 없다. 기본 제공 메서드들은 이미 검증을 마치고 제공된 것이기 때문이다.

두 번째로 데이터베이스 연동에 대한 여부는 테스트해볼 만하지만, 데이터베이스는 외부 요인으로 취급할 수도 있다.

세 번째로는 테스트용 DB를 두는 것이다. 서비스용 데이터베이스에 연동하여 진행한다면 테스트 데이터가 적재되고, 그렇다면 이를 제거할 로직까지 짜야 하는데 이 과정에서 사이드 이펙트가 발생할 수 있으므로 테스트용 DB를 따로 두는 것을 고려해볼 만하다.

 

진행되는 레포지토리 테스트는 테스트용 DB로 H2를 선정하여 진행되며, JPA에서 제공하는 기본 메서드로 진행된다.

먼저 H2의 의존성 설정을 위해 pom.xml에 다음과 같이 작성한다.

 

 

이후 동일하게 테스트 클래스를 만들고 다음과 같이 작성하자.

이때 테스트 클래스 이름을 ProductRepositoryTestByH2로 만듦에 주의한다.

package com.springboot.test.data.repository;

import com.springboot.test.data.entity.Product;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
public class ProductRepositoryTestByH2 {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void saveTest() {

        // given
        Product product = Product.builder()
                .name("펜")
                .price(1000)
                .stock(1000)
                .build();

        // 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 = Product.builder()
                .name("펜")
                .price(1000)
                .stock(1000)
                .build();

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

 

해당 테스트는 기본적으로 저장할 객체와 저장한 객체의 속성이 같은지, 그리고 저장한 객체와 동일한 id로 찾은 객체의 속성이 같은지를 테스트하는 것으로 진행된다.

 

@DataJpaTest 어노테이션은 JPA 관련 설정만 로드하여 테스트를 진행하며, @Transactional을 포함하고 있어 테스트 이후 자동으로 데이터베이스를 롤백하고, 기본값으로 임베디드 데이터베이스를 사용한다. 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐야 하며, 현재 우리는 H2를 설정하였으므로 기본값이 H2로 설정된다.

 

save()와 saveAndFlush()는 기능은 거의 비슷하며, save()는 즉시 저장하는 것이 아닌 영속성 컨텍스트에 저장 후 flush() 혹은 commit() 수행 시 DB에 반영 실행, saveAndFlush()는 그 즉시 DB에 반영하는 차이를 보인다.

 

만약 H2에 저장하는 것이 아닌 설정해둔 DB에(본 프로젝트에선 MariaDB로 설정되어 있음) 저장하는 것으로 테스트 코드를 바꾸려면 다음과 같이 어노테이션을 설정하면 된다.

 

replace의 기본값 (임베디드 DB 사용) 은 Replace.ANY이며, 이를 Replace.NONE으로 바꾸게 되면 애플리케이션에서 실제로 사용하는 데이터베이스로 테스트를 가능하게 한다.

 

마지막으로 @SpringBootTest 어노테이션을 활용한 테스트 코드를 살펴보자.

동일 패키지에 ProductRepositoryTest 클래스를 만들고 다음과 같이 작성한다.

package com.springboot.test.data.repository;

import com.springboot.test.data.entity.Product;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

@SpringBootTest
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    public void basicCURDTest() {

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

 

해당 테스트는 기본적인 CRUD에 대해 테스트하며, 조금 다른 Assertions 구문으로 테스트를 진행해본다. (기능은 동일)

 

추가적으로 @SpringBootTest의 경우 스프링의 모든 설정 및 빈 객체 전체 스캔 후 진행하기 때문에 의존성과 설정 등에서 고민할 필요가 사라지지만, 속도가 느리기 때문에 다른 방안을 선택하는 것이 좋다.

 

 

 

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

코드 커버리지 (code coverage) 는 소프트웨어의 테스트 수준의 지표와 테스트 진행 시 대상 코드가 실행됐는지 등을 표현하는 방법으로 활용된다.

 

커버리지의 도구로는 보편적으로 JaCoCo (Java Code Coverage) 가 사용되며, JUnit 테스트로 얼마나 테스트가 되었는지 Line, Branch를 기준한 커버리지로 리포트한다. 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작하며, HTML, XML, CSV와 같은 다양한 형태로 레포트를 받을 수 있다. 이러한 JaCoCo를 현재 프로젝트에 적용해본다.

 

 

7.5.1 JaCoCo 플러그인 설정

pom.xml에 다음과 같이 의존성을 추가한다.

플러그인 의존성의 경우 코드의 길이가 길기 때문에 코드의 위치는 사진으로, 코드의 내용은 글로 첨부한다.

 

 

<plugin>
   <groupId>org.jacoco</groupId>
   <artifactId>jacoco-maven-plugin</artifactId>
   <version>0.8.7</version>
   <configuration>
      <excludes>
         <exclude>**/ProductServiceImpl.class</exclude>
      </excludes>
   </configuration>
   <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>
                        <minimum>50</minimum>
                     </limit>
                  </limits>
               </rule>
            </rules>
         </configuration>
      </execution>
   </executions>
</plugin>

 

플러그 인의 설정에 대해 잠시 소개한다.

 

먼저 <configuration>을 통해 ProductServiceImpl.class를 커버리지 측정 대상에서 제외할 수 있다.

 

<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-integration //
    report와 유사하며, 통합 테스트에 적합한 기본값을 제공한다.

  • report-aggregate //
    Reactor 내의 프로젝트에서 구조화된 보고서를 생성한다. 보고서는 해당 프로젝트가 의존하는 모듈에서 생성된다.

  • check //
    코드 커버리지의 메트릭 (테스트 커버리지 측정의 필요 지표) 충족 여부를 검사한다. 
    메트릭은 check 설정된 <execution> 내의 <rule>을 통해 설정할 수 있다.

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

  • instrument //
    오프라인 측정을 수행한다.
    테스트 실행 후 restort-instrumented-classes Goal로 원본 클래스 파일들을 저장해야 한다.

  • restore-instrumented-class //
    오프라인 측정 전 원본 파일을 저장한다.

 

 

<configuration>의 <rule> 속성값은 다음과 같다.

 

  • Element // 코드 커버리지 체크의 필요 범위 기준을 설정한다. 사용 가능 속성은 다음과 같다.

    - BUNDLE (기본값) : 패키지 번들 (프로젝트 내 모든 파일) - <limits> 내의 <counter>, <value>로 결정
    - PACKAGE : 패키지
    - CLASS : 클래스
    - GROUP : 논리적 번들 그룹
    - SOURCEFILE : 소스 파일
    - METHOD : 메서드

  • Counter // 커버리지 측정의 지표 (방식) 를 설정한다. 사용 가능 속성은 다음과 같다.

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

  • Value // 커버리지 지표 (결과를 보여주는 방식) 를 설정한다. 사용 가능 속성은 다음과 같다.

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

 

이를 토대로 실제 적용한 의존성을 해석해본다면,

[BUNDLE - INSTRUCTION - COVEREDRATIO - 0.80]을 통해 패키지 번들 단위로 바이트 코드 명령 수 기준 커버리지가 80%를 달성하도록 설정하였으며, 

[METHOD - LINE - TOTALCOUNT - 50]을 통해 메서드 단위로 전체 라인 수를 50으로 설정했다.

만일 설정 범위에서 벗어나면 에러를 내게 된다.

 

 

7.5.2 JaCoCo 테스트 커버리지 확인

이제 커버리지를 측정하고 결과를 확인해본다.

인텔리제이 기준, 우측 상단 [Maven] -> [test] -> [LifeCycle] -> [clean] -> [package]를 순서대로 누른다.

 

 

그럼 [target] 폴더에 그림과 같이 [site] 폴더가 생기며, 내부에 jacoco 관련 디렉토리와 파일들이 보인다.

 

만약 [site] 폴더가 생기지 않을 경우, 경로에 띄어쓰기 혹은 한글이 포함돼 있는지 확인하고 수정해야 하며,

그럼에도 문제가 있을 경우 인텔리제이를 종료 후 재시작해보자.

(필자도 해당 증상을 겪어 dependency 추가 등 여러가지를 해보았지만 껐다 켜보니 바로 되었다..)

 

기본적으로 Jacoco의 리포트 파일은 HTML, CSV, XML 형식으로 제공되며,

바로 확인해보고 싶다면 index.html 파일을 브라우저로 열어보면 된다.

다음과 같이 index.html 파일을 브라우저로 열 수 있다.

 

index.html 파일은 마우스 우클릭하여 도구 상자를 열어야 한다. [Browser]는 기호에 맞게 원하는 것으로 선택하면 된다.

 

 

이렇게 리포트를 [Open In] 하면 다음과 같은 화면을 볼 수 있다. 이제 리포트의 각 부분이 어떤 것을 의미하는지 알아본다.

 

  • Element : 우측 테스트 커버리지를 측정한 단위. 링크 클릭 시 세부 사항 확인 가능.
  • Missed Instructions - Cov.(Coverage) : 바이트코드의 커버리지를 퍼센테이지 및 바(bar) 형식으로 제공.
  • Missed Branches - Cov.(Coverage) : 분기에 대한 커버리지를 퍼센테이지 및 바(bar) 형식으로 제공.
  • Missed - Cxty(Complexity) : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수 제공.
  • Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수 제공.
  • Missed - Method : 테스트 대상 메서드 수와 커버되지 않은 메서드 수 제공.
  • Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 클래스 수 제공.

 

해당 리포트에서 패키지를 나타내는 [Element] 부의 링크를 계속 타고 들어가면 다음과 같이 해당 코드부가 보이게 된다.

 

 

초록색의 경우 테스트에서 실행 됐다는 의미이며, 빨간색의 경우 테스트 코드에서 실행되지 않은 부분을 의미한다.

해당 그림에선 나타나지 않았지만 조건문의 경우 노란색이 칠해지는 경우도 있는데,

이는 true/false의 경우 중 하나만 테스트 된 부분에 대해서는 노란색으로 칠해지게 된다.

 

 

 

7.6 테스트 주도 개발(TDD)

TDD란 Test Driven Development의 준말로, 테스트 주도 개발이란 뜻을 가진다.

애자일 방법론 중 하나인 익스트림 프로그래밍(eXtream programming)의 Test-First를 기반하여,

테스트 코드를 먼저 작성하고 성공하면 그때 해당 코드를 작성하는 과정을 반복한다.

이번 장에서는 테스트 주도 개발에 대해 간략히 알아본다.

 

 

7.6.1 테스트 주도 개발의 개발 주기

테스트 주도 개발은 아래의 세 단계를 원형으로 순환하며 진행한다.

테스트 코드를 먼저 작성하고 애플리케이션 코드를 작성한다는 순서에서 특징이 있다.

 

  • Write a failing test - 실패 테스트 작성 // 실패하는 경우의 테스트 코드를 먼저 작성.
  • Make a test pass - 테스트 통과 코드 작성 // 테스트 코드를 성공하기 위한 실제 코드 작성.
  • Refactoring - 리팩토링 // 중복 코드 제거와 일반화 하는 리팩토링 수행.

 

 

7.6.2 테스트 주도 개발의 효과

테스트 주도 개발에서는 다음과 같은 이점을 얻을 수 있다.

 

디버깅 시간 단축

문제 발생 위치를 확인하기 쉬워짐.

 

생산성 향상

테스트 코드를 통해 애플리케이션 코드의 불안정성에 대한 피드백을 지속적으로 받을 수 있기 때문에 리팩토링 횟수가 줄고 따라서 생산성이 향상됨.

 

재설계 시간 단축

재설계가 필요할 경우 테스트 코드를 조정하는 것으로 재설계 시간을 단축할 수 있음.

 

기능 추가와 같은 추가 구현이 용이

테스트 코드로 의도한 기능의 설계 후 실제 코드를 작성하기 때문에 목적에 맞는 코드 작성에 비교적 용이.

 

이처럼 주도 개발은 다양한 장점을 갖지만, 기존 개발 방식으로 프로세스를 진행해온 조직 입장에선 바꾸기 쉽지 않은 것이 실상이다. 섣부른 프로세스의 변화로, 적응기에 생산성의 저하가 우려될 수 있기 때문이다. 

 

 

 

7.7 정리

이번 장에서는 테스트 개념, 이론, 작성 방법과 중요성에 대해 알아보았다.

최근 여러 조직에서 테스트의 중요성을 인정하는 추세이므로, 다양한 시나리오의 테스트 코드 작성에 익숙해지는 것이 중요하다.

또한 JaCoCo는 SonarQube 솔루선과 함께 현업의 많은 회사에서 채택하여 사용하므로, 연동하여 익숙해지는 것이 필요하겠다.