테스트 방법은 테스트 대상 범위를 기준으로 구분하면 크게 단위 테스트와 통합 테스트로 구분됩니다.
7.2.1 단위테스트의 특징
단위 테스트(Unit Tests)란 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식으로, 테스트 대상의 범위를 기준으로 하는 가장 작은 단위의 테스트 방법입니다. 일반적으로 메서드 단위로 테스트를 수행하게 되며, 메서드 호출을 통해 의도한 결괏값이 나오는지 확인하는 수준으로 테스트를 진행합니다. 단위 테스트는 테스트 비용이 적게 들기 때문에 테스트 피드백을 빠르게 받을 수 있습니다. 데이터베이스나 네트워크 같은 외부 요인을 제외하고 진행합니다.
7.2.2 통합 테스트의 특징
통합 테스트(Integration Tests)란 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식입니다. 외부 요인들을 포함하여 애플리케이션이 온전히 동작하는지를 테스트합니다. 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점이 있습니다.
테스트 코드를 작성하는 방법은 다양합니다. 그 중 'Given-When-Then' 패턴과 'F.I.R.S.T' 전략을 소개합니다.
7.3.1 Given-When-Then 패턴
단어에서 유추할 수 있듯이 다음과 같은 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성합니다.
Given - 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계입니다. 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의합니다. |
When - 테스트의 목적을 보여주는 단계입니다. 실제 테스트 코드가 포함되며, 테스트를 통한 결괏괎을 가져오게 됩니다. |
Then - 테스트의 결과를 검증하는 단계입니다. 일반적으로 When 단계에서 나온 결괏값을 검증하는 작업을 수행합니다. 결괏값이 아니더라도 이 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 이 단계에 포함됩니다. |
Given-When-Then 패턴은 테스트 주도 개발에서 파생된 BDD(Behavior-Driven-Development:행위 주도 개발)을 통해 탄생한 테스트 접근 방식입니다. 일반적으로 비교적 많은 환경을 포함해서 테스트하는 인수 테스트에서 사용하는 것에 적합하다고 알려져 있습니다. 이 패턴을 통해 테스트 코드를 작성한다면 '명세' 문서의 역할을 수행한다는 측면에서 많은 도움이 됩니다.
7.3.2 좋은 테스트를 작성하는 5가지 속성(F.I.R.S.T)
F.I.R.S.T는 테스트 코드를 작성하는 데 도움이 될 수 있는 5가지 규칙을 의미합니다. 대체로 단위 테스트에 적용할 수 있는 규칙입니다.
빠르게(Fast) - 테스트는 빠르게 수행되어야 합니다. 테스트가 느리면 코드를 개선하는 작업이 느려져 코드의 품질이 떨어질 수 있습니다. |
고립된, 고립적(Isolated) - 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 합니다. 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 테스트가 수행되지 않을 수 있습니다. |
반복 가능한(Repeatable) - 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 합니다. 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행되어야 합니다. |
자가 검증(Self-Validating) - 테스트는 그 자체만으로도 테스트의 검증이 완료되어야 합니다. 테스트의 성공 여부를 확인할 수 있는 코드를 함께 작성해야 합니다. |
적시에(Timely) - 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 합니다. 이 규칙은 테스트 주도 개발의 원칙을 따르는 테스트 작성 규칙으로, 테스트 주도 개발을 기반으로 애플리케이션을 개발하는 것이 아니라면 이 규칙을 제외하기도 합니다. |
JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트와 통합 테스트를 위한 도구와 기능을 제공합니다. 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원한다는 것입니다. 단정문(assert)을 통해 테스트 케이스의 기댓값이 정상적으로 도출됐는지 검토할 수 있다는 장점이 있습니다.
7.4.1 JUnit의 세부 모듈
JUnit 5는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성됩니다. JUnit은 하나의 Platform 모듈을 기반으로 Jupiter과 Vintage 모듈이 구현체의 역할을 수행합니다.
JUnit Platform - JVM에서 테스트를 시작하기 위한 뼈대 역할을 합니다. 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진의 인터페이스를 가지고 있습니다. 테스트 엔진은 테스트를 수행하며, 그 결과를 보고하는 역할을 수행합니다. |
JUnit Jupiter - 테스트 엔진 API의 구현체를 포함하고 있으며, JUnit 5에서 제공하는 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있습니다. 테스트의 실제 구현체는 별도 모듈의 역할을 수행하는데, 그중 하나가 Jupiter Engine입니다. Jupiter Engine은 Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행하는 역할을 수행합니다. |
JUnit Vintage - JUnit 3, 4에 대한 테스트 엔진 API를 구현하고 있습니다. 기존에 작성된 JUnit 3, 4 버전의 테스트 코드를 실행할 때 사용되며 Vintage Engine을 포함하고 있습니다. |
7.4.2 스프링 부트 프로젝트 생성
이번 장에서 사용될 프로젝트를 아래와 같이 생성합니다.
그리고 6장에서 만들었던 프로젝트의 코드 일부를 다음과 같이 가져옵니다. 간단한 코드로 구성되어 있기 때문에 DAO 레이어는 제외하고 서비스 레이어에서 바로 리포지토리를 사용하는 구조로 진행하겠습니다.
ProductServiceImpl 클래스를 다음과 같이 수정합니다.
@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);
}
}
그리고 Product 엔티티 클래스를 다음과 같이 수정합니다.
@Entity
@Builder
@Table(name = "product")
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString(exclude = "name")
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;
}
7.4.3 스프링 부트의 테스트 설정
스프링 부트는 테스트 환경을 쉽게 설정할 수 있게 spring-boot-starter-test 프로젝트를 지원합니다. 이 프로젝트를 사용하려면 pom.xml 파일에 관련 의존성을 추가해야 합니다.
<dependencies>
...생략...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
...생략...
</dependencies>
스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ 등의 다양한 테스트 도구를 제공합니다. 또한 자동 설정을 지원하므로 편리하게 쓸 수 있습니다. 제공하는 라이브러리는 다음과 같습니다.
7.4.4 JUnit의 생명주기
JUnit의 동작 방식을 확인하기 위해 생명주기를 알아보겠습니다. 생명주기와 관련하여 테스트 순서에 관여하게 되는 대표적인 어노테이션은 다음과 같습니다.
@Test - 테스트 코드를 포함한 메서드를 정의합니다. |
@BeforeAll - 테스트를 시작하기 전에 호출되는 메서드를 정의합니다. |
@BeforeEach - 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의합니다. |
@AfterAll - 테스트를 종료하면서 호출되는 메서드를 정의합니다. |
@AfterEach - 각 테스트 메서드가 종료되면서 호출되는 메서드를 정의합니다. |
어노테이션의 동작을 알아보기 위해 test 디렉터리에 TestLifeCycle.java 파일을 생성합니다.
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 호출 ## void cohttp://m.springboot.test.TestLifeCycle.test3() is @Disabled ## afterAll Annotation 호출 ## |
test3()에는 @Disabled 어노테이션을 지정했는데, 이 어노테이션이 지정된 테스트는 실행되지 않는 것을 볼 수 있습니다. 그렇지만 테스트 메서드로는 인식되고 있어 test3() 메서드가 비활성화됐다는 로그가 출력되었습니다.
7.4.5 스프링 부트에서의 테스트
테스트 방식은 매우 다양합니다. 이번 장에서는 레이어별로 사용하기 적합한 방식의 테스트 가이드를 소개할 예정입니다.
7.4.6 컨트롤러 객체의 테스트
컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞은 서비스 컴포넌트로 요청을 전달하고 그 결괏값을 가공해서 클라이언트에게 응답하는 역할을 수행합니다. 즉, 가장 웹 가까이에 있는 모듈입니다. 이번 절에서는 프로젝트에 생성한 ProductController를 대상으로 getProduct()와 createProduct() 메서드에 대한 테스트 코드를 작성하겠습니다.
@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의 객체를 의존성 주입받습니다. 테스트하는 입장에서 ProductController만 테스트하고 싶다면 ProductService는 외부 요인에 해당됩니다. 독립적인 테스트 코드를 작성하기 위해서는 Mock 객체를 활용해야 합니다. 해당 위치에 ProductControllerTest.java 파일을 생성해 주세요.
@WebMvcTest(ProductController.class) // 슬라이스 테스트
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // 컨트롤러의 API를 테스트하기 위해 사용된 객체
@MockBean // 가짜 객체를 생성해서 주입하는 역할 수행. 실제 객체가 아니기 때문에 실제 행위를 수행하지 않는다.
ProductServiceImpl productService;
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트") // 테스트에 대한 표현 정의
void getProductTest() throws Exception {
// given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
given(productService.getProduct(123L)).willReturn(
new ProductResponseDto(123L, "pen", 5000, 2000));
String productId = "123";
// andExpect : 리턴된 ResultActions 객체로 기대하는 값이 나왔는지 체크해볼 수 있는 메소드
// andDo : 요청과 응답의 전체 내용을 확인. When-Then 구조
mockMvc.perform(
get("/product?number=" + productId))
.andExpect(status().isOk())
.andExpect(jsonPath(
"$.number").exists()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
.andDo(print());
// verify : 지정된 메소드가 실행되었는지 검증
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("/product") // URL을 구성
.content(content) // @RequestBody의 값을 넘겨주기 위해 DTO의 값을 담음.
.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(productService).saveProduct(new ProductDto("pen", 5000, 2000));
}
}
테스트 코드를 실행하려면 pom.xml에 Gson에 대한 의존성을 추가해야 합니다. Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할을 합니다.
<dependencies>
...생략...
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
...생략...
</dependencies>
예제에 사용된 어노테이션은 다음과 같습니다.
@WebMvcTest(테스트 대상 클래스.class) - 웹에서 사용되는 요청과 응답에 대한 테스트를 수행할 수 있습니다. 대상 클래스만 로드해 테스트를 수행하며, 만약 대상 클래스를 추가하지 않으면 컨트롤러 관련 빈 객체가 모두 로드됩니다. @SpringBootTest보다 가볍게 테스트할 수 있습니다. |
@MockBean - 가짜 객체를 생성해서 주입하는 역할을 수행합니다. 실제 객체가 아니기 때문에 실제 행위를 수행하지 않습니다. 그렇기 때문에 given() 메서드를 통해 동작을 정의해야 합니다. |
@Test - 테스트 코드가 포함되어 있다고 선언하는 어노테이션입니다. |
@DisplayName - 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현을 정의할 수 있습니다. |
@WebMvcTest 어노테이션을 사용한 테스트를 슬라이스(Slice) 테스트라고 부릅니다. 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로 이해하면 되는데, 레이어드 아키텍쳐를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미입니다. 컨트롤러는 개념상 웹과 맞닿은 레이어로서 외부 요인을 차단하고 테스트할 수 없기 때문에 슬라이스 테스트를 진행하는 경우가 많습니다.
given 부분 코드를 살펴보면 Mockito에서 제공하는 given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드가 작성되어 있습니다.
MockMvc는 서블릿 컨테이너의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스입니다. perform() 메서드를 이용하면 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러를 테스트할 수 있습니다. perform() 메서드는 MockMvcRequestBuilders에서 제공하는 HTTP 메서드로 URL을 정의해서 사용합니다. MockMvcRequestBuilders는 GET, POST, PUT, DELETE에 매핑되는 메서드를 제공합니다. 이 메서드는 MockHttpServletRequestBuilder 객체를 리턴하며, HTTP 요청 정보를 설정할 수 있게 됩니다.
7.4.7 서비스 객체의 테스트
이번에는 서비스 레이어에 해당하는 ProductService 객체를 테스트하겠습니다. 해당 위치에 ProductServiceTest.java 파일을 생성합니다.
public class ProductServiceTest {
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)); // productRepository의 동작에 대한 결괏값 리턴 설정
// when
ProductResponseDto productResponseDto = productService.getProduct(123L); // 동작 테스트
// then
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() {
// given
Mockito.when(productRepository.save(any(Product.class)))
.then(returnsFirstArg());
// when
ProductResponseDto productResponseDto = productService.saveProduct(
new ProductDto("펜", 1000, 1234));
// then
Assertions.assertEquals(productResponseDto.getName(), "펜");
Assertions.assertEquals(productResponseDto.getPrice(), 1000);
Assertions.assertEquals(productResponseDto.getStock(), 1234);
verify(productRepository).save(any());
}
}
단위 테스트를 위해서는 외부 요인을 모두 배제하도록 코드를 작성해야 합니다. 이번 예제에서는 Test 어노테이션이 선언되어 있지 않습니다.
이후 Mockito의 mock() 메서드를 통해 Mock 객체로 ProductRepository를 주입받았습니다. 이 객체를 기반으로 각 테스트 전에 ProductService 객체를 초기화해서 사용합니다.
테스트 코드는 Given-When-Then 패턴을 기반으로 작성되었습니다.
saveProductTest() 메서드의 given 부분의 any() 메서드를 살펴보겠습니다. any()는 Mockito의 ArgumentMatchers에서 제공하는 메서드로서 Mock 객체의 동작을 정의하거나 검증하는 단계에서 조건으로 특정 매개변수의 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용합니다.
일반적으로 Mock 객체의 메서드 동작 감지는 매개변수의 비교를 통해 이뤄지나 메모리상에 생성된 인스턴스를 가리키는 변수인 레퍼런스 변수의 비교는 주솟값으로 이뤄지기 때문에 any()를 사용해 클래스만 정의하는 경우도 있습니다.
지금까지 소개한 테스트는 Mock 객체를 활용한 방식이었습니다. 큰 차이는 없지만 Mock 객체를 직접 생성하지 않고 @MockBean 어노테이션을 사용해 스프링 컨테이너에 Mock 객체를 주입받는 방식을 소개하겠습니다.
@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {
@MockBean
ProductRepository productRepository;
@Autowired
ProductService productService;
... 테스트 코드 생략 ...
}
동작을 설정하는 ProductRepository에 대한 초기화 작업을 어떻게 진행하는지를 비교하기 위한 코드입니다. 앞선 코드에서는 Mockito를 통해 리포지토리를 Mock 객체로 대체하는 작업을 수행하고 서비스 객체를 직접 초기화했습니다. 반면 위의 코드에서는 스프링에서 제공하는 테스트 어노테이션을 통해 Mock 객체를 생성하고 의존성 주입을 받고 있습니다.
둘의 차이는 스프링의 기능 의존 여부뿐입니다. 두 예제 모두 Mock 객체를 활용한 테스트 방식인 것은 동일하나 @MockBean을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입받는 형식이며 Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식입니다. 둘 다 테스트 속도에는 큰 차이는 없지만 아무래도 스프링을 사용하지 않는 Mock 객체를 직접 생성하는 방식이 더 빠르게 동작합니다.
스프링에서 객체를 주입받기 위해 @ExtendWith(SpringExtension.class)를 사용해 JUnit5의 테스트에서 스프링 테스트 컨텍스트를 사용하도록 설정합니다. 그리고 ProductService를 주입받기 위해 직접 클래스를 @Import 어노테이션을 통해 사용합니다.
7.4.8 리포지토리 객체의 테스트
리포지토리는 개발자가 구현하는 레이어 중에서 가장 데이터베이스와 가깝습니다. 리포지토리 객체는 JpaRepository를 상속받아 기본적인 퀴리 메서드를 사용할 수 있기 때문에 기본 메서드에 대한 테스트는 큰 의미가 없습니다.
데이터베이스의 연동 여부는 테스트 시 고려해 볼 사항입니다. 굳이 따지자면 데이터베이스는 외부 요인에 속합니다. 만약 단위 테스트를 고려한다면 데이터베이스를 제외할 수 있습니다.
혹은 테스트용으로 다른 데이터베이스를 사용하는 경우도 있습니다. 왜냐하면 데이터베이스를 사용한 테스트는 테스트 과정에서 데이터베이스에 테스트 데이터가 적재되기 때문입니다. 그렇기 때문에 데이터베이스를 연동한 테스트는 테스트 데이터를 제거하는 코드까지 포함해서 작성하는 것이 좋습니다. 다만 잘못된 테스트 코드가 실행되면서 발생할 수 있는 사이트 이펙트를 고려해서 데이터베이스 연동 없이 테스트하는 편이 좋을 수도 있습니다.
여기에서는 데이터베이스를 제외한 테스트 상황을 가정해서 테스트 데이터베이스로 H2 DB를 사용하는 방법을 간략하게 소개하고 기본 테스트 환경에서는 마리아 DB를 사용할 예정입니다.
먼저 H2 DB를 사용하려면 pom.xml에 의존성을 추가해야 합니다.
<dependencies>
...생략...
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
...생략...
</dependencies>
아래의 위치에 ProductRepositoryTestByH2.java 파일을 생성합니다.
데이터베이스에 값을 저장하는 테스트 코드는 아래와 같이 작성합니다.
@DataJpaTest
public class ProductRepositoryTestByH2 {
@Autowired
private ProductRepository productRepository;
@Test
void saveTest() {
// given
Product product = new Product(); // 테스트에서 사용할 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
public class ProductRepositoryTestByH2 {
@Autowired
private ProductRepository productRepository;
@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());
}
}
앞선 테스트에서는 H2 DB에서 실행되는 것을 확인할 수 있습니다. 기존에 사용하고 있던 마리아 DB에서 테스트하기 위해서는 별도의 설정이 필요합니다. 아래의 경로에 ProductRepositoryTest.java 파일을 생성하고 코드를 작성해 주세요.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void save() {
// 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());
}
}
@AutoConfigureTestDatabase 어노테이션은 replace 요소를 통해 값을 조정할 수 있습니다. replace의 기본값은 Replace.ANY이며, 이 경우 임베디드 메모리 데이터베이스를 사용합니다. 이 속성값을 Replace.NONE으로 변경하면 애플리케이션에서 실제로 사용하는 데이터베이스로 테스트가 가능합니다.
그리고 @SpringBootTest 어노테이션으로 테스트를 할 수 있습니다. 이 어노테이션을 활용하면 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요 없이 테스트가 가능합니다. 다만 테스트의 속도가 느린 단점이 있습니다.
코드 커버리지(code coverage)는 소프트웨어의 테스트 수준이 충분한지를 표현하는 지표 중 하나입니다. 테스트를 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용됩니다.
커버리지를 확인하기 위한 다양한 커버리지 도구 중 가장 보편적으로 사용되는 도구는 JaCoCo입니다. JaCoCo는 Java Code Coverage의 약자로, JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트합니다.
7.5.1 JaCoCo 플러그인 설정
Jacoco의 플러그인 설정은 pom.xml 파일에서 합니다. 우선 다음과 같이 의존성을 추가합니다.
<dependencies>
...생략...
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
</dependency>
...생략...
</dependencies>
기본적인 설정 형식은 다음과 같습니다.
<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>
<element>BUNDLE</element>
<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>을 통해 작성합니다.
설정할 수 있는 <goal>의 속성값은 다음과 같습니다.
JaCoCo에서 설정할 수 있는 Rule을 살펴보겠습니다. <configuration> 태그 안에 설정하며, 다양한 속성을 활용할 수 있습니다.
먼저 Element는 코드 커버리지를 체크하는 데 필요한 범위 기준을 설정합니다. 사용 가능한 속성은 총 6가지입니다.
값을 지정하지 않은 상태의 기본값은 BUNDLE입니다. BUNDLE은 Element를 기준으로 <limits> 태그 내 <counter>와 <value>를 활용해 커버리지 측정 단위와 방식을 설정합니다.
다음으로 Counter는 커버리지를 측정하는 데 사용하는 지표입니다. 사용할 수 있는 측정 단위는 6가지입니다.
마지막으로 Value 태그로는 커버리지 지표를 설정합니다. 측정한 커버리지를 어떤 방식으로 보여주는지 설정하며, 총 5가지 방식 중에서 선택할 수 있습니다.
예제에서는 limit이 각 Element 단위로 설정되어 있으며 패키지 번들 단위로 바이트코드 명령수를 기준으로 커버리지가 최소한 80% 달성하는 것을 limit으로 설정했습니다. 또한 메서드 단위로 전체 라인 수를 최대 50줄로 설정했습니다.
7.5.2 JaCoCo 테스트 커버리지 확인
JaCoCo 플러그인으로 테스트 커버리지를 측정하려면 메이븐의 테스트 단계가 선행돼야 합니다. JaCoCo는 test 단계 뒤에 있는 package 단계에서 실행할 수 있습니다.
package를 더블클릭해서 빌드를 진행하면 target 폴더 내에 site/jacoco 폴더가 생성됩니다. 만약 정상적으로 동작하지 않는다면 프로젝트 경로에 한글이 없는지 확인해야 합니다.
기본적으로 JaCoCo 리포트 파일은 HTML, CSV, XML 형식으로 제공되며 일반적으로 곧바로 보고서를 보기 위해서는 HTML 파일을 주로 이용합니다. 웹 브라우저를 통해 HTML 파일을 열면 다음과 같이 리포트 결과를 확인할 수 있습니다.
com.springboot.test.controller 패키지를 누르고 ProductController를 누르면 컨트롤러에 작성되어 있는 Method가 Element의 기준이 되고, 여기에서 메서드를 클릭하면 코드 레벨에서의 테스트 커버리지를 확인할 수 있습니다.
초록색은 테스트에서 실행되었다는 의미이고, 빨간색은 테스트 코드에서 실행되지 않은 라인을 의미합니다. 조건문의 경우 true와 false에 대한 모든 케이스가 테스트되었다면 초록색으로 표시되며, 둘 중 하나만 테스트되었다면 노란색으로 표시됩니다.
TDD란 'Test-Driven-Development'의 줄임말로 '테스트 주도 개발'이라는 의미를 가지고 있습니다. 테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식입니다.
7.6.1 테스트 주도 개발의 개발 주기
테스트 주도 개발에서는 총 3개의 단계로 개발 주기가 반복됩니다.
실패 테스트 작성: 실패하는 경우의 테스트 코드를 먼저 작성합니다. |
테스트를 통과하는 코드 작성: 테스트 코드를 성공시키기 위한 실제 코드를 작성합니다. |
리팩토링: 중복 코드를 제거하거나 일반화하는 리팩토링을 수행합니다. |
7.6.1 테스트 주도 개발의 효과
테스트 주도 개발에 따라 개발을 진행하면 다음과 같은 이점을 얻을 수 있습니다.
디버깅 시간 단축: 테스트 코드 기반으로 개발이 진행되기 때문에 문제가 발생했을 때 어디에서 잘못됐는지 확인하기가 쉽습니다. |
생산성 향상: 테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대한 피드백을 받기 때문에 리팩토링 횟수가 줄고 생산성이 높아집니다. |
재설계 시간 단축: 작성되어 있는 테스트 코드를 기반으로 코드를 작성하기 때문에 재설계가 필요할 경우 테스트 코드를 조정하는 것으로 재설계 시간을 단축할 수 있습니다. |
기능 추가와 같은 추가 구현이 용이: 테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성하기 때문에 목적에 맞는 코드를 작성하는 데 비교적 용이합니다. |
그러나 새로운 개발 방법에 적응하는 과정에서 발생하게 될 생산성 저하의 우려로 상용화되진 않았습니다. 그러나 대부분의 개발 조직에서 테스트 코드의 중요성에 대해서는 인정하는 분위기이므로 테스트 코드를 많이 작성해 보는 것을 권장합니다.
1. (TDD(테스트 주도 개발))이란 반복 테스트를 이용한 소프트웨어 개발 방법론으로서 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식입니다.
2. (단위 테스트)란 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식으로, 테스트 대상의 범위를 기준으로 하는 가장 작은 단위의 테스트 방법이고, (통합 테스트)란 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식입니다.
3. (Given-When-Then 패턴)이란 필요한 환경을 설정하는 단계, 테스트의 목적을 보여주는 단계, 테스트의 결과를 검증하는 단계로 나누어서 테스트 코드를 작성하는 방식입니다.
4. (F.I.R.S.T)는 테스트 코드를 작성하는 데 도움이 될 수 있는 5가지 규칙을 의미합니다. 대체로 단위 테스트에 적용할 수 있는 규칙입니다.
5. (JUnit)은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트와 통합 테스트를 위한 도구와 기능을 제공합니다.
6. (@BeforeEach)는 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의하는 어노테이션이고, (@AfterAll)은 테스트를 종료하면서 호출되는 메서드를 정의하는 어노테이션입니다.
7. (@MockBean)은 가짜 객체를 생성해서 주입하는 역할을 수행하는 어노테이션이고, (MockMvc)는 서블릿 컨테이너의 구동 없이 가상의 MVC 환경에서 모의 HTTP 서블릿을 요청하는 유틸리티 클래스입니다.
8. (JaCoCo)는 Java Code Coverage의 약자로, JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트합니다.
9. 리포지토리 객체의 테스트를 하려고 합니다. 책의 예제를 참고해 데이터베이스에 product 객체 값을 저장하는 테스트 코드를 작성해 주세요. 조건은 아래와 같습니다.
10. 서비스 객체의 테스트를 하려고 합니다. 책의 예제를 참고해 ProductServiceImpl 클래스의 getProduct 메서드를 테스트하는 테스트 코드를 작성해 주세요. 조건은 아래와 같습니다.
답
9.
@DataJpaTest
public class ProductRepositoryTestByH2 {
@Autowired
private ProductRepository productRepository;
@Test
void saveTest() {
// given
Product product = new Product();
product.setName("책");
product.setPrice(10000);
product.setStock(100);
// when
Product savedProduct = productRepository.save(product);
// then 일치하는지 비교
assertEquals(product.getName(), savedProduct.getName());
assertEquals(product.getPrice(), savedProduct.getPrice());
assertEquals(product.getStock(), savedProduct.getStock());
}
}
10.
@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})
public class ProductServiceTest {
@MockBean
ProductRepository productRepository;
@Autowired
ProductServiceImpl productService;
@BeforeEach
public void setUpTest() {
productService = new ProductServiceImpl(productRepository);
}
@Test
void getProductTest() {
// given
Product givenProduct = new Product();
givenProduct.setNumber(129L);
givenProduct.setName("반지");
givenProduct.setPrice(100);
givenProduct.setStock(10);
Mockito.when(productRepository.findById(123L))
.thenReturn(Optional.of(givenProduct));
// when
ProductResponseDto productResponseDto = productService.getProduct(123L);
// then
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);
}
}
[출처] 장정우, 『스프링 부트 핵심 가이드』, 위키북스(2022), p.159-207.
Corner Spring 2
Editor : 도담
[스프링2] 9장. 연관관계 매핑 (0) | 2023.12.01 |
---|---|
[스프링2] 8장. Spring Data JPA 활용 (0) | 2023.11.24 |
[스프링2] 6장. 데이터베이스 연동 (0) | 2023.11.10 |
[스프링2] 5-6장. API를 작성하는 다양한 방법 & 데이터베이스 연동 (0) | 2023.11.03 |
[스프링2] 1-4장. 스프링 부트란? & 개발에 앞서 알면 좋은 기초 지식 & 개발 환경 구성 & 스프링 부트 애플리케이션 개발하기 (0) | 2023.10.13 |