딸기말차

[Spring Boot] 3. 단위 테스트, TDD 본문

Bootcamp/Spring Boot

[Spring Boot] 3. 단위 테스트, TDD

딸기말차 2023. 9. 5. 08:49

엔코아 플레이데이터(Encore Playdata) Backend 2기 백엔드 개발 부트캠프 (playdata.io)

 

백엔드 개발 부트캠프

백엔드 기초부터 배포까지! 매력있는 백엔드 개발자 포트폴리오를 완성하여 취업하세요.

playdata.io


1.  단위 테스트 (Unit Test)

1. 단위 테스트 ?

기능을 개발 후 문제가 있는지 없는지 확인하려면 어플리케이션을 띄우고, 직접 요청하거나 Swagger 등 툴을 사용해서 테스트를 진행해야 한다. 이 때 문제는 이 과정이 생각보다 많은 시간을 잡아 먹는다는 것이다.

 

반면 단위 테스트는, 서버를 띄워서 요청을 직접 보내고 로그나 화면의 동작을 확인할 필요 없이 오직 테스트 코드만 작성해서 실행해보면 해당 기능이 정상적으로 동작하는지 확인 할 수 있다.

즉, 개발 및 테스트에 들어가는 비용이 확연히 줄어들어드는 장점을 가지고 있다고 볼 수 있다.

 

2. 단위 테스트를 작성해야하는 이유

1. 코드를 수정하거나 기능을 추가할 때 해당 부분을 빠르게 검증 해볼 수 있다.
2. 리팩토링 시 안정성을 확보할 수 있다.
3. 개발 및 테스트에 대한 시간과 비용을 절감할 수 있다.

3. 테스트의 특징

1. Fast
테스트는 빠르게 동작하여 자주 돌릴 수 있어야 한다.
2. Independent
각각의 테스트는 독립적이며 서로 의존해서는 안된다.
3. Repeatable
어느 환경에서도 반복 가능해야 한다.
4. Self-Validating
테스트는 성공 또는 실패로 bool 값으로 결과를 내어 검증되어야 한다.
5. Timely
테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.

2. TDD (Test Driven Development) 

1. 테스트 주도 개발 순서

1. 실패하는 단위 테스트를 작성한다. 컴파일이 안되도 상관없다.
2. 빨리 테스트를 통과하기 위해 간단한 코드를 작성한다. 단순히 값을 입력하고 출력하는 형식이어도 된다.
3. 그 다음의 테스트 코드를 작성한다. 실패 테스트가 없을 경우에 성공 테스트를 작성한다.
4. 새로운 테스트를 통과하기 위해 기존 테스트 코드를 추가 또는 수정한다.
5. 1~4단계를 반복하여 실패/성공의 모든 테스트 케이스를 작성한다.
6. 개발된 코드들에 대해 모든 중복을 제거하며 리팩토링한다.

2. Given - When - Then 패턴

테스트를 만들 때 가장 자주 사용하는 패턴으로, 테스트 메서드 내부 구조를 파악하기가 용이해진다.

1. Given (준비)
테스트 하기 위해 기본적으로 세팅하는 값들

2. When (실행)
테스트를 하기 위한 기능을 실행

3. Then (검증)
테스트 한 기능의 결과가 예상대로 동작하는 지 검증

 


3. Repository Test

1. 입력 된 데이터를 DB에 저장하고 조회한 결과와 비교하는 테스트

/**
 * 해당 어노테이션을 통해 DB 변경 가능
 * 기본 값은 Replace.ANY 이고, 이 경우 임베디드 메모리 DB (H2 DB) 를 사용한다.
 * Replace.NONE 을 사용할 시, 어플리케이션에서 실제로 사용하는 DB 로 테스트를 한다.
 */
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

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

        // when

        // 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);
        /**
         * 트랜잭션 종료 전에 먼저 DB 에 flush 를 날리기 위해 saveAndFlush() 사용
         */
        Product savedProduct = productRepository.saveAndFlush(product);

        // when
        /**
         * saveAndFlush() 를 통해 먼저 flush 를 날려 저장했기 때문에 조회가 가능하다.
         */
        Product foundProduct = productRepository.findById(savedProduct.getNumber()).get();

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

2. builder 를 이용한 입력, 기본적인 CRUD 테스트

@SpringBootTest
class ProductRepositoryTest2 {

    @Autowired
    private ProductRepository productRepository;

    /**
     * 1. Assertions.assertThat()
     * junit 에서 제공하는 assertEquals 에 비해 테스트 코드 가독성이 좋다.
     * junit 의 assertEquals 는 파라미터의 순서를 헷갈릴 수도 있기 때문이다.
     *
     * 2. findById().orElseThrow(RuntimeException::new);
     * Optional 객체를 반환 받을 시, 해당 객체가 null 일 때 예외처리를 하고 null 이 아니면 .get() 을 하여 내부 객체를 반환한다.
     *
     * 3. Assertions.assertFalse()
     * 인자로 특정 조건 및 boolean 값을 넘기고 return 값이 true 일 때 junit 에러를 발생시킨다.
     */
    @Test
    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(selectedProduct.getName()).isEqualTo(givenProduct.getName());
        assertThat(selectedProduct.getPrice()).isEqualTo(givenProduct.getPrice());
        assertThat(selectedProduct.getStock()).isEqualTo(givenProduct.getStock());

        // == update ==
        // when
        Product foundProduct = productRepository.findById(selectedProduct.getNumber())
                .orElseThrow(RuntimeException::new);
        foundProduct.setName("장난감");
        Product updatedProduct = productRepository.save(foundProduct);

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

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

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

}

 


4. Service Test

1. Mock 객체를 직접 생성하여 사용한 테스트

class ProductServiceTest {

    /**
     * mock() 을 통해 Mock 객체로 ProductRepository 주입
     */
    private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private ProductServiceImpl productService;

    /**
     * 테스트 시작 전 ProductService 초기화
     * 현재 테스트 환경은 @SpringBootTest 의 관리하에 있지 않기 때문에 @Autowired 대신 테스트 시작 전 주입을 해줘야한다.
     */
    @BeforeEach
    public void setUpTest() {
        productService = new ProductServiceImpl(productRepository);
    }

    /**
     * 1. Mockito.when().thenReturn()
     * when 메서드 내부에 있는 동작을 하면, thenReturn() 을 통해 값을 return
     *
     * 2. assertEquals
     * when 절 에서 설정한 productResponseDto 와, given 절에서 설정한 givenProduct 의 값을 비교
     *
     * 3. any()
     * Mockito 의 ArgumentMatchers 에서 제공하는 메서드
     * Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고,
     * 메서드의 실행만을 확인하거나 클래스 객체 (현재 상황은 Entity Class) 를 매개변수로 전달받는 상황에 사용
     */
    @Test
    void getProductTest() {
        // given
        Product givenProduct = new Product();
        givenProduct.setNumber(123L);
        givenProduct.setName("펜");
        givenProduct.setPrice(1000);
        givenProduct.setStock(1234);

        // when
        Mockito.when(productRepository.findById(123L))
                .thenReturn(Optional.of(givenProduct));
        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // then
        assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
        assertEquals(productResponseDto.getName(), givenProduct.getName());
        assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
        assertEquals(productResponseDto.getStock(), givenProduct.getStock());

        verify(productRepository).findById(123L);
    }

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

        ProductResponseDto productResponseDto = productService
                .saveProduct(new ProductRequestDto("펜", 1000, 1234));

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

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

}

2. 스프링이 제공하는 테스트 어노테이션을 통해 Mock 객체를 생성 후 사용하는 테스트

/**
 * 스프링에서 제공하는 테스트 어노테이션을 통해 Mock 객체를 생성하기 위해
 * 큰 차이는 없지만, 스프링을 띄워야 되는 시간이 추가되기 때문에 Mock 객체를 직접 생성하는게 좀 더 빠르다.
 */
@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {

    /**
     * 스프링에 Mock 객체를 등록해서 주입
     */
    @MockBean
    ProductRepository productRepository;

    @Autowired
    ProductService productService;

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

        // when
        Mockito.when(productRepository.findById(123L))
                .thenReturn(Optional.of(givenProduct));
        ProductResponseDto productResponseDto = productService.getProduct(123L);

        // then
        assertEquals(productResponseDto.getNumber(), givenProduct.getNumber());
        assertEquals(productResponseDto.getName(), givenProduct.getName());
        assertEquals(productResponseDto.getPrice(), givenProduct.getPrice());
        assertEquals(productResponseDto.getStock(), givenProduct.getStock());

        verify(productRepository).findById(123L);
    }

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

        ProductResponseDto productResponseDto = productService
                .saveProduct(new ProductRequestDto("펜", 1000, 1234));

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

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

}

5. Controller Test

1. Mock 객체와 @WebMvcTest 를 통한 API 테스트

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ProductServiceImpl productService;

    /**
     * MockMvc
     * 테스트용 MVC 환경을 만들어 요청 및 전송, 응답 기능을 제공 / api 테스트도 가능
     *
     * 1. given()
     * 주입한 Mock 객체를 통해 테스트할 메서드 호출
     *
     * 2. willReturn()
     * given() 을 통해 실행한 결과 값과 일치해야한다. 즉, DB 에 접근하지 않고 테스트하기 위한 값을 세팅한다.
     *
     * 3. perform()
     * 요청을 전송하는 역할
     * andExpect() : perform 의 결과로 return 받은 ResultActions 객체의 값을 검증
     * andDo(print()) : 요청 / 응답 전체 메세지를 확인
     *
     * 4. GSON
     * JSON Object -> JAVA Object 또는 JAVA Object -> JSON Object 를 도와준다.
     *
     * 5. MediaType.APPLICATION_JSON
     * new MediaType("application", "json") 을 요청 시 추가
     *
     * 6. verify()
     * 해당 메서드가 실행되었는지 검증
     */
    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProductTest() throws Exception {
        // given
        given(productService.getProduct(123L)).willReturn(
                new ProductResponseDto(123L, "pen", 5000, 2000)
        );

        String productId = "123";

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

        // then
        verify(productService).getProduct(123L);
    }

    @Test
    @DisplayName("Product 데이터 생성 테스트")
    void createProductTest() throws Exception {
        // given
        given(productService.saveProduct(new ProductRequestDto("pen", 5000, 2000)))
                .willReturn(new ProductResponseDto(12315L, "pen", 5000, 2000));

        ProductRequestDto productRequestDto = ProductRequestDto.builder()
                .name("pen")
                .price(5000)
                .stock(2000)
                .build();

        Gson gson = new Gson();
        String content = gson.toJson(productRequestDto);

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

        // then
        verify(productService).saveProduct(new ProductRequestDto("pen", 5000, 2000));
    }

}

6. 38일차 후기

테스트 주도 개발은, 실제로 많은 회사들이 지원 요구사항에 작성해 두는 항목이다. 

당장은 간단한 프로젝트를 진행하고 있기 때문에 해당 부분이 굳이 필요한지 느끼지 못할 수 있지만, 어플리케이션의 사이즈가 커질 수록 직접 어플리케이션을 실행하고 기능 요청을 하는 부분이 굉장히 많은 시간을 잡아먹기 때문이다.

 

때문에 당장은 익숙하지 않지만 테스트 코드를 통해 개발을 진행하는 습관을 들여, 큰 규모의 프로젝트를 진행하더라도 테스트를 활용해 효율적으로 개발을 진행할 수 있으면 좋겠다는 생각이 들었다.

'Bootcamp > Spring Boot' 카테고리의 다른 글

[Spring Boot] 6. Spring Data JPA  (0) 2023.09.11
[Spring Boot] 5. 상속 관계 매핑  (0) 2023.09.11
[Spring Boot] 4. 연관 관계 매핑  (0) 2023.09.07
[Spring Boot] 2. JPA  (1) 2023.09.04
[Spring Boot] 1. Design Pattern  (0) 2023.09.04