[Spring] 테스트 코드 작성하기

Miin·2023년 11월 9일
0

Spring

목록 보기
8/17
post-custom-banner

테스트 코드란?

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

테스트 코드 작성 이유

  • 개발 과정에서 문제 미리 발견
    • 일부러 오류가 발생할 수 있는 테스트 코드를 작성해 예외 처리가 잘 작동하는지 확인
    • 정확히 의도한 비즈니스 로직에 맞춰 테스트 코드를 작성해 결과가 잘 나오는지 검토
  • 리팩토링 리스크 감소
  • 하나의 명세 문서로서의 기능 수행
    • 테스트 코드를 통해 작성자의 의도 파악

테스트 방법은 여러 기준으로 분류 가능

📌 테스트 대상 범위를 기준으로 구분

  • 단위테스트(Unit Test)
  • 통합 테스트(Integration Test)

📌 단위 테스트

애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식
테스트 대상의 범위를 기준으로 가장 작은 단위의 테스트 방식

일반적으로 메서드 단위로 테스트 수행
테스트 비용이 적어 피드백 빠름

📌 통합 테스트

애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식
모듈을 통합하는 과정에서의 호환성 등을 포함해 애플리케이션이 정상적으로 동작하는지 확인하기 위해 수행하는 테스트 방식
데이터베이스나 네트워크 같은 외부 요인들을 포함하고 테스트 진행
테스트 비용이 큼

테스트 비용: 금전적인 비용 포함, 시간, 인력과 같은 개발에 필요한 것들 포괄
하나의 서비스를 개발할 때 개발 과정에서 60%, 테스트 과정에서 40% 비용이 듦


테스트 코드 작성 방법

📌 Given-When-Then 패턴

테스트 주도 개발에서 파생된 BDD(Behavior-Driven-Development: 행위 주도 개발)를 통해 탄생한 테스트 접근 방식.

Given

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

When

테스트의 목적을 보여주는 단계
실제 테스트 코드 포함
테스트를 통한 결과값을 가져옴

Then

테스트 결과를 검증하는 단계
When 단계에서 나온 결과 검증


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

  • Fast 빠르게
  • Isolated 고립된, 독립적
    • 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행
  • Repeatable 반복 가능한
  • Self-Validating 자가 검증
    • 테스트 그 자체만으로 테스트 검증이 완료돼야 함
  • Timely 적시에
    • 애플리케이션 코드 구현 전에 테스트 코드 완성 (테스트 주도 개발)

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

JUnit이란?

자바 언어에서 사용되는 대표적인 테스트 프레임워크, 테스트를 위한 도구 제공

어노테이션 기반의 테스트 방식을 지원하는 것이 가장 큰 특징

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 // 테스트 실행X
    void test3() {
        System.out.println("## test3 시작 ##");
        System.out.println();
    }
}

실행결과 콘솔

## BeforeAll Annotation 호출 ##

## BeforeEach Annotation 호출 ##

## test1 시작 ##

## AfterEach Annotation 호출 ##

## BeforeEach Annotation 호출 ##

## test2 시작 ##

## AfterEach Annotation 호출 ##


void com.springboot.jpa.TestLifeCycle.test3() is @Disabled
## AfterAll Annotation 호출 ##

컨트롤러 객체의 테스트

import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.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 org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@WebMvcTest(ProductController.class)
public class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ProductServiceImpl productService; // Mock 객체 주입

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

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

        String productId = "123";

		// mockMvc - api 테스트를 위한 객체
        // 서블릿 컨테이너의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스
        mockMvc.perform(
                MockMvcRequestBuilders.get("/product?number="+productId)
        )
        		// 결과값 검증
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.number").exists())
                .andExpect(MockMvcResultMatchers.jsonPath("$.name").exists())
                .andExpect(MockMvcResultMatchers.jsonPath("$.price").exists())
                .andExpect(MockMvcResultMatchers.jsonPath("$.stock").exists())
                .andDo(MockMvcResultHandlers.print()); // 요청과 응답의 전체 내용 확인

		// 지정된 메서드가 실행됐는지 검증
        verify(productService).getProduct(123L);
    }
    
}
  • @WebMvcTest(테스트 대상 클래스.class)
    웹에서 사용되는 요청과 응답에 대한 테스트 수행. 대상 클래스만 로드해 테스트 수행.
    대상 클래스 추가하지 않으면 컨트롤러 관련 빈 객체 모두 로드
    @SpringBootTest보다 가볍게 테스트하기 위해 사용 (슬라이스 테스트 - 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트 진행)

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

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

  • @DisplayName
    테스트에 대한 표현 정의


✔️ 슬라이스 테스트를 위해 사용할 수 있는 대표적인 어노테이션
@DataJdbcTest, @DataJpaTest, @DataMongoTest, @DataRedisTest, @JdbcTest, @JooqTest, @JsonTest, @RestClientTest, @WebFluxText, @WebMvcText, @WebServiceClientTest


서비스 객체의 테스트

단위 테스트를 위해서는 외부 요인을 모두 배제하도록 코드 작성

public class ProductServiceTest {

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

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

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

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

        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // 값 검증
        Assertions.assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
        Assertions.assertEquals(productResponseDto.getName(), givenProduct.getName());
        Assertions.assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
        Assertions.assertEquals(productResponseDto.getStock(), givenProduct.getStock());

        Mockito.verify(productRepository).findById(123L);
    }
    
    @Test
    void saveProductTest() {
        Mockito.when(productRepository.save(ArgumentMatchers.any(Product.class)))
                .then(AdditionalAnswers.returnsFirstArg());
        
        ProductResponseDto productResponseDto = productService.saveProduct(
                new ProductDto("펜", 1000, 1234)
        );
        
        Assertions.assertEquals(productResponseDto.getName(), "펜");
        Assertions.assertEquals(productResponseDto.getPrice(), 1000);
        Assertions.assertEquals(productResponseDto.getStock(), 1234);
        
        Mockito.verify(productRepository).save(ArgumentMatchers.any());
        // any() : Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고
        // 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용
    }

}

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

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

    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;
    
    ... 테스트 코드 동일 생략 ...
    
}

리포지토리 객체의 테스트

리포지토리는 개발자가 구현하는 레이어 중 가장 데이터베이스와 가까움

특히 구현하는 목적에 대해 고민하고 작성해야 함

리포지토리의 기본 메서드는 테스트 검증을 마치고 제공된 것이기에 findById(), save() 같은 기본 메서드에 대한 테스트는 큰 의미가 없음

데이터베이스는 외부 요인에 속함
데이터베이스를 연동한 테스트는 테스트 데이터를 제거하는 코드까지 포함해 작성하는 것이 좋음
데이터베이스 연동 없이 테스트하는 것이 더 좋을 수도

H2 DB를 사용한 테스트 코드

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 실제 사용하는 데이터베이스로 테스트 가능
public class ProductRepositoryTestByH2 {

    @Autowired
    private ProductRepository productRepository;

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

        // when
        Product 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);

        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 포함 -> 테스트 코드 종료시 자동으로 데이터베이스 롤백 진행
  • 기본값으로 임베디드 데이터베이스 사용. 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능

@SpringBootTest 어노테이션을 활용한 테스트

스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요 없이 테스트 가능 but 테스트 속도 느림


-- CRUD의 모든 기능을 한 테스트 코드에 작성
@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
        assertThat(savedProduct.getNumber())
                .isEqualTo(givenProduct.getNumber());
        assertThat(savedProduct.getName())
                .isEqualTo(givenProduct.getName());
        assertThat(savedProduct.getPrice())
                .isEqualTo(givenProduct.getPrice());
        assertThat(savedProduct.getStock())
                .isEqualTo(givenProduct.getStock());

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

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

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

        foundProduct.setName("장난감");

        Product updatedProduct = productRepository.save(foundProduct);

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

        /* delete */
        // when
        productRepository.delete(updatedProduct);
        
        // then
        assertFalse(productRepository.findById(selectedProduct.getNumber()).isPresent());
    }

}



📌 테스트 주도 개발(TDD)

TDD란?
Test-Driven Development의 약자. 테스트 주도 개발

반복 테스트를 이용한 소프트웨어 개발 방법론
테스트 코드를 먼저 작성 후 테스트를 통과하는 코드를 작성하는 과정을 반복

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

애자일 소프트웨어 개발 방법론이란?
신속한 반복 작업을 통해 실제 작동 가능한 소프트웨어를 개발하는 개발 방식
신속한 개발 프로세스를 통해 수시로 변하는 고객의 요구사항에 대응해서 제공하는 서비스 가치 극대화

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

  • 실패 테스트 작성(Write a failing test): 실패하는 경우의 테스트 코드 먼저 작성
  • 테스트를 통과하는 코드 작성(Make a test pass): 테스트 코드를 성공시키기 위한 실제 코드 작성
  • 리팩토링(Refactor): 중복 코드 제거 또는 일반화하는 리팩토링 수행

효과

  • 디버깅 시간 단축
  • 생산성 향상
  • 재설계 시간 단축
  • 기능 추가와 같은 추가 구현 용이


✒️ 항상 어떤 프로젝트를 개발하더라도 항상 급급하게 개발 작업에만 치중하느라, 테스트 코드를 일일이 작성하여 검증하는 일은 잘 없었던 것 같다.. 그러나 현업의 대부분은 테스트 코드 작성이며, 애플리케이션 개발에서 매우 중요한 부분이라고 하니 테스트 코드 작성 방법도 더 열심히 공부를 해야할 것 같다..!!


[출처] 스프링 부트 핵심 가이드 / 장정우, 위키북스

profile
컴퓨터공학전공 학부생 Back-end Developer
post-custom-banner

0개의 댓글