스프링 부트 핵심 가이드 - 테스트 코드

이건희·2024년 3월 7일
1

오늘은 테스트 코드에 대한 이야기이다. 최근 나도 테스트 코드에 대한 중요성을 깨닫고 있는데 테스트 코드를 어떻게 작성하고 어떠한 방식으로 테스트 하는지 알아보자.

Given-When-Then 패턴

테스트 코드를 작성하는 방법은 다양하다. 이 책에서는 많은 사람들이 사용하는 'Given-When_Then' 패턴을 소개한다.

Given

  • 테스트를 수행하지 전에 테스트에 필요한 환경을 설정하는 단계

  • 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의한다.

When

  • 테스트의 목적을 보여주는 단계

  • 실제 테스트코드가 포함 된다

  • 테스트를 통한 결괏값을 가져온다.

Then

  • 테스트의 결과를 검증하는 단계

  • 일반적으로 When 단계에서 나온 결과값을 검증하는 작업을 수행한다.

일반적으로 Given-When-Then 패턴은 단위 테스트보다 비교적 많은 환경을 포함해서 테스트하는 인수 테스트에서 사용하는 것이 적합하다고 알려져 있지만 단위 테스트에서도 유용하게 활용할 수 있다고 한다.


JUnit의 생명주기

JUnit은 자바에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트를 위한 도구를 제공한다. 또한 단위 테스트 뿐만 아니라 통합 테스트를 할 수 있는 기능도 제공한다.

JUnit의 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원한다는 것이다.

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

@Test

  • 테스트 코드를 포함한 메서드를 정의한다.

@BeforeAll

  • 테스트를 시작하기 전에 호출되는 메서드이다.

@BeforeEach

  • 각 테스트 메서드가 실행되기 전에 동작하는 메서드이다.

@AfterAll

  • 테스트를 종료하면서 호출되는 메서드이다.

@AfterEach

  • 각 테스트 메서드가 종료되면서 호출되는 메서드이다.

즉, @BeforeAll, @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 수행되고, @BeforeEach나 @AfterEach 어노테이션이 지정된 메서드는 각 테스트가 실행될 때 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행된다.


컨트롤러 객체의 테스트

스프링에서는 스프링 부트가 자동 지원하는 기능들을 사용하고 있어 일부 모듈에서만 단위 테스트를 수행하기 어려운 경우도 있다. 그래서 이 책에서는 각 레이어(Controller, Service, Repository)별로 사용하기 적합한 방식의 테스트 가이드를 소개한다.

GET 요청

예시를 통해 보자. 아래 예시는 getProduct()를 테스트하는 메서드이다.

@WebMvcTest(ProductController.class)
public class ProductControllerTest {

	@Autowired
    private MockMvc mockMvc;
    
    //ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
    @MockBean
    ProductServiceImpl productService;
    
    @Test
    @DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
    void getProductTest() throws Exception {
    	

        given(productService.getProduct(123L)).willReturn(
        				new ProductResponseDto(123L, "pen", 5000, 2000));
        //given: 어떤 메서드가 호출되고 어떤 파라미터를 주입 받는지 가정
        //willReturn: 메서드가 어떤 결과를 리턴하는 것인지 정의
        
        String productId = "123";
        
        
        //perforn 메서드로 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드 작성
        //결과값으로 ResultActions 객체가 리턴되는데, andExpect 메서드를 사용해 결과값 검증을 수행할 수 있다.
        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());
        		//andDo로 요청과 응답의 전체 내용 확인
                
        verify(productService).getProduct(123L);
        //지정된 메서드가 검증하는 역할을 하는 verify
        
    }
}

위에서 사용된 어노테이션은 아래와 같다.

@WebMvcTest(테스트 대상 클래스.class)

  • 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있다.

  • 대상 클래스만 로드해 테스트를 수행한다

  • 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등 컨트롤러 관련 빈 객체가 모두 로드된다.

  • @SpringBootTest 보다 가볍게 테스트하기 위해 사용된다.

@MockBean

  • @MockBean은 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할을 수행한다.

  • @MockBean이 선언된 객체는 실제 객체가 아니기 때문에 실제 행위를 수행하지 않는다.

  • 따라서 개발자는 Mockito의 given() 메서드를 통해 동작을 정의해야 한다.

@Test

  • 테스트 코드가 포함돼 있다고 선언하는 어노테이션이다.

  • JUnit에서는 이 어노테이션을 감지해 테스트 계획에 포함시킨다.

@DisplayName

  • 테스트 메서드의 이름이 복잡해 가독성이 떨어질 경우 이 어노테이션을 사용해 표현을 정의한다

일반적으로 @WebMvcTest를 사용한 테스트는 슬라이스 테스트라고 부른다. 슬라이스 테스트는 딴위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍처를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미이다.

단위 테스트를 수행하기 위해서는 모든 외부 요인을 차단하고 테스트를 진행해야 하지만 컨트롤러는 개념상 웹과 맞닿은 레이어로서 외부 요인을 차단하면 의미가 없기 때문에 슬라이스 테스트를 진행하는 경우가 많다.

POST 요청

만약 Controller가 @RequestBody로 Json 형태의 값을 받는다면 아래와 같이 작성할 수 있다.

@WebMvcTest(ProductController.class)
public class ProductControllerTest {

	@Autowired
    private MockMvc mockMvc;
    
    //ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
    @MockBean
    ProductServiceImpl productService;
    
    @Test
    @DisplayName("Product 데이터 생성 테스트")
    void createProductTest() throws Exception {
    	

        given(productService.saveProduct(new ProductDto("pen, 5000, 2000))).willReturn(
        				new ProductResponseDto(12315L, "pen", 5000, 2000));
        //given: 어떤 메서드가 호출되고 어떤 파라미터를 주입 받는지 가정
        //willReturn: 메서드가 어떤 결과를 리턴하는 것인지 정의
        
        ProductDto productDto = ProductDto.builder()
        							.name("pen")
                                    .price(5000)
                                    .stock(2000)
                                    .build();
        
        //Json 파싱 라이브러리인 Gson 사용
        Gson gson = new Gson();
        String content = gson.toJson(productDto);
        
		
        //Post로 @RequestBody의 값을 넘겨주기
        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());
        		//andDo로 요청과 응답의 전체 내용 확인
                
        verify(productService).saveProduct(new ProductDto("pen", 5000, 2000));
        //지정된 메서드가 검증하는 역할을 하는 verify
        
    }
}

Gson을 사용해 @RequestBody에 해당하는 값을 넘겨주고, MediaType도 APPLICATION_JSON으로 맞춰준다. 이외에는 GET 요청과 거의 동일하다.


서비스 객체의 테스트

Mock 객체 직접 생성

서비스 객체 테스트도 예시로 보자

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() {
    	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());
        
        verify(productRepository).findById(123L);
        
    }
}

위에서는 @SpringBootTest, @WebMvcTest 등의 @...Test 어노테이션이 선언 되어 있지 않다. 이는 단위 테스트를 위해서 외부 요인을 모두 배제하도록 코드를 작성해야 하기 때문이다.

Mockito의 Mock 메서드를 통해 Mock 객체로 ProductRepository를 주입 받았다. 이 객체를 기반으로 테스트 전에 ProductService 객체를 초기화해서 사용한다.

본격적인 테스트 부분은 Given-When-Then 패턴으로 작성되어 있다. 테스트를 위해 필요한 값들을 준비하고 실행을 테스트 한 다음, Assertions을 이용해 검증한다. 또한 verify로 부가 검증을 시도한다.

@MockBean을 사용한 Mock 객체 주입

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

	@MockBean
    ProductRepository productRepository;
    
    @Autowired
    ProductService productService;
    
    ...생략...
}

생략된 코드는 위와 비슷해서 생략했다. ProductRepository에 대한 초기화 작업을 어떻게 진행하는지 비교하기 위한 코드이다. 이전 예제에서는 Mockito를 통해 리포지토리를 Mock 객체로 대체하는 작업을 수행하고 서비스 객체를 직접 초기화 했다.

위에서는 스프링에서 제공하는 테스트 어노테이션을 통해 Mock 객체를 생성하고 의존성을 주입 받고 있다. 둘의 차이라면 스프링의 기능에 의존하냐 아니냐의 차이이다.

@MockBean을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입 받는 형식이고, Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식이다.

@ExtendWith(SpringExtension.class)를 사용해 스프링 테스트 컨텍스트를 사용하도록 설정하고 객체를 주입 받는다. 그리고 @Autowired로 주입 받는 ProductService를 주입 받기 위해 @Import 어노테이션을 통해 사용한다.


리포지토리 객체의 테스트

리포지토리를 테스트 할 땐 고려해야 할 사항이 몇개 있다.

  • 우선적으로 리포지토리의 findById(), save() 등과 같이 기본 메서드에 대한 테스트는 큰 의미가 없다. 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문이다.

  • 또한 데이터베이스의 연동 여부는 테스트 시 고려해 볼 사항이다. 데이터베이스는 굳이 따지자면 외부 요인이고, 단위 테스트를 고려한다면 데이터베이스를 제외할 수 있다. 혹은 테스트용으로 다른 데이터베이스를 사용할 수 있다.

  • 데이터베이스를 연동한 테스트는 테스트 데이터가 적재되어 테스트 데이터를 제거하는 코드까지 포함해서 작성하는 것이 좋다.

기본 메서드는 의미가 없다고 했지만 예시를 위해 한번 저장을 테스트 해보자.

@DataJpaTest
public class ProductRepositoryTest {

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

첫번째 줄에서 @DataJpaTest를 사용하고 있다. @DataJpaTest는 다음과 같은 기능을 제공한다.

  • JPA 관련 설정만 로드해서 테스트를 진행한다.

  • 기본적으로 @Transaction을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백이 진행된다.

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

@DataJpaTest를 선언했기 때문에 ProductRepository를 정상적으로 주입 받을 수 있다. 한번 데이터 조회에 대한 테스트도 보자

@DataJpaTest
public class ProductRepositoryTest {

	@Autowired
    private ProductRepository productRepository;
    
    @Test
    void selectTest() {
    	//given
        Product product = new Product();
        product.setName("펜");
        product.setPrice(1000);
        product.setStock(1000);
        
        Product.saveProduct = 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(), foundPorduct.getStock());
    }
}

Given 절에서 객체를 데이터베이스에 저장 후, 조회 메서드를 호출해 테스트를 진행하고 이후 코드에서 데이터를 비교하며 검증을 수행한다.

profile
광운대학교 정보융합학부 학생입니다.

0개의 댓글