스프링 부트 애플리케이션 개발하면서 테스트 코드를 통해 우리가 개발한 코드를 어떻게 테스트할 수 있는지 알아봅니다.
최근에는 개발할 때 테스트 코드로 로직을 확인하는 과정이 점점 더 중요하게 여겨지고 있습니다. 애자일 방법론 중 하나인 테스트 주도 개발(TDD: Test-Driven Development)도 등장했습니다. 하지만 여기서 주의할 점은 테스트 코드를 작성하는 것과 테스트 주도 개발은 엄연히 다릅니다만 테스트 주도 개발은 개발 관점을 다르게 볼 수 있는 기회가 될 수 있습니다.
테스트 대상 범위를 기준으로 구분하면 크데 단위 테스트(Unit Test)와 통합 테스트(Integration Test)로 구분됩니다.
단위 테스트의 특징
단위 테스트는 테스트 대상의 범위를 기준으로 가장 낮은 단위의 테스트 방식입니다. 일반적으로 메서드 단위로 수행하고, 메서드 호출을 통해 의도한 결괏값이 나오는지 확인하는 수준으로 테스트를 진행합니다. 테스트 비용이 적게 들어서 테스트 피드백을 빠르게 받을 수 있습니다.
통합 테스트의 특징
통합 테스트는 모듈을 통합하는 과정에서의 호환성 등을 포함해 애플리케이션이 정상적으로 동작하는지 확인하기 위해 수행하는 테스트 방식입니다. 단위 테스트와 비교하면 단위테스트는 모듈을 독립적으로 테스트하고 통합 테스트는 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지를 확인합니다. 그리고 단위 테스트는 특정 모듈에 대한 테스트만 진행하기 때문에 데이터베이스, 네트워크 같은 외부 요인들을 제외하고 진행하는 데 비해 통합 테스트는 외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 오전히 동작하는지를 테스트하게 됩니다. 테스트를 진행할때 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점이 있습니다.
테스트 비용은 금전적인 비용을 포함해서 시간, 일력 같은 개발에 필요한 것들을 포괄합니다. 통계적으로 하나의 서비스를 개발할 때는 개발 과정에서 60%, 테스트 과정에서 40%의 비용이 든다고 알려져 있습니다.
Given-When-Then 패턴
Given: 테스트를 수행하기전에 테스트에 필요한 환경을 설정하는 단계입니다. 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의합니다.
When: 테스트이 목적을 보여주는 단계입니다. 실제 테스트 코드가 포함되며, 테스트를 통한 결괏값을 가져오게 됩니다.
Then: 테스트의 결과를 검증하는 단계입니다. 일반적으로 When 단계에서 나온 결괏값을 검증하는 작업을 수행합니다. 결괏값이 아니더라도 이 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 이 단계에 포함합니다.
Given-When-Then 패턴은 테스트 주도 개발에서 파생된 BDD(Behavior-Driven-Development; 행위 주도 개발)를 통해 탄생한 테스트 정근 방식입니다.
일반적으로 단위 테스트보다 인수 테스트에서 사용하는 것에 적합하다고 알려져 있지만 개인적으로는 단위 테스트에서도 유용하게 활용할 수 있다고 생각합니다.
간단한 테스트로 여겨지는 단위 테스트에서는 잘 사용하지 않는 이유는 불 필요하게 코드가 길어진다는 것입니다. 하지만 이 패턴을 통해 테스트 코드를 작성한다면 명세 문서의 역할을 수행한다는 측면에서 많은 도움이 됩니다.
F.I.R.S.T는 테스트 코드를 작성하는 데 도움이 될 수 있는 5가지 규칙을 의미합니다.
빠르게(Fast): 테스트는 빠르게 수행돼야 합니다. 느리면 개선하는 작업이 느리기 때문에 품질이 떨어질 수 있습니다. 목적을 단순하게 설정해서 작성하거나 외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있습니다.
고립된, 독립적(Isolated): 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행돼야 합니다. 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 테스트가 수행되지 않을 수 있습니다.
반복 가능한(Repeatable): 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 합니다. Isolated 규칙과 비슷하게 테스트는 개발환경의 변화나 네트워크의 연결 여부와 상관없이 수행돼야 합니다.
자가 검증(Self-Validating): 테스트는 그 자체만으로도 테스트의 검증이 완료돼야 합니다. 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야 합니다. 만약 결괏값과 기대갓을 비교하는 작업을 코드가 아니라 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드입니다.
적시에(Timely): 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성돼야 합니다. 너무 늦게 작성된 테스트 코드는 정삭적인 역할을 수행하기 어렵고 테스트 코드로 통해 발견된 문제를 해결하기 위해 소모되는 개발 비용도 커지기 쉽습니다.
JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트를 위한 도구를 제공합니다. 또한 단위 테스트뿐만 아니라 통합 테스트를 할 수 있는 기능도 제공합니다. JUnit의 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원한다는 것입니다. 즉 JUnit을 사용하면 몇 개의 어노테이션만으로 간편하게 테스트 코드를 작성할 수 있습니다. 또한 JUnit을 활용하면 단정문(assert)을 통해 테스트 케이스의 기댓값이 정상적으로 도출됐는지 검토할 수 있다는 장점이 있습니다.
JUnit Platform: JUnit Platform은 JVM에서 테스트를 시작하기 위한 뼈대 역할을 합니다. 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진(TestEngine)의 인터페이스를 가지고 있습니다. 테스트 엔진은 테스트를 발견하고 테스트를 수행하며, 그 결과를 보고하는 역할을 수행합니다. 각종 IDE와의 연동을 보조하는 역할을 수행합니다(IDE 콘솔 출력등), Platformd에는 TestEngine API, Console Launcher, JUnit 4 Based Runner 등이 포함돼 있습니다.
JUnit Jupiter: 테스트 엔진 API의 구현체를 포함하고 있으며, JUnit 5에서 제공하는 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있습니다. 테스트의 실제 구현체는 별도 모듈의 역할을 수행한는데, 그중 하나가 Jupiter Engine입니다. Jupiter Engine은 Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행하는 역할을 수행합니다.
JUnit Vintage: JUnit 3, 4에 대한 테스트 엔진 API를 구현하고 있습니다. 기존에 작성된 JUnit 3, 4 버전의 테스트 코드를 실행할 때 사용되며 Vintage Engine을 포함하고 있습니다.
JUnit의 하나의 Platform 모듈을 기반으로 Jupiter와 Vintage 모듈이 구현체의 역할을 수행합니다.
6장에서 가져옵니다.
원래는 프로젝트 구조 설명을 하기 위해 DAO 레이어를 추가했습니다. 하지만 간단한 코드로 구성돼기 때문에 DAO레이어는 제외합니다. 서비스레이어로 바로 리포지토리를 사용하는 구조로 진행합니다.
서비스IMPL
@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] input number : {}, 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 saveProduct = productRepository.save(product);
LOGGER.info("[saveProduct] savedProduct : {}", saveProduct);
ProductResponseDto productResponseDto = new ProductResponseDto();
productResponseDto.setNumber(saveProduct.getNumber());
productResponseDto.setName(saveProduct.getName());
productResponseDto.setPrice(saveProduct.getPrice());
productResponseDto.setStock(saveProduct.getStock());
return productResponseDto;
}
@Override
public ProductResponseDto changeProductName(Long number, String name) throws Exception{
Product foundProduct=productRepository.findById(number).get();
foundProduct.setName(name);
Product changeProduct=productRepository.save(foundProduct);
ProductResponseDto productResponseDto=new ProductResponseDto();
productResponseDto.setNumber(changeProduct.getNumber());
productResponseDto.setName(changeProduct.getName());
productResponseDto.setPrice(changeProduct.getPrice());
productResponseDto.setStock(changeProduct.getStock());
return productResponseDto;
}
@Override
public void deleteProduct(Long number) throws Exception{
productRepository.deleteById(number);
}
}
엔티티
@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 의존성을 추가합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
제공하는 라이브러리
어노테이션
public class TestLifeCycle {
@BeforeAll
static void beforeAll(){
System.out.println("## BeforeAll Annotation 호출 ##");
System.out.println();
}
@AfterAll
static void afterAll(){
System.out.println("## After All 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 호출 ##
void com.springboot.test.TestLifeCycle.test3() is @Disabled
## After All Annotation 호출 ##
@BeforeAll과 @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행됩니다. 그럼 @BeforeEach와 @AfterEach 어노테이션이 지정된 메서드는 각 테스트가 실행될 때 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행되는 것을 볼 수 있습니다. 마지막으로 test3()에는 @Disabled 어노테이션을 지정했는데, 이 어노테이션이 지정된 테스트는 실행되지 않는 것을 볼 수 있습니다. 따라서 테스트 메서드로는 인식되고 있어 test3() 메서드가 비활성화됐다는 로그가 출력됩니다.
전체적인 비즈니스 로직이 정상적으로 동작하는지 테스트하고 싶다면 통합 테스트를 하고, 각 모듈을 테스트하고 싶다면 단위 테스트를 해야 합니다. 스프링부트에 자동기능들을 사용해서 일부 모듈에서만 단위 테스트를 수행하기 여려운 경우도 있습니다. 그렇게 때문에 레이어별로 사용하기 적합한 방식의 테스트 가이드를 소개합니다. 목적에 따라 적합한 방식일수도 있고 아닐수도 있으니 다양한 테스트를 구성해보길 권장합니다.
컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고 그 결괏값을 가공해서 클라이언트에게 응답하는 역할입니다. 애플리케이션 구성하는 여러 레이어 중 가장 웹에 가까이에 있는 모듈이라고 볼 수 있습니다.
ProductController를 대상으로 getProduct()와 createProduct() 메서드에 대한 테스트 코드를 작성합니다.
@RestController
@RequestMapping("/product")
public class ProductControllerTest {
private final ProductService productService;
@Autowired
public ProductControllerTest(ProductService productService){
this.productService=productService;
}
@GetMapping
public ResponseEntity<ProductResponseDto> getProduct(Long number){
ProductResponseDto productResponseDto = productService.getProduct(number);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
ProductController는 ProductServiced의 객체를 의존성 주입받습니다. 앞으로 이제 클래스에서도 최소 1개 이상의 객체를 의존서 주입받는 코드가 등장합니다. 예를들면 ProductController만 테스트하고 싶다면 ProudcutService는 외부 요인이기 때문에 독립적인 테스트 코드를 작성하기 위해 Mock 객체를 활용해야합니다. 이제 테스트 해볼건데 test/java/com.springboot.test 패키지에 controlelr패키지를 생성하고 ProductControllerTest.java 생성합니다.
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)
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
ProductServiceimpl procuctService;
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
given(procuctService.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("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
.andDo(print());
verify(procuctService).getProduct(123L);
}
}
사용된 어노테이션은
여기서 코드를 살펴보면 @MockBean 어노테이션을 통해 ProdcutController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체를 주입했습니다. Mock 객체에는 테스트 과정에서 맡을 동작을 정의해야합니다. 위에 String 값으로 초기화된 123은 Mockito에서 제공하는 given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성합니다.
MockMvc는 컨틀롤러의 API를 테스트하기 위해 사용된 객체입니다. 정확하게 얘기하면 서블릿 컨테이너 구동없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스입니다.
Perform() 메서드를 이용하면 서버로 URL 요청을 보내는것 처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있습니다. perform() 메서드는 MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용합니다. MockMvcRequestBuilders는 GET,POST,PUT,DELETE에 매핑되는 메서드를 제공합니다. 이 메서드는 MockHttpServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게 됩니다.
Perform() 메서드의 결괏값으로 ReusltActions 객체가 리턴되는데,
.andExpect(status().isOk())
.andExpect(jsonPath(
"$.number").exists())
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
andExpect() 메서드를 이용해 결괏값을 검증을 수행할 수 있습니다. andExpect() 메서드에서는 ResultMatcher를 활용하는데, 이를 위해 MockMvcResultMatchers 클래스에 정의돼 있는 메서드를 활용해 생성할 수 있습니다.
요청과 응답의 전체 내용을 확인하려면 andDo() 메서드를 사용합니다. MockMvc의 코드는 모두 합쳐져 있어 구분하기 어렵지만 전체적인 'When-Then'의 구조를 갖추고 있음을 확인할 수 있습니다.
마지막으로 verify() 메서드는 지정된 메서드가 실행됐는지 검증하는 역할입니다. 일반적으로 given()에 정의된 동작과 대응합니다.
슬라이드 테스트를위해 사용할수 있는 대표적인 어노테이션
이제 createProduct() 메서드의 테스트 코드를 작성합니다.
import com.springboot.test.data.dto.ProductDto;
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.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;
import com.google.gson.Gson;
import org.springframework.http.MediaType;
@Test
@DisplayName("Product 데이터 생성 테스트")
void createProductTest() throws Exception{
//Mock 객체에서 특정 메서드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
given(procuctService.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(procuctService).saveProduct(new ProductDto("pen",5000, 2000));
}
이제 ProductDto를 수정합니다
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ProductDto {
private String name;
private int price;
private int stock;
}
이제 gson의 의존성을 추가해야합니다 Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 합니다.
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
createProduct()를 테스트하는 코드는 getProduct()를 테스트하는 코드와 거의 비슷합니다. 여기서 given() 메서드를 통해 ProductService의 saveProduct() 메서드의 동작 규칙을 설정하고 builder를 통해 필요한 객체를 생성합니다. 실제 테스트 수행코든느 mockMvc.perform() 메서드의 post 메서드를 통해 URL을 구성합니다. 그리고 @RequestBody의 값을 넘겨주기 위해 content() 메서드에 DTO값을 담아 테스트를 진행합니다. 마지막으로 POST 요청을 통해 도출된 결괏값에 대해 각 항목이 존재하는지 jsonPath().exists()를 통해 검증합니다. 검증한 결과, 대응하는 값이 없다면 오류가 발생합니다.
서비스 레이어에 해당하는 ProductService 객체를 테스트합니다.
그럼 먼저 getProduct() 메서드에 대해 테스트 코드를 작성합니다. 아무 의존성 주입 없이 받은 단위 테스트를 작성하면
package com.springboot.test.service.impl;
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.Mockito.verify;
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 giveProduct = new Product();
giveProduct.setNumber(123L);
giveProduct.setName("펜");
giveProduct.setPrice(1000);
giveProduct.setStock(1234);
Mockito.when(productRepository.findById(123L))
.thenReturn(Optional.of(giveProduct));
ProductResponseDto productResponseDto = productService.getProduct(123L);
Assertions.assertEquals(productResponseDto.getNumber(), giveProduct.getNumber());
Assertions.assertEquals(productResponseDto.getName(), giveProduct.getName());
Assertions.assertEquals(productResponseDto.getPrice(), giveProduct.getPrice());
Assertions.assertEquals(productResponseDto.getStock(), giveProduct.getStock());
verify(productRepository).findById(123L);
}
}
단위 테스트를 위해 외부 요인을 모두 배재했습니다.
코드를 보면 mock()메서드를 통해 Mock 객체로 ProductRepository를 주입받았습니다. setUpTest를 보시면 각 테스트 전에 ProductService 객체를 초기화해서 사용합니다.
Given - When - Then 패턴으로 Given 구문에 Product 엔티티 객체를 생성하고 ProductRepository의 동작에 대한 결과값을 리턴을 설정합니다.
그리고 나서 ProductService 의 getProduct() 메서드를 호출해서 동작을 테스트합니다.
그럼 테스트에서 리턴받은 ProductResponseDto 객체에 대해서 Assertions을 통해 값을 검증함으로써 테스트의 목적을 달성하는지 확인합니다. 마지막으로 검증 보완을 위해 verify() 메서드로 부가 검증을 시도합니다.
이어서 saveProduct() 메서드에 대한 테스트 코드를 작성합니다.
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.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
public class ProductServiceTest {
private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
private ProductServiceimpl productService;
@BeforeEach
public void setUpTest(){
productService = new ProductServiceimpl(productRepository);
}
@Test
void saveProductTest(){
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());
}
}
여기서 any()는 Mockito의 ArgumentMatchers에서 제공하는 메서드로서 Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수를 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용합니다.
즉 any(Product.class)로 동작을 설정했는데, 일반적으로 given()으로 정의된 Mock 객체의 메서드 동작 감지는 매개변수의 비교를 통해 이뤄지나 레퍼런스 변수의 비교는 주솟값으로 이뤄지기 때문에 any()를 사용해 클래스만 정의하는 경우도 있습니다.
지금까지는 Mock 객체를 활용한 테스트 방식입니다. 큰 차이는 없지만 Mock 객체를 직접 생성하지 않고 @MockBean 어노테이션을 사용해 스프링 컨테이너에 Mock 객체를 주입받는 방식을 소개 합니다.
이제 여기서 똑같은 위치에 ProductServiceTest2.java를 만들어줍니다.
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.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.Optional;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
@ExtendWith(SpringExtension.class)
@Import(ProductServiceimpl.class)
//@SpringBootTest
class ProductServiceTest2 {
@MockBean
ProductRepository productRepository;
@Autowired
ProductService productService;
@Test
void getProductTest(){
Product giveProduct = new Product();
giveProduct.setNumber(123L);
giveProduct.setName("펜");
giveProduct.setPrice(1000);
giveProduct.setStock(1234);
Mockito.when(productRepository.findById(123L))
.thenReturn(Optional.of(giveProduct));
ProductResponseDto productResponseDto = productService.getProduct(123L);
Assertions.assertEquals(productResponseDto.getNumber(), giveProduct.getNumber());
Assertions.assertEquals(productResponseDto.getName(), giveProduct.getName());
Assertions.assertEquals(productResponseDto.getPrice(), giveProduct.getPrice());
Assertions.assertEquals(productResponseDto.getStock(), giveProduct.getStock());
verify(productRepository).findById(123L);
}
@Test
void saveProductTest(){
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 객체를 등록해서 주입받는 형식이며 Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식입니다. 둘다 테스트 속도에서 큰 차이는 없지만 아무래도 스프링을 사용하지 않는 Mock 객체를 직접 생성하는 방식이 더 빠르게 동작합니다.
@ExtendWith(SpringExtension.class) JUnit 5의 테스트에서 스프링 테스트 컨텍스트를 사용하도록 설정합니다.
@Import(ProductServiceimpl.class) @Autowired 어노테이션으로 주입받는 ProductService를 주입작기 위해 @Import 어노테이션을 통해 사용합니다.
리포지티리는 개발자가 구현하는 레이어 중에서 가장 데이터베이스와 가깝습니다. JpaRepository를 상속받아 기본적인 쿼리 메서드를 사용할 수 있습니다. 그렇기 때문에 리포지토리 테스트는 특히 구현하는 목적에 대해 고민하고 작성해야합니다.
리포지토리 객체의 테스트 코드를 작성할 때 findById(), save() 같은 기본 메서드에 대한 테스트는 큰 의미가 없습니다. 왜냐하면 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문입니다.
데이터베이스는 외부요인이고, 단위 테스트를 할시 데이터베이스를 제외할 수 있습니다. 혹은 테스트용으로 다른 데이터베이스를 사용하는 경우도 있습니다 왜냐하면 데이터베이스를 사용한 테스트는 테스트 과정에서 데이터베이스에 테스트 데이터가 적재되기 때문입니다. 그래서 데이터베이스 연동한 테스트는 테스트 데이터를 제거하는 코드까지 포함해서 작성 해야합니다. 테스트 데이터의 적재를 신경 써야 하는 테스트 환경이라면 잘못된 테스트 코드가 실행되면서 발생할 수 있어 데이터베이스 연동 없이 테스트 하는게 좋습니다.
마리아DB를 사용해서 H2DB를 사용합니다.
H2DB 의존성을 추가해줍니다.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
데이터베이스 값을 저장하는 테스트 코드입니다.
테스트 패키지에 com.springboot.test 내에 data/repository 패키지 생성한 후 ProductRepositoryTesrByJ2.java를 생성합니다.
@DataJpaTest
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 saveProduct= productRepository.save(product);
//then
assertEquals(product.getName(), saveProduct.getName());
assertEquals(product.getPrice(), saveProduct.getPrice());
assertEquals(product.getStock(), saveProduct.getStock());
}
}
@DataJpaTest 언노테이션
Given-When-Then 팬턴으로 작성된 코드입니다. Given 구문에서는 테스트에서 사용할 Product 엔티티를 만들고, When 구문에서 생성된 엔티티를 기반으로 save() 메서드를 호출해서 테스트 진해합니다. 이후 정상적인 테스트가 이뤄졌는지 체크하기 위해 save() 메서드의 리턴 객체와 Given에서 생성한 엔티티 객체의 값이 일치하는 assertEquals() 메서드를 통해 검증합니다.
데이터 조회
@DataJpaTest
public class ProductRepositoryTestByH2 {
@Autowired
private ProductRepository productRepository;
@Test
void saveTest(){
//given
Product product = new Product();
product.setName("펜");
product.setPrice(1000);
product.setStock(1000);
Product saveProduct= productRepository.saveAndFlush(product);
//when
Product foundProduct=productRepository.findById(saveProduct.getNumber()).get();
//then
assertEquals(product.getName(), saveProduct.getName());
assertEquals(product.getPrice(), saveProduct.getPrice());
assertEquals(product.getStock(), saveProduct.getStock());
}
}
데이터 조회 하기위해 데이터베이스에 테스트 데이터를 추가해야 합니다. Given 절에 객체를 데이터베이스에 저장하고 Product foundProduct=productRepository.findById(saveProduct.getNumber()).get(); 을 통해 조회 메서드를 호출해서 테스트를 진행하고 이후 코드에서 데이터를 비교하며 검증을 수행합니다.
기존에 사용하고 있던 마리아DB에서 테스트하기 위해서는 별도의 설정이 필요해서 패키지 경로에 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 saveProduct= productRepository.save(product);
assertEquals(product.getName(), saveProduct.getName());
assertEquals(product.getPrice(), saveProduct.getPrice());
assertEquals(product.getPrice(), saveProduct.getPrice());
}
}
replace 요소는 @AutoConfigureTestDatabase 어노테이션 값을 조정하는 작업을 수행합니다. replace 속성 기본값은 Replace.ANY이며, 이 경우 임베디드 메모리 데이터베이스를 사용합니다. 이 속성값을 Replace.NONE으로 변경하면 애플리케이션에서 실제로 사용하는 데이터베이스로 테스트가 가능합니다.
또한 @DataJpaTest를 사용하지않고 @SpringBootTest 어노테이션으로도 테스트 할 수 있습니다. 패키지경로에 ProductRepository2.java를 생성합니다.
@SpringBootTest 어노테이션을 사용한 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 saveProduct = productRepository.save(givenProduct);
//then
Assertions.assertThat(saveProduct.getNumber())
.isEqualTo(givenProduct.getNumber());
Assertions.assertThat(saveProduct.getName())
.isEqualTo(givenProduct.getName());
Assertions.assertThat(saveProduct.getPrice())
.isEqualTo(givenProduct.getPrice());
Assertions.assertThat(saveProduct.getStock())
.isEqualTo(givenProduct.getStock());
/* read */
//when
Product selectedProduct= productRepository.findById(saveProduct.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 */
//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());
}
}
위에 코드는 CRUD의 모든 기능을 한 테스트 코드에 작성했습니다. 기본 메서드를 테스트하기 때문에 Given 구문을 한 번만 사용해 전체 테스트에 활용했습니다. @SpringBootTest 어노테이션을 활용하면 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요 없이 테스트가 가능합니다. 다만 테스트의 속도가 느리므로 다른 방법으로 테스트할 수 있다면 대안을 고려해보는 것이 좋습니다.
코드 커버리지(code coverage)는 소프트웨어의 테스트 수준이 충분한지를 표현하는 지표 중 하나입니다. 테스트 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용됩니다.
커버리지를 확인하기 위한 다양한 커버리지 도구 중 가장 보편적으로 사용되는 도구는 JaCoCo입니다. JaCoCo는 Java Code Coverage의 약자로, JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트합니다. JaCoCo는 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작하며, 리포트는 HTML, XML, CSV 같은 다양한 형식으로 확인할 수 있습니다.
의조성 추가
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
</dependency>
<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>
<elment>BUNDLE</elment>
<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>
<maximum>50</maximum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
configuration 설정은 일부 클래스를 커버리지 측정 대상에서 제외하는 것입니다. 경로와 무관하게 ProductServiceImpl.class를 커버리지 측정 대상에서 제외하도록 설정돼 있습니다.
execution은 기본적으로 goal을 포함하며, 설정한 값에 따라 추가 설정이 필요한 내용을 configuration 과 rule을 통해 작성합니다. 먼저 execution에서 설정할 수 있는 goal의 속성값은
JaCoCO에서 설정할 수 있는 Rule을 살펴봅니다. configuratin 태그안에 설정하며, 다양한 속성을 활용할 수 있습니다.
Element는 코드 커버리지를 체크하는 데 필요한 범위 기준을 설정합니다.
Counter는 커버리지를 측정하는 데 사용하는 지표입니다.
Value 태그로는 커버리지 지표를 설정합니다. 측정한 커버리지를 어떤 방식으로 보여주는지 설정합니다.
코드를 보면
<configuration>
<rules>
<rule>
<elment>BUNDLE</elment>
<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>
<maximum>50</maximum>
</limit>
</limits>
</rule>
</rules>
</configuration>
limit이 각 Element 다윈로 설정돼 있습니다. 패키지 번들 단위로 바이트코드 명령 수를 기준으로 커버리지가 최소한 80% 달성하는 것을 limit으로 설정했습니다. 그리고 메서드 단위로 전체 라인 수를 최대 50줄로 설정했습니다. 설정한 기준에 벗어나면 에러가 발생합니다.
TDD란 Test-Driven Devlopment의 줄임말로 테스트 주도 개발이라는 의미를 가지고 있습니다.
테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코들르 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식입니다.
애자일 방법론 중 하나인 익스트림 프로그래밍(eXtream Programming)의 Test-First 개념에 기반을 둔, 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시합니다.
애자일은 신속한 반복 작업을 통해 실제 작동 가능한 소프트웨어를 개발하는 개발 방식입니다. 원래 애자일 방법론 자체는 일하는 방법에 대한 관점으로 소프트웨어 개발에만 국한되지는 않습니다.
애자일 소프트웨어 개발 방법론의 핵심은 신속한 개발 프로세스를 통해 수시로 변하는 고객의 요구사항에 대응해서 제공하는 서비스의 가치를 극대화하는 것입니다.
테스트 주도 개발에서는 위 그림과 같이 총 3개의 단계로 개발 주기를 표현합니다.
디버깅 시간 단축: 테스트 코드 기반으로 개발이 진행되기 때문에 문제가 발생햇을 때 어디에서 잘못됐는지 확인하기가 쉽습니다.
생산성 향상: 테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대한 피드백을 받기 때문에 리팩토링 횟수가 줄고 생산성이 높아집니다.
재설계 시간 단축: 작성돼 있는 테스트 코드를 기반으로 코드를 작성하기 때문에 재설계가 필요한 경우 테스트 코드를 조정하는 것으로 재설계 시간을 단축할 수 있습니다.
기능 추가와 같은 추가 구현이 용이
테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성하기 때문에 목적에 맞는 코드를 작성하는 데 비교적 용이합니다.
좋은 글이네요. 공유해주셔서 감사합니다.