상세 컨텐츠

본문 제목

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

24-25/Spring 1

by oze 2024. 12. 27. 10:00

본문

728x90

 

테스트 코드

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

 

📌 테스트 주도 개발(TDD)

: 테스트 코드를 더 잘 작성하고 활용할 수 있는지 고민한 결과로 등장한 애자일 방법론 중 하나

→ 테스트 코드를 작성하는 것과는 엄연히 다르지만 개발 관점을 다르게 볼 수 있는 기회가 될 수 있음

 

작성 이유

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

 

테스트 방법 분류 - 테스트 대상 범위 기준

📌 V모델에서의 확인 단계

: 인수 테스트 > 시스템 테스트 > 통합 테스트 > 단위 테스트

 

단위 테스트(Unit Test)

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

 

특징

  • 테스트 대상의 범위 기준으로 가장 작은 단위의 테스트 방식
  • 외부 요인은 제외하고 특정 모듈에 대한 테스트만 진행
    • 메서드 단위로 테스트를 수행 → 메서드 호출을 통해 의도한 결괏값이 나오는지 확인
  • 테스트 비용이 적게 들기 때문에 테스트 피드백을 빠르게 받을 수 있음

 

통합 테스트(Integration Test)

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

 

특징

  • 모듈을 통합하는 과정에서의 호환성 등을 포함해 애플리케이션이 정상적으로 동작하는지 확인
  • 외부 요인(DB, 네트워크 등)을 포함하고 진행하므로 애플리케이션이 온전히 동작하는지 테스트함
  • 테스트 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점 존재

 

테스트 코드 작성 방법

Given-When-Then 패턴

: 테스트 코드 표현 방식 중 하나로 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성함

 

특징

  • 테스트 주도 개발에서 파생된 BDD를 통해 탄생한 테스트 접근 방식임
  • 단위 테스트보다 인수 테스트에서 사용하는 것이 적합 → 간단한 단위 테스트에 적용시 불필요한 코드가 길어짐 but 명세 문서의 역할을 수행한다는 측면에서는 많은 도움이 됨

 

단계

  • Given : 테스트 수행 전 테스트에 필요한 환경을 설정하는 단계
    • 필요한 변수 정의, Mock객체를 통해 특정 상황에 대한 행동 정의 등
  • When : 테스트의 목적을 보여주는 단계
    • 실제 테스트 코드가 포함되며, 테스트를 통한 결괏값을 가져오게 됨
  • Then : 테스트의 결과를 검증하는 단계
    • When 단계의 결괏값과 테스트를 통해 나온 결과에서 검증해야 하는 부분 검증

 

F.I.R.S.T 전략

: 테스트 코드를 작성하는 데 도움이 될 수 있는 5가지 규칙 의미

 

특징

  • 대체로 단위 테스트에 적용할 수 있는 규칙임

규칙

  • Fast(빠르게) : 빠르게 수행돼야 함
    • 테스트가 느리면 코드 개선 작업이 느려져 코드 품질이 떨어질 수 있음
    • 빠른 테스트 = 목적을 단순하게 설정, 외부 환경을 사용하지 않는 단위 테스트를 작성 등
  • Isolated(고립된,독립적) : 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행돼야 함
    • 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 테스트가 수행되지 않을 수 있음
  • Repeatable(반복 가능한) : 어떤 환경에서도 반복 가능하도록 작성해야 함
    • Isolated규칙과 비슷한 의미로 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행돼야 함
  • Self-Validating(자가 검증)
    • 테스트의 성공 여부를 확인할 수 있는 코드를 함께 작성해야 함
    • 결괏값이나 기댓값을 비교하는 작업을 코드가 아닌 개발자가 직접 확인한다면 좋지 못한 테스트 코드임
  • Timely(적시에) : 테스트하려는 애플리케이션 코드를 구현하기 전에 완성돼야 함
    • 너무 늦게 작성된 테스트 코드는 정상적인 역할 수행이 어려움
    • 테스트로 발견된 문제를 해결하기 위해 소모되는 개발 비용이 커지기 쉬움
    • 테스트 주도 개발의 원칙을 따르는 테스트 작성 규칙으로 해당 개발을 기반으로 하는 것이 아니면 제외 가능

 

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

📌 JUnit : 자바 언어에서 사용되는 대표적인 테스트 프레임워크

→ 단위 테스트를 위한 도구와 통합 테스트를 할 수 있는 기능 제공

 

특징

  • 어노테이션 기반의 테스트 방식 지원 ⇒ 몇 개의 어노테이션만으로 간편하게 테스트 코드 작성 가능
  • 단정문을 통해 테스트 케이스의 기댓값이 정상적으로 도출됐는지 검토 가능

 

세부 모듈

JUnit 5 기준

  • JUnit Platform : JVM에서 테스트를 시작하기 위한 뼈대 역할로 테스트 엔진의 인터페이스를 가지고 있음   
    • Test Engine 역할 :  테스트 발견 및 수행, 결과 보고, 각종 IDE와의 연동 보조
    • TestEngine API, Console Launcher, JUnit 4 Based Runner 등 포함
  • JUnit Jupiter : 테스트 엔진 API의 구현체 포함, Jupiter 기반의 테스트 실행을 위한 테스트 엔진 가지고 있음
    • Jupiter Engine : 테스트의 실제 구현체가 수행하는 별도 모듈의 역할 중 하나
    • Jupiter Engine  역할 : Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행
  • JUnit Vintage : JUnit 3,4에 대한 테스트 엔진 API 구현
    • 기존 에 작성된 JUnit 3,4 버전의 테스트 코드 실행에 사용됨
    • Vintage Engine을 포함함

⇒ JUnit : 하나의 Platform 모듈을 기반으로 Jupiter와 Vintage 모듈이 구현체의 역할을 수행함

 

프로젝트 생성

📌 6장에서 만들었던 코드 가져옴

DAO 레이어 제외하고 서비스 레이어에서 바로 리포지토리를 사용하는 구조로 수정해서 진행

 

*수정 코드

// service/impl/ProductServiceImpl.java

@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("[saveProduct] productDTO : {}", productDto.toString());
        Product product = new Product();
        product.setName(productDto.getName());
        product.setPrice(productDto.getPrice());
        product.setStock(productDto.getStock());

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

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

        return productResponseDto;
    }

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

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

        return productResponseDto;
    }

    @Override
    public void deleteProduct(Long number) {
        productRepository.deleteById(number);
    }
}
// entity/Product.java

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

 

테스트 설정

spring-boot-starter-test

: 스프링 부트에서 테스트 환경을 쉽게 설정할 수 있도록 지원하는 프로젝트로 다양한 테스트 도구를 제공하고 자동 설정을 지원함

사용을 위해 pom.xml 파일에 의존성 추가

</dependencies>
  ... 생략 ...
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
  ... 생략 ...
</dependencies>

 

대표적인 라이브러리

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

 

JUnit의 생명주기

// com.springboot.test/TestLifeCycle.java

import org.junit.jupiter.api.*;

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 종료 ##

## AfterAll Annotation 종료 ##
*/

테스트 순서에 관여하게 되는 대표적인 어노테이션

  • @Test : 테스트 코드를 포함한 메서드 정의
  • @BeforeAll : 테스트 시작 전 호출되는 메서드 정의
  • @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드 정의 
  • @AfterAll : 테스트를 종료하면서 호출되는 메서드 정의
  • @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드 정의
  • @Disabled : 지정된 테스트는 실행되지 않음

 

컨트롤러 객체의 테스트 

📌 컨트롤러 : 클라이언트로부터 요청을 받아 그에 걸맞은 서비스 컴포넌트로 요청을 전달하고 결괏값을 가공해 응답하는 역할

→ 여러 레이어 중 가장 웹에 가까이 있는 모듈

 

@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);
    }
    
    @PostMapping()
    public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
        ProductResponseDto productResponseDto = productService.saveProduct(productDto);

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

ProductController : ProductService의 객체를 의존성 주입받음. 즉, ProductService는 외부 요인에 해당함

⇒ 독립적인 테스트 코드 작성을 위해선 Mock 객체를 활용해야 함 

 

 

ProductController의 getProduct()와 createProduct() 메서드 테스트 코드

→ test/com.spring.test/controller/ProductControllerTest.java 파일 생성

// test/com.spring.test/controller/ProductControllerTest.java

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;

@WebMvcTest(ProductController.class)
public class ProductControllerTest  {

    @Autowired
    // MockMvc : 컨트롤러의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
    private MockMvc mockMvc;

    @MockBean
    ProductServiceImpl productService;

    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProductTest() throws Exception {
        // Given 부분
        // given() : 객체에 호출되는 메서드와 주입되는 파라미터을 가정함, willReturn() : 리턴할 결과 정의
        given(productService.getProduct(123L)).willReturn(
                new ProductResponseDto(123L, "pen", 5000, 2000)
        );

        String productId = "123";

        // When-Then 부분
        /*
            perform() : 서버로 URL 요청을 보낸 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있음
            => MockMcvRequestBuilders에서 제공하는 GET, POST, PUT, DELETE로 URL을 정의해 사용
        */
        mockMvc.perform(
                get("/product?number=" + productId)).andExpect(status().isOk())
                        // andExpect() : perform() 메서드의 결괏값으로 리턴되는 ResultActions 객체를 검증함
                        .andExpect(jsonPath("$.number").exists())
                        .andExpect(jsonPath("$.price").exists())
                        .andExpect(jsonPath("$.stock").exists())
                        // andDO() : 요청과 응답의 전체 내용 확인
                        .andDo(print());

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

    @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() : 리소스 생성 기능을 테스트하기 위해 URL 구성, content() : DTO의 값을 담아 테스트 진행
                        post("/product").content(content).contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                // jsonPath().exists() : POST 요청을 통해 도출된 결괏값에 대한 각 항목이 존재하는지 검증 -> 대응값이 없으면 오류 발생
                .andExpect(jsonPath("$.number").exists())
                .andExpect(jsonPath("$.price").exists())
                .andExpect(jsonPath("$.stock").exists())
                .andDo(print());

        verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
    }
}

📌 슬라이스 테스트 : @WebMvcTest 어노테이션을 사용한 테스트

특징 - 단위 테스트와 통합 테스트의 중간 개념으로 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트 진행함

→ 웹과 맞닿은 레이어인 컨트롤러는 외부 요인을 차단하면 테스트의 의미가 없기에 단위 테스트보다 슬라이스 테스트를 진행함

 

사용된 어노테이션

  • @WebMvcTest(테스트대상클래스.class) : 웹에서 사용되는 요청과 응답에 대한 테스트 수행
    대상 클래스만 로드함. 대상 클래스를 추가하지 않으면 빈 객체가 모두 로드됨
  • @MockBean : 실제 빈 객체가 아닌 Mock 객체를 생성해서 주입하는 역할 수행
    실제 행위는 수행하지 않기에 개발자가 Mockito의 given() 메서드로 동작 정의해야 함
  • @Test : 테스트 코드가 포함되어 있다고 선언함 -> JUnit Jupiter에서 이 어노테이션을 감지해 테스트 계획에 포함시킴
  • @DisplayName : 테스트 이름이 복잡해 가독성이 떨어지는 경우 테스트에 대한 표현 정의 가능

 

📌 Gson : 구글에서 개발한 JSON 파싱 라이브러리로 자바 객체와 JSON 문자열 간 변환을 하는 역할

-> 사용하기 위해 pom.xml 파일에 의존성 추가해야함

<dependencies>
	... 생략 ...
	<dependency>
		<groupId>com.google.code.gson</groupId>
		<artifactId>gson</artifactId>
	</dependency>
	... 생략 ...
<dependencies>

 

 

서비스 객체의 테스트

getProduct() 메서드와 saveProduct() 메서드 테스트 코드

// test/come.spring.test/impl/ProductServiceTest.java

public class ProductServiceTest {

    // mock() : Mock객체로 ProductRepository 주입받음
    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private  ProductServiceImpl productService;

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

    @Test
    void getProductTest(){
        // Given 부분
        // 엔티티 객체 생성 및 결괏값 리턴 설정
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

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

        // ProductService의 getProduct() 메서드 호출해 동작 테스트
        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // Assertion으로 리턴받은 ProductResponseDto 객체 검증
        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(){
        /* any() : Mock 객체의 동작을 정의하거나 검증하는 단계에서 메서드의 실행만 확인 or 클래스 객체를 매개변수로 전달받는 등의 상황에 사용
           -> given()으로 정의된 Mock객체의 메서드 동작 감지는 매개변수 비교 or 레퍼런스 변수의 비교는 주솟값으로 이루어져 
           any()로 클래스만 정의하는 경우 존재
         */
        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());
    }
}

 

+ Mock객체를 직접 생성하지 않고 @MockBean 어노테이션을 사용해 Mock 객체를 주입받는 방식 

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

    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;
    
    .. 생략 ..
}
  Mock 객체 활용 방식 @MockBean 어노테이션 사용 방식
리포지터리 초기화 작업 Mockito.mock() 사용
- 스프링 빈에 등록하지 않고 직접 객체를 초기화함
@MockBean 사용
- 스프링에 Mock 객체를 등록해 주입받음
차이점 - 스프링 기능에 의존하는지 여부
- 테스트 속도에 큰 차이는 없지만 Mock 객체가 더 빠르게 동작함

 

리포지토리 객체의 테스트

📌 리포지토리 : 레이어 중 가장 DB에 가까움, JpaRepository를 상속받아 기본적인 쿼리 메서드 사용 가능 

-> 구현하는 목적에 대해 고민해야함

 

고려 사항

  • 기본 메서드는 테스트 검증을 마치고 제공된 것 -> 테스트에 의미 없음
  • DB 연동 여부 - DB를 사용한 테스트는 테스트 결과가 적재되기에 테스트 데이터를 제거하는 코드까지 포함해서 작성
    => H2 DB : DB를 제외한 테스트 상황을 가정할 때 테스트 DB로 사용함. 사용을 위해 pom.xml에 의존성 추가 필요
... 생략 ...
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
        <scope>test</scope>
	</dependency>
... 생략 ...

 

DB에 값을 저장하고 조회하는 테스트 코드 

// tet/com.springboot.test/data/repository/ProductRepositoryTestH2.java

@DataJpaTest
public class ProductRepositoryTestH2 {

    @Autowired
    private ProductRepository productRepository;

    // 저장
    @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 - 리턴 객체와 Given에서 생성한 엔티티 객체의 값이 일치하는지 검즌
        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);

        // DB에 저장하는 작업 수행
        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 어노테이션을 포함 -> 테스트 코드 종료 시 자동으로 DB의 롤백 진행

- 임베디드 DB 사용 -> 다른 DB 사용하려면 별도의 설정 필요

 

 

임베디드 메모리 DB가 아닌 실제 사용하는 DB에서 테스트 진행

// test/com.springboot.test/data/repository/ProductRepositoryTest.java


@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.getNumber(), savedProduct.getNumber());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());

    }
}

📌 @AutoConfigureTestDatabase의 replace 요소 : @AutoConfigureTestDatabase의 값을 조정함

- 기본값 : Replace.ANY -> 임베디드 메모리 DB를 사용

- 실제 사용하는 DB로 테스트하기 위해선 Replace.NONE으로 변경

 

 

@SpringBootTest 어노테이션으로 테스트 수행

// test/com.springboot.test/data/repository/ProductRepositoryTestH2.java

@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 selectProduct = productRepository.findById(savedProduct.getNumber()).orElseThrow(RuntimeException::new);

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


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

        foundProduct.setName("장난감");

        Product updatedProduct = productRepository.save(foundProduct);

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


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

        // then
        assertFalse(productRepository.findById(selectProduct.getNumber()).isPresent());
    }
}

@SpringBootTest 특징

- 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캠함 -> 의존성 주입에 대한 고민할 필요 없이 테스트 가능

- 테스트 속도가 느림

 

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

📌 코드 커버리지 : 테스트 수준이 충분하지 표현하는 지표 중 하나로 대상 코드가 실행됐는지 표현하는 방법으로 사용

-> 가장 보편적인 도구 JaCoCo

 

JaCoCo : JUnity 테스트를 통해 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지 리포트함

-> 런타임으로 테스트 케이스 실행하고 커버리지를 체크하는 방식, 리포트 - HTML, XML, CSV 같은 다양한 형식으로 확인 가능

 

JaCoCo 플러그인 설정

pom.xml 파일에서 의존성 추가 및 플러그인 추가

... 생략 ...
    <dependency>
		<groupId>org.jacoco</groupId>
		<artifactId>jacoco-maven-plugin</artifactId>
		<version>0.8.7</version>
	</dependency>
... 생략 ...

<build>
	... 생략 ...
		<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>
					<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>
							</rule>
							<rule>
								<element>METHOD</element>
								<limits>
									<limit>
										<counter>LINE</counter>
										<value>TOTALCOUNT</value>
										<maximum>50</maximum>
									</limit>
								</limits>
							</rule>
						</rules>
					</configuration>
				</execution>
			</executions>
		</plugin>
	 </plugins>
   </build>
</project>
  • <configuration> 태그 : 일부 클래스를 커버리지 측정 대상에서 제외함
  • <executions> 태그 : <goal>을 포함, 추가 설정이 필요하면 < configuration>과 <rule>로 작성함 
    • Element : 코드 커버리지 체크하는데 필요한 범위 기준 설정 - 6가지 범위 존재
    • Counter : 커버리지를 측정하는데 사용하는 지표 - 측정 단위 6가지 존재
    • Value : 커버리지 지표 설정 - 5가지 방식 중 보여줄 방식 선택

 

JaCoCo 테스트 커버리지 확인

: 플러그인으로 테스트 커버리지 측정하려면 메이븐의 테스트 단계가 선행되어야 함 - 생명주기는 Mayen탭에서 확인 가능

-> package 더블클릭해 빌드하면 target 폴더에 site>jacoco 폴더 생성됨 

* 정상적으로 작동하지 않으면 경로에 한글이 없는지 확인

메이븐 생명주기

 

HTML 리포트 파일

 

각 칼럼

  • Element : 우측 테스트 커버리지를 측저안 단위 표현 
  • Missed Instructions - Cov. : 테스트 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식으로 제공
  • Missed Branches - Cov. : 분기에 대한 테스트 커버리지를 퍼센티지와 바 형식으로 제공
  • Missed - Cxty : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수 제공
  • Missed - 단위(= Lines, Methods, Classes) : 테스트 대상 단위 수와 커버되지 않는 단위 수 제공

세부 내용 확인

1. controller 패키지를 누르면 ProductController 클래스가 Element에 표시됨

2. 해당 부분 다시 클릭 시 Method가 Elemet의 기준이 됨

3. 메서드 클릭 시 코드 레벨에서 테스트 커버리지 확인 가능

 

=> 초록색 : 테스트 실행 / 빨간색 : 실행되지 않은 라인 / 노랑색 : 조건문 중 하나만 테스트된 경우

 

테스트 주도 개발

테스트 주도 개발(TDD) : 테스트 코드 먼저 작성 후 테스트를 통과하는 코드를 작성하는 과정 반복하는 반복 테스트 개발 방법

-> 특징 - 애자일 방법론 중 하나인 익스트림 프로그랭의 Test-First 개념에 기반 => 개발 주기 짧은 단순 설계를 중시함

 

📌 애자일 소프트웨어 방법론

: 신속한 반복 작업을 통해 실제 작동 간으한 소프트웨어를 개발하는 개발 형식 

 

개발 주기

: 3단계로 표현함

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

일반적으로 설계 후 그에 맞게 코드 작성하고 테스트 코드 작성하는 흐름 

but 테스트 주도 개발은 설계 이후 바로 테스트 코드 작성하고 애플리케이션 코드를 작성함

 

개발 효과

  • 디버깅 시간 단축 : 문제 발생 시 어디에서 잘못됐는지 확인이 쉬움
  • 생상성 향상 : 지속적으로 코드의 불안정성에 대해 피드백 받음 -> 리팩토링 횟수 줄고 생산성 높아짐
  • 재설계 시간 단축 : 재설계 필요한 경우 테스트 코드를 조정하는 것으로 시간 단축 가능
  • 기능 추가와 같은 추가 구현이 용이 : 의도한 기능 미리 설계 후 코드 작성 -> 목적에 맞는 코드 작성에 용이함

QUIZ

1. ( 테스트 코드 )는 작성한 코드나 비즈니스 로직 자체를 테스트하기 위해 작성한 코드이다.
2. 단위 테스트(Unit Test)는 애플리케이션의 개별 모듈을 ( 독립적 )으로 테스트하는 방식이다.
3. ( Given-When-Then 패턴 )은 테스트 코드 표현 방식 중 하나로 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성한다.
4. ( JUnit )이란, 자바 언어에서 사용되는 대표적인 테스트 프레임워크로 단위 테스트를 위한 도구와 통합 테스트를 할 수 있는 기능 제공한다.
5. ( 슬라이스 테스트 )란 @WebMvcTest 어노테이션을 사용한 테스트다.
6. Mock 객체 활용 방법과 @MockBean 어노테이션 활용 방법의 차이점은 ( 스프링 기능에 의존하는지 ) 여부이다. 
7. ( 테스트 주도 개발 )란 테스트 코드 먼저 작성 후 테스트를 통과하는 코드를 작성하는 과정 반복하는 반복 테스트 개발 방법이다.

 

 

PROGRAMMING QUIZ

1. Mock객체를 직접 생성하지 않고 @MockBean 어노테이션을 사용해 Mock 객체를 주입받는 방식이다. 빈칸에 들어가야할 어노테이션을 작성하시오.

// 빈칸 
// 빈칸  
public class ProductServiceTest2 {

    @MockBean
    ProductRepository productRepository;

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

 

정답 : 

더보기

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

    @MockBean
    ProductRepository productRepository;

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

 

 

2. 임베디드 메모리 데이터베이스가 아닌 실제로 사용하는 데이터베이스에서 테스트를 진행하기 위해서는 어떤 어노테이션을 추가해야하는지 빈칸에 알맞은 답을 작성하시오.

@DataJpaTest
// 빈칸
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.getNumber(), savedProduct.getNumber());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());

    }
}

 

정답 : 

더보기

@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.getNumber(), savedProduct.getNumber());
        assertEquals(product.getPrice(), savedProduct.getPrice());
        assertEquals(product.getStock(), savedProduct.getStock());

    }
}


 

[출처] 장정우, 『스프링 부트 핵심 가이드』, 위키북스(2022), p.159-207.

Corner Spring 1
Editor:  Luna

728x90

관련글 더보기