스프링부트 애플리케이션을 개발하면서 테스트 코드를 통해 우리가 개발한 코드를 어떻게 테스트할 수 있는지 알아보겠습니다.
📌 테스트 코드
작성한 코드나 비즈니스 로직 자체를 테스트하기 위해 작성한 코드
- 개발 과정에서 문제를 미리 발견할 수 있다.
- 리팩토링의 리스크가 줄어든다.
- 애플리케이션을 가동해서 직접 테스트하는 것보다 테스트를 빠르게 진행할 수 있다.
- 하나의 명세 문서로서의 기능을 수행한다.
- 몇 가지 프레임워크에 맞춰 테스트 코드를 작성하면 좋은 코드를 생산할 수 있다.
- 코드가 작성된 목적을 명확하게 표현할 수 있으며, 불필요한 내용이 추가되는 것을 방지한다.
테스트 대상 범위를 기준으로 테스트 방법을 구분하면 크게 단위 테스트와 통합 테스트.
📌 단위 테스트(Unit Test)
애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식
일반적으로 메서드 단위로 테스트를 수행하게 되며, 메서드 호출을 통해 의도한 결괏값이 나오는지 확인하는 수준으로 테스트를 진행한다. 단위 테스트는 테스트 비용이 적게 들기 때문에 테스트 피드백을 빠르게 받을 수 있다.
📌 통합 테스트(Integration Test)
애플리케이션을 구성하는 다양한 모듈을 결함해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식
단위 테스트는 특정 모듈에 대한 테스트만 진행하기 때문에 데이터베이스나 네트워크 같은 외부 요인들을 제외하고 진행하는 데 비해 통합 테스트는 외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 온전히 동작하는지를 테스트하게 된다. 다만 테스트를 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점이 있다.
Tip. 테스트 비용이란?
금전적인 비용을 포함해서 시간, 인력과 같은 개발에 필요한 것들을 포괄한다. 통계적으로 하나의 서비스를 개발할 때 개발 과정에서 60%, 테스트 과정에서 40%의 비용이 든다고 알려져 있다.
테스트 코드를 작성하는 방법은 다양하다. 그중 많은 사람들이 사용하는 'Given-When-Then' 패턴과 'F.I.R.S.T' 전략을 소개한다.
Given-When-Then 패턴
일반적으로 단위 테스트보다는 비교적 많은 환경을 포함해서 테스트하는 인수 테스트에 적합하다고 알려져 있다. 단위 테스트에서도 유용하게 활용할 수 있지만 불필요하게 코드가 길어질 수 있어 잘 사용하지 않는다. 이 패턴을 통해 테스트 코드를 작성하면 '명세' 문서의 역할을 잘 수행한다는 측면에서 많은 도움이 된다.
아래와 같이 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성한다.
Given
- 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계
- 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동 정의
When
- 테스트의 목적을 보여주는 단계
- 실제 테스트 코드가 포함되며, 테스트를 통한 결괏값을 가져오게 됨
Then
- 테스트의 결과를 검증하는 단계
- 일반적으로 When 단계에 나온 결괏값을 검증하는 작업 수행
- 결괏값이 아니더라도 해당 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 Then 단계에 포함
좋은 테스트를 작성하는 5가지 속성 (F.I.R.S.T)
F.I.R.S.T는 테스트 코드를 작성하는데 도움이 될 수 있는 5가지 규칙. 대체로 단위 테스트에 적용할 수 있다.
빠르게(fast)
- 테스트는 빠르게 수행되어야 함
- 테스트가 느리면 코드를 개선하는 작업이 느려져 코드의 품질이 떨어질 수 있음
- 테스트 속도에 절대적인 기준은 없지만 목적을 단순하게 설정해서 작성하거나 외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있음
고립된, 독립적(Isolated)
- 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 함
- 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 테스트가 수행되지 않을 수 있음
반복 가능한(Repeatable)
- 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 함
- 테스트는 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행되어야 함
자가 검증(Self-Validating)
- 테스트는 그 자체만으로도 테스트의 검증이 완료되어야 함
- 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야 함
만약, 결과값과 기대값을 비교하는 작업을 코드가 아니라 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드임
적시에(Timely)
- 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 함
너무 늦게 작성된 테스트 코드는 정상적인 역할을 수행하기 어려움, 문제 해결에 소모되는 개발 비용 커지기 쉬움
- 테스트 주도 개발의 원칙을 따르는 테스트 작성 규칙으로, 테스트 주도 개발 기반이 아니라면 해당 규칙은 제외하고 진행하기도 함
JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트, 통합 테스트를 위한 도구를 제공한다. 가장 큰 특징은 어노테이션 기반의 테스트 방식을 지원하기 때문에 몇 개의 어노테이션만으로 간편하게 테스트 코드를 작성할 수 있게 해준다. 또한 단정문(assert)을 통해 테스트 기댓값이 정상적으로 도출됐는지 검토할 수 있다는 장점이 있다.
JUnit 5는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성된다.
JUnit Platform
- JVM에서 테스트를 시작하기 위한 뼈대 역할
- 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진의 인터페이스를 가지고 있음
JUnit Jupiter
- 테스트 엔진 API의 구현체를 포함
- JUnit 5에서 제공하고 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있음
- Jupiter Engine은 Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행하는 역할 수행
JUnit Vintage
- JUnit 3, 4에 대한 테스트 엔진 API 구현
- 기존에 작성된 JUnit 3, 4 버전의 테스트 코드를 실행할 때 사용되며 Vintage Engine을 포함하고 있음
이처럼 JUnit은 하나의 Platform 모듈을 기반으로 Jupiter와 Vintage 모듈이 구현체의 역할을 수행한다.
프로젝트를 생성할 때 아래의 의존성을 추가한다.
Developer Tools : Lombok, Spring Configuration Processor
Web : Spring Web
SQL : Spring Data JPA, Maria DB Driver
6장에서 만들었던 프로젝트의 코드를 가져오되, 이번 장에서는 DAO 레이를 제외하고 진행한다. 이에 따라 ProductServiceImpl 클래스, Product 엔티티 클래스를 수정한다.
@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("[getProduct] 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] saveProduct : {}", 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 updateAt;
}
스프링 부트는 테스트 환경을 쉽게 설정할 수 있게 spring-boot-starter-test 프로젝트를 지원한다. 이 프로젝트를 사용하기 위해 pom.xml 파일에 의존성을 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
7.4.4 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
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
## AfterAll Annotation 호출 ##
컨트롤러는 클라이언트로부터 요청을 받아 요청에 걸맞는 서비스 컴포넌트로 요청을 전달하고 그 결과값을 가공해서 클라이언트에게 응답하는 역할을 수행한다. 즉, 애플리케이션을 구성하는 여러 레이어 중 가장 웹 가까이에 있는 모듈이다. ProductController를 대상으로 getProduct()와 createProduct() 메서드에 대한 테스트 코드 작성 예시를 살펴보자.
- ProductController의 getProduct() 메서드
@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);
}
... 생략
}
- getProduct() 메서드에 대한 테스트
package com.springboot.test.controller;
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 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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // 컨트롤러의 API를 테스트하기 위해 사용된 객체
@MockBean // -> ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
ProductServiceImpl productService;
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// Mock 객체에는 테스트 과정에서 맡을 동작을 정의해야 함 -> given() 메서드를 통해
이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해
어떤 결과를 리턴할 것인지 정의하는 구조로 코드 작성
// given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
given(productService.getProduct(123L)).willReturn(
new ProductResponseDto(123L, "pen", 5000, 2000));
String productId = "123";
// perform() 메서드 이용 -> 서버로 URL 요청을 보내는 것처럼 통신 테스트 코드를 작성해서 컨트롤러 테스트
// andExcept() 메서드 이용 -> perform() 메서드의 결과값으로 리턴된 ResultAction 객체 결과값 검증 수행 가능 수행
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() : 지정된 메서드가 실행됐는지 검증하는 역할(일반적으로 given()에 정의된 동작과 대응)
verify(productService).getProduct(123L);
}
}
- 예제에서 사용된 어노테이션
@WebMvcTest(테스트 대상 클래스.class)
- 웹에서 사용되는 요청과 응답에 대한 테스트 수행 가능
- 대상 클래스만 로드해 테스트 수행
- 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의 컨트롤러 관련 빈 객체가 모두 로드됨
- @SpringBootTest보다 가볍게 테스트하기 위해 사용
@MockBean
- 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할 -> 실제 행위를 수행하지 않음
- 따라서 해당 객체는 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 함
@Test
- 테스트 코드가 포함되어 있다고 선언하는 어노테이션
- JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킴
@DisplayName
- 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현 정의할 수 있음
- ProductController의 createProduct() 메서드
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
ProductResponseDto productResponseDto = productService.saveProduct(productDto);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
- createProduct() 메서드에 대한 테스트
@Test // getProduct() 테스크 코드와 유사한 구성
@DisplayName("Product 데이터 생성 테스트")
void createProductTest() throws Exception {
// Mock 객체에서 특정 메소드가 실행되는 경우 실제 Return을 줄 수 없기 때문에 아래와 같이 가정 사항을 만들어줌
// given() 메서드를 통해 메서드의 동작 규칙 설정
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") // 리소스 생성 기능을 테스트하기 때문에 post 메서드를 통해 URL 구성
.content(content) // @RequestBody의 값을 넘겨주기 위해 content() 메서드에 DTO의 값 담아서 테스트 진행
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").exists()) // jsonPath().exists() -> POST 요청을 통해 도출된 결과값에 대해 각 항목이 존재하는지 검증
.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 라이브러리 추가
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
이번에는 서비스 레이어에 해당하는 ProductService 객체를 테스트한다.
- getProduct() 메서드의 단위 테스트
package com.springboot.test.service.impl;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
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 java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
public class ProductServiceTest {
private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
// Mockito의 mock() 메서드를 통해 Mock 객체로 ProductRepository 주입받음
private ProductServiceImpl productService;
// 위의 Mock 객체를 기반으로 각 테스트 전 ProductService로 객체를 초기화해서 사용
@BeforeEach
public void setUpTest() {
productService = new ProductServiceImpl(productRepository);
}
@Test
void getProductTest() {
// given -> 테스트에 사용될 Product 엔티티 객체를 생성하고 ProductRepository의 동작에 대한 결과값 리턴 설정
Product givenProduct = new Product();
givenProduct.setNumber(123L);
givenProduct.setName("펜");
givenProduct.setPrice(1000);
givenProduct.setStock(1234);
Mockito.when(productRepository.findById(123L))
.thenReturn(Optional.of(givenProduct));
// when
ProductResponseDto productResponseDto = productService.getProduct(123L);
// then
// 테스트에서 리턴받은 ProductResponseDto 객체에 대해서 Assertion을 통해 값을 검증함으로 테스트 목적을 달성하는지 확인
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()로 부가 검증 시도
verify(productRepository).findById(123L);
}
@Test
void saveProductTest() {
// given
// any(Product.class)로 동작 설정, 일반적으로 given()으로 정의된 Mock 객체의 메서드 동작 감지는 매개변수의 비교를 통해 이뤄지나
레퍼런스 변수의 비교는 주솟값만으로 이뤄지기 때문에 any()를 사용해 클래스만 정의하는 경우도 있음
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());
}
}
리포지토리는 개발자가 구현하는 레이어 중에서 가장 데이터 베이스와 가깝다. 그리고 JpaRepository를 상속받아 기본적인 쿼리 메서드를 사용할 수 있다. 리포지터리의 기본 메서드는 테스트 검증을 마치고 제공된 것이기 때문에 findById(), save() 같은 기본 메서드에 대한 테스트는 의미가 없다. H2 DB를 사용하여 테스트 코드 작성해야 하기 때문에 pom.xml 파일에 의존성을 추가한다.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
- 데이터베이스 저장 테스트
@DataJpaTest
public class ProductRepositoryTestByH2 {
// @DataJpaTest 어노테이션 선언 -> 리포지토리 정상 주입 가능
@Autowired
private ProductRepository productRepository;
// Given-When-Then 패턴으로 작성
// 데이터베이스 저장 테스트 코드
@Test
void saveTest() {
// given - 테스트에서 사용할 Product 엔티티 만들고
Product product = new Product();
product.setName("펜");
product.setPrice(1000);
product.setStock(1000);
// when - 생성된 엔티티를 기반으로 save() 메서드를 호출해서 테스트 진행
Product savedProduct = productRepository.save(product);
// then - 정상적인 테스트가 이뤄졌는지 체크
assertEquals(product.getName(), savedProduct.getName());
assertEquals(product.getPrice(), savedProduct.getPrice());
assertEquals(product.getStock(), savedProduct.getStock());
}
📌 @DataJpaTest가 제공하는 기능
- JPA와 관련된 설정만 로드해서 테스트 진행
- 기본적으로 @Transactional 어노테이션을 포함하고 있어 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백 진행
- 기본값으로 임베디드 데이터베이스를 사용. 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능함
- 데이터베이스 조회 테스트
@DataJpaTest
public class ProductRepositoryTestByH2 {
// @DataJpaTest 어노테이션 선언 -> 리포지토리 정상 주입 가능
@Autowired
private ProductRepository productRepository;
// Given-When-Then 패턴으로 작성
// 데이터베이스 조회 테스트 코드
@Test
void selectTest() {
// given
Product product = new Product();
product.setName("펜");
product.setPrice(1000);
product.setStock(1000);
// 데이터베이스 조회 테스트를 위해 Given 절에서 객체를 데이터베이스에 저장
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());
}
}
pom.xml 파일에서 설정한다. (코드 생략)
help : jacoco-maven-plugin에 대한 도움말을 보여줌
prepare-agent : 테스트 중인 애플리케이션에 VM 인수를 전달하는 JaCoCo 런타임 에이전트의 속성을 준비, 에이전트는 maven-surefire-plugin을 통해 테스트한 결과를 가져오는 역할 수행
prepare-agent-integration : prepare-agent와 유사하지만 통합 테스트에 적합한 기본값을 제공
merge : 실행 데이터 파일 세트(.exec)를 단일 파일로 병합
report : 단일 프로젝트 테스트를 마치면 생성되는 코드 검사 보고서를 HTML, XML, CSV 중에서 선택 가능
report-aggregate : Reactor 내의 여러 프로젝트에서 구조화된 보고서(HTML , XML, CSV)를 생성, 보고서는 해당 프로젝트가 의존하는 모듈에서 생성
check : 코드 커버리지의 메트릭 충족 여부를 검사
dump : TCP 서버 모드에서 실행 중인 JaCoCo 에이전트에서 TCP/IP를 통한 덤프를 생성
instrument : 오프라인 측정을 수행하는 명령, 테스트를 실행한 후 restore-instrument-classes Goal로 원본 클래스 파일을 저장해야 함
restore-instrumented-class : 오프라인 측정 전 원본 파일을 저장하는 기능 수행
BUNDLE(기본값) : 패키지 번들(프로젝트 내 모든 파일), Element를 기준으로 <limits> 태그 내 <counter>와 <value>를 활용해 커버리지 측정 단위와 방식을 설정
PACKAGE : 패키지
CLASS : 클래스
GROUP : 논리적 번들 그룹
SOURCEFILE : 소스 파일
METHOD : 메서드
LINE : 빈 줄을 제외한 실제 코드의 라인 수
BRANCH : 조건문 등의 분기 수
CLASS : 클래스 수
METHOD : 메서드 수
INSTRUCTION(기본값) : 자바의 바이트 코드 명령 수
COMPLEXITY : 복잡도 (복잡도는 맥케이브 순환 복잡도 정의를 따름)
TOTALCOUNT : 전체 개수
MISSEDCOUNT : 커버되지 않은 개수
COVEREDCOUNT : 커버된 개수
MISSEDRATIO : 커버되지 않은 비율
COVEREDRATIO(기본값) : 커버된 비율, Value 속성을 지정하지 않는 경우의 기본값
Element : 우측 테스트 커버리지를 측정한 단위를 표현 & 링크를 따라 들어가면 세부 사항을 볼 수 있음
Missed Instructions - Cov.(Coverage) : 테스트를 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식 제공
Missed Branches - Cov.(Coverage) : 분기에 대한 테스트 커버리지를 퍼센티지와 바 형식으로 제공
Missed - Cxty(Complexity) : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수를 제공
Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수를 제공
Missed - Methods : 테스트 대상 메서드 수와 커버되지 않은 메서드 수를 제공
Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 메서드 수를 제공
Method가 Element의 기준이 되고, 메서드 클릭하면 코드 레벨에서의 테스트 커버리지 확인 가능
1. 각 필드에 알맞은 어노테이션을 추가하세요.
// 알맞은 어노테이션 추가
class ProductControllerTest {
// 알맞은 어노테이션 추가
private MockMvc mockMvc; // 컨트롤러의 API를 테스트하기 위해 사용된 객체
// 알맞은 어노테이션 추가
ProductServiceImpl productService;
// 알맞은 어노테이션 추가
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
given(productService.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(productService).getProduct(123L);
}
}
2. 각 주석에 들어갈 코드를 작성해 데이터베이스를 조회하는 코드를 완성하세요.
// 알맞는 어노테이션을 추가해 리포지터리 정상 주입
public class ProductRepositoryTestByH2 {
@Autowired
private ProductRepository productRepository;
// Given-When-Then 패턴으로 작성
// 데이터베이스 조회 테스트 코드
@Test
void selectTest() {
// given
Product product = new Product();
product.setName("펜");
product.setPrice(1000);
product.setStock(1000);
// 알맞은 코드를 작성해 데이터베이스 조회 테스트를 위해 Given 절에서 객체를 데이터베이스에 저장
// when
// 조회 메서드를 호출해서 테스트를 진행하고 이후 코드에서 데이터를 비교하며 검증 수행
// then
// 특정 메서드를 사용해 데이터 값 비교로 검증 수행
// 이름
// 가격
// 재고
}
}
QUIZ
단위 테스트, Given-When-Then 패턴, @BeforeEach, @WebMvcTest, @DataJpaTest, 코드커버리지, JaCoCo, 테스트 주도 개발(TDD)
PROGRAMMING QUIZ
1.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // 컨트롤러의 API를 테스트하기 위해 사용된 객체
@MockBean // -> ProductController가 의존성을 가지고 있던 ProductService 객체에 Mock 객체 주입
ProductServiceImpl productService;
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
given(productService.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()); // 요청과 응답의 전체 내용 확인할 수 있는 메서드
}
}
2.
@DataJpaTest
public class ProductRepositoryTestByH2 {
// @DataJpaTest 어노테이션 선언 -> 리포지토리 정상 주입 가능
@Autowired
private ProductRepository productRepository;
// Given-When-Then 패턴으로 작성
// 데이터베이스 조회 테스트 코드
@Test
void selectTest() {
// given
Product product = new Product();
product.setName("펜");
product.setPrice(1000);
product.setStock(1000);
// 데이터베이스 조회 테스트를 위해 Given 절에서 객체를 데이터베이스에 저장
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());
}
}
[출처] 장정우, 「스프링부트 핵심 가이드」 7장
Corner Spring 3
ⓒ Nini
[스프링 3팀] 13장. 서비스 인증과 권한 부여 (1) | 2024.01.05 |
---|---|
[스프링 3팀] 12. 서버 간 통신 & 섹션 0. 시큐리티 (0) | 2023.12.29 |
[스프링 3팀] 10장. 유효성 검사와 예외 처리 (2) | 2023.12.22 |
[스프링3] 9장 연관관계 매핑 (1) | 2023.12.01 |
[스프링3] 8장. Spring Data JPA 활용 (0) | 2023.11.24 |