[스프링 3] 07 테스트 코드 작성하기
- 테스트 코드란 작성한 코드나 비즈니스 로직 자체를 테스트하기 위해 작성한 코드를 의미
7.1 테스트 코드를 작성하는 이유
- 개발 과정에서 문제를 미리 발견할 수 있습니다.
- 리팩토링의 리스크가 줄어듭니다. (코드 추가/수정 시)
- 애플리케이션을 가동해서 직접 테스트하는 것보다 테스트를 빠르게 진행할 수 있습니다.
- 작성자의 의도가 파악되어 하나의 명세 문서로서의 기능을 수행합니다. (협업 시 용이)
- 몇 가지 프레임워크에 맞춰 테스트 코드를 작성하면 좋은 코드를 생산할 수 있습니다.
- 코드가 작성된 목적을 명확하게 표현할 수 있으며, 불필요한 내용이 추가되는 것을 방지합니다.
7.2 단위 테스트와 통합 테스트
- 테스트 대상 범위를 기준으로 구분
- 단위 테스트 : 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식
- 통합 테스트 : 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식
7.2.1 단위 테스트의 특징
- 단위테스트는 테스트 대상의 범위를 기준으로 가장 작은 단위의 테스트 방식
- 일반적으로 메서드 단위로 테스트를 수행하며 메서드 호출을 통해 의도한 결과값이 나오는지 확인하는 수준으로 진행
- 단위 테스트는 모듈을 독립적으로 테스트
7.2.2 통합 테스트의 특징
- 통합테스트란 모듈을 통합하는 과정에서의 호환성 등을 포함해 애플리케이션이 정상적으로 동작하는지 확인하기 위해 수행하는 테스트 방식
- 통합 테스트는 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지 확인
- 단위 테스트와 통합 테스트의 차이
단위 테스트 | 통합 테스트 |
모듈을 독립적으로 테스트 | 여러 모듈을 함께 테스트해서 정상적인 로직 수행이 가능한지 확인 |
특정 모듈에 대한 테스트만 진행하기 때문에 데이터베이스나 네트워크 같은 외부 요인들을 제외하고 진행 |
외부 요인들을 포함하고 테스트를 진행하므로 애플리케이션이 온전히 동작하는지 테스트 (단, 테스트를 수행할 때마다 모든 컴포넌트가 동작해야 하기 때문에 테스트 비용이 커지는 단점 존재) |
7.3 테스트 코드를 작성하는 방법
- 'Given-When-Then' 패턴과 'F.I.R.S.T' 전략
7.3.1 Given-When-Then 패턴
- 테스트 코드를 표현하는 방식 중 하나
- 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성
- Given-When-Then 패턴은 간단한 테스트로 여겨지는 단위 테스트에서는 잘 사용하지 않음
(불필요하게 코드가 길어지기 때문) - '명세' 문서의 역할을 잘 수행한다는 측면에서 도움
◻️ Given
- 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계
- 테스트에 필요한 변수를 정의하거나 Mock 객체를 통해 특정 상황에 대한 행동을 정의
◻️ When
- 테스트의 목적을 보여주는 단계
- 실제 테스트 코드가 포함되며, 테스트를 통한 결괏값을 가져오게 됨
◻️ Then
- 테스트의 결과를 검증하는 단계
- 일반적으로 When 단계에 나온 결괏값을 검증하는 작업을 수행
- 결괏값이 아니더라도 이 테스트를 통해 나온 결과에서 검증해야 하는 부분이 있다면 Then 단계에 포함
7.3.2 좋은 테스트를 작성하는 5가지 속성 (F.I.R.S.T)
- F.I.R.S.T는 테스트 코드를 작성하는데 도움이 될 수 있는 5가지 규칙
- 대체로 단위 테스트에 적용할 수 있는 규칙
◻️ 빠르게(fast)
- 테스트가 느리면 코드를 개선하는 작업이 느려져 코드의 품질이 떨어질 수 있어 테스트는 빠르게 수행될 필요 O
- 테스트 속도에 절대적인 기준은 없지만 목적을 단순하게 설정해서 작성하거나
외부 환경을 사용하지 않는 단위 테스트를 작성하는 것 등을 빠른 테스트라고 할 수 있음
◻️ 고립된, 독립적(Isolated)
- 하나의 테스트 코드는 목적으로 여기는 하나의 대상에 대해서만 수행되어야 함
- 만약 하나의 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면
외부 요인으로 인해 테스트가 수행되지 않을 수 있음
◻️ 반복 가능한(Repeatable)
- 테스트는 어떤 환경에서도 반복 가능하도록 작성해야 함
- 테스트는 개발 환경의 변화나 네트워크의 연결 여부와 상관없이 수행되어야 함
◻️ 자가 검증(Self-Validating)
- 테스트는 그 자체만으로도 테스트의 검증이 완료되어야 함
- 테스트가 성공했는지 실패했는지 확인할 수 있는 코드를 함께 작성해야 함
(결과값과 기대값을 비교하는 작업을 코드가 아니라 개발자가 직접 확인하고 있다면 좋지 못한 테스트 코드)
◻️ 적시에(Timely)
- 테스트 코드는 테스트하려는 애플리케이션 코드를 구현하기 전에 완성되어야 함
(너무 늦게 작성된 테스트 코드는 정상적인 역할을 수행하기 어려움 & 문제 해결에 소모되는 개발 비용 ↑)
- 테스트 주도 개발의 원칙을 따르는 테스트 작성 규칙으로, 테스트 주도 개발 기반이 아니라면 생략 가능
7.4 JUnit을 활용한 테스트 코드 작성
- JUnit은 자바 언어에서 사용되는 대표적인 테스트 프레임워크로서 단위 테스트, 통합 테스트를 위한 도구 제공
- 어노테이션 기반의 테스트 방식 지원 => 몇 개의 어노테이션만으로 간편하게 테스트 코드 작성 가능
- JUnit을 활용하면 단정문(assert)을 통해 테스트 케이스의 기대값이 정상적으로 도출됐는지 검토할 수 있는 장점 존재
7.4.1 JUnit의 세부 모듈
- JUnit 5는 크게 Jupiter, Platform, Vintage의 세 모듈로 구성
- JUnit은 하나의 Platform 모듈을 기반으로 Jupiter와 Vintage 모듈이 구현체의 역할을 수행
◻️ JUnit Platform
- JVM에서 테스트를 시작하기 위한 뼈대 역할
- 테스트를 발견하고 테스트 계획을 생성하는 테스트 엔진의 인터페이스를 가지고 있음
- 테스트 엔진은 테스트를 발견하고 테스트를 수행하며, 그 결과를 보고하는 역할을 수행
- 각종 IDE와의 연동을 보조하는 역할 수행
◻️ JUnit Jupiter
- 테스트 엔진 API의 구현체를 포함
- JUnit 5에서 제공하고 Jupiter 기반의 테스트를 실행하기 위한 테스트 엔진을 가지고 있음
- Jupiter Engine은 Jupiter API를 활용해서 작성한 테스트 코드를 발견하고 실행하는 역할을 수행
◻️ JUnit Vintage
- JUnit 3, 4에 대한 테스트 엔진 API를 구현
- 기존에 작성된 JUnit 3, 4 버전의 테스트 코드를 실행할 때 사용되며 Vintage Engine을 포함하고 있음
7.4.2 스프링 부트 프로젝트 생성
- 의존성 추가
◻️ Developer Tools : Lombok, Spring Configuration Processor
◻️ Web : Spring Web
◻️ SQL : Spring Data JPA, Maria DB Driver
- ProductServiceImpl 클래스, Product 엔티티 클래스 수정 (코드 생략)
7.4.3 스프링 부트의 테스트 설정
- 스프링 부트는 테스트 환경을 쉽게 설정할 수 있게 spring-boot-starter-test 프로젝트를 지원
-> pom.xml 파일에 관련 의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
- 스프링 부트에서 제공하는 spring-boot-starter-test 라이브러리는 JUnit, Mockito, assertJ 등 다양한 테스트 도구 제공
◻️ JUnit 5 : 자바 애플리케이션의 단위 테스트를 지원
◻️ Spring Test & Spring Boot Test : 스프링 부트 애플리케이션에 대한 유틸리티와 통합 테스트를 지원
◻️ AssertJ : 다양한 단정문(assert)을 지원하는 라이브러리
◻️ Hamcrest : Matcher를 지원하는 라이브러리
◻️ Mockito : 자바 Mock 객체를 지원하는 프레임워크
◻️ JSONassert : JSON용 단정문 라이브러리
◻️ JsonPath : JSON용 XPath를 지원
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();
}
// Disabled Annotation : 테스트를 실행하지 않도록 하는 어노테이션
@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 호출 ##
- @BeforeEach와 @AfterAll 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행
- @BeforeEach와 @AfterEach 어노테이션이 지정된 메서드는 각 테스트가 실행될 때 @Test 어노테이션이 지정된 테스트 메서드를 기준으로 실행
- @Disabled 어노테이션이 지정된 테스트는 테스트 메서드로 인식은 되지만 실행되지 않음
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);
}
- ProductControlller는 ProductService의 객체를 의존성 주입받음
- ProductController만 테스트한다면 ProductService는 외부 요인에 해당 -> 독립적인 테스트를 위해 Mock 객체 활용
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; // MockMvc : 컨트롤러의 API를 테스트하기 위해 사용된 객체
// ProductController에서 잡고 있는 Bean 객체에 대해 Mock 형태의 객체를 생성해줌
@MockBean
ProductServiceImpl productService;
// getProduct() 메서드 테스트 코드
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
// given : Mock 객체가 특정 상황에서 해야하는 행위를 정의하는 메소드
// given() & willReturn()
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()) // json path의 depth가 깊어지면 .을 추가하여 탐색할 수 있음 (ex : $.productId.productIdName)
.andExpect(jsonPath("$.name").exists())
.andExpect(jsonPath("$.price").exists())
.andExpect(jsonPath("$.stock").exists())
.andDo(print()); // 요청과 응답의 전체 내용을 확인하기 위해 andDo() 사용
// verify() : 지정된 메서드가 실행됐는지 검증하는 역할(일반적으로 given()에 정의된 동작과 대응)
verify(productService).getProduct(123L);
}
}
- Mockito에서 제공하는 given() 메서드를 통해 이 객체에서 어떤 메서드가 호출되고 어떤 파라미터를 주입받는지 가정한 후 willReturn() 메서드를 통해 어떤 결과를 리턴할 것인지 정의하는 구조로 코드를 작성
◻️@WebMvcTest(테스트 대상 클래스.class)
- 웹에서 사용되는 요청과 응답에 대한 테스트 수행 가능
- 대상 클래스만 로드해 테스트를 수행
- 만약 대상 클래스를 추가하지 않으면 @Controller, @RestController, @ControllerAdvice 등의
컨트롤러 관련 빈 객체가 모두 로드
- @SpringBootTest보다 가볍게 테스트하기 위해 사용
◻️ @MockBean
- 실제 빈 객체가 아닌 Mock(가짜) 객체를 생성해서 주입하는 역할 -> 실제 행위를 수행하지 X
- 따라서 해당 객체는 개발자가 Mockito의 given() 메서드를 통해 동작을 정의해야 함
◻️ @Test
- 테스트 코드가 포함되어 있다고 선언하는 어노테이션
- JUnit Jupiter에서는 이 어노테이션을 감지해서 테스트 계획에 포함시킴
◻️ @DisplayName
- 테스트 메서드의 이름이 복잡해서 가독성이 떨어질 경우 이 어노테이션을 통해 테스트에 대한 표현 정의 가능
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
ProductResponseDto productResponseDto = productService.saveProduct(productDto);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
- createProduct() 메서드는 @RequestBody로 값을 받음
// createProduct() 메서드 테스트 코드 (getProduct() 테스트 코드와 유사)
@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")
.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(productService).saveProduct(new ProductDto("pen", 5000, 2000));
}
- pom.xml 파일에 Gson에 대한 의존성 추가 필요
- Gson은 구글에서 개발한 JSON 파싱 라이브러리로서 자바 객체를 JSON 문자열로 변환하거나 JSON 문자열을 자바 객체로 변환하는 역할
<Gson 의존성 추가>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
7.4.7 서비스 객체의 테스트
- 서비스 레이어에 해당하는 ProductService 객체 테스트
- 단위 테스트를 수행할 클래스는 test/java/com.springboot.test 내에 service/impl 패키지를 생성 후 ProductServiceTest 파일 생성
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()
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 어노테이션 선언 X
- any()는 Mockito의 ArgumentMatchers에서 제공하는 메서드로 Mock 객체의 동작을 정의하거나 검증하는 단계에서
조건으로 특정 매개변수의 전달을 설정하지 않고 메서드의 실행만을 확인하거나 좀 더 큰 범위의 클래스 객체를 매개변수로 전달받는 등의 상황에 사용
package com.springboot.test.service.impl;
import ...
// 스프링에서 객체를 주입받기 위해 스프링 테스트 컨텍스트를 사용하도록 설정
@ExtendWith(SpringExtension.class)
// @Autowired 어노테이션으로 주입받는 ProductService를 주입받기 위해 직접 클래스를 @Import 어노테이션을 통해 사용
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {
@MockBean
ProductRepository productRepository;
@Autowired
ProductService productService;
... 생략 ...
}
@MockBean 어노테이션을 사용한 테스트 환경 설정
- @MockBean을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입받는 형식
- Mockito.mock()을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식
7.4.8 리포지토리 객체의 테스트
- 리포지토리는 개발자가 구현하는 레이어 중에서 가장 데이터 베이스와 가까움 & JpaRepository를 상속받아 기본적인 쿼리 메서드 사용 가능
- findById(), save()와 같은 기본 메서드에 대한 테스트는 의미 X
- H2 DB를 사용하여 테스트 코드 작성
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<H2 DB 의존성 추가>
// @DataJpaTest 어노테이션 선언
@DataJpaTest
public class ProductRepositoryTestByH2 {
// @DataJpaTest 어노테이션 선언 -> 리포지토리 정상 주입 가능
@Autowired
private ProductRepository productRepository;
// Given-When-Then 패턴으로 작성
// 데이터베이스 저장 테스트 코드
@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());
}
// 데이터베이스 조회 테스트 코드
@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());
}
}
※ @DataJpaTest의 기능
◻️ JPA와 관련된 설정만 로드해서 테스트를 진행
◻️ 기본적으로 @Transactional 어노테이션을 포함해 테스트 코드가 종료되면 자동으로 데이터베이스의 롤백 진행
◻️ 기본값으로 임베디드 데이터 베이스를 사용 & 다른 데이터베이스를 사용하려면 별도의 설정을 거쳐 사용 가능
- Given 구문에서는 테스트에서 사용할 Product 엔티티 생성
- When 구문에서는 생성된 엔티티를 기반으로 save() 메서드를 호출해서 테스트를 진행
- 이후 정상적인 테스트가 이뤄졌는지 체크하기 위해 save() 메서드의 리턴 객체와 Given에서 생성한 엔티티 객체의 값이 일치하는지 assertEquals() 메서드를 통해 검증
@DataJpaTest
// replace 요소는 @AutoConfigureTestDatabase 어노테이션 값을 조정하는 작업 수행
@AutoConfigureTestDatabase(replace = 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());
}
}
<테스트 데이터베이스 변경을 위한 어노테이션 추가>
- 지금까지 다룬 @DataJpaTest를 사용하지 않고 @SpringBootTest 어노테이션으로도 테스트 가능
@SpringBootTest
public class ProductRepositoryTest2 {
@Autowired
ProductRepository productRepository;
@Test
public void basicCRUDTest() {
// given
Product givenProduct = Product.builder()
.name("노트")
.price(1000)
.stock(500)
.build();
// when
Product savedProduct = productRepository.save(givenProduct);
// then
Assertions.assertThat(savedProduct.getNumber())
.isEqualTo(givenProduct.getNumber());
Assertions.assertThat(savedProduct.getName())
.isEqualTo(givenProduct.getName());
Assertions.assertThat(savedProduct.getPrice())
.isEqualTo(givenProduct.getPrice());
Assertions.assertThat(savedProduct.getStock())
.isEqualTo(givenProduct.getStock());
// when
Product selectedProduct = productRepository.findById(savedProduct.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(), "장난감");
// when
productRepository.delete(updatedProduct);
// then
assertFalse(productRepository.findById(selectedProduct.getNumber()).isPresent());
}
}
- CRUD의 모든 기능을 한 테스트 코드에 작성
- @SpringBootTest 어노테이션을 활용하면 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요없이 테스트 가능
- 테스트 속도가 느리다는 단점 존재
7.5 JaCoCo를 활용한 테스트 커버리지 확인
- 코드커버리지란 소프트웨어의 테스트 수준이 충분한지 표현하는 지표
또는 테스트를 진행했을 때 대상 코드가 실행됐는지 표현하는 방법으로도 사용 - 커버리지를 확인하는 가장 보편적인 도구가 JaCoCo
- JaCoCo는 JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를 기준으로 한 커버리지로 리포트함
- JaCoCo는 런타임으로 테스트 케이스를 실행하고 커버리지를 체크하는 방식으로 동작
- 리포트는 HTML, XML, CSV 같은 다양한 형식으로 확인 가능
7.5.1 JaCoCo 플러그인 설정
- pom.xml 파일에 의존성 추가
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
</dependency>
- pom.xml 파일에 JaCoCo 플러그인 추가 (pom.xml 파일의 <build> 태그 안에 추가)
<configuration>
<excludes>
<exclude>**/ProductServiceImpl.class</exclude>
</excludes>
</configuration>
<configuration 설정>
- configuration 설정은 일부 클래스를 커버리지 측정 대상에서 제외하는 것을 의미
- 위의 코드에서는 경로와 무관하게 ProductServiceImpl.class를 커버리지 측정 대상에서 제외하도록 설정
<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>
<executions 설정>
- <execution>은 기본적으로 <goal>을 포함하며, 설정한 값에 따라 추가 설정이 필요한 내용을 <configuration>과 <rule>을 통해 작성
- <execution>에서 설정할 수 있는 <goal>의 속성값
◻️ 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 : 오프라인 측정 전 원본 파일을 저장하는 기능 수행
- JaCoCo의 Rule은 <configuration> 태그 안에 설정하며, 다양한 속성 활용 가능
- Element는 코드 커버리지를 체크하는데 필요한 범위 기준을 설정
◻️ BUNDLE(기본값) : 패키지 번들(프로젝트 내 모든 파일)
& Element를 기준으로 <limits> 태그 내 <counter>와 <value>를 활용해 커버리지 측정 단위와 방식을 설정
◻️ PACKAGE : 패키지
◻️ CLASS : 클래스
◻️ GROUP : 논리적 번들 그룹
◻️ SOURCEFILE : 소스 파일
◻️ METHOD : 메서드
- Counter는 커버리지를 측정하는데 사용하는 지표 (6가지 측정 단위 존재)
◻️ LINE : 빈 줄을 제외한 실제 코드의 라인 수
◻️ BRANCH : 조건문 등의 분기 수
◻️ CLASS : 클래스 수
◻️ METHOD : 메서드 수
◻️ INSTRUCTION(기본값) : 자바의 바이트 코드 명령 수
◻️ COMPLEXITY : 복잡도 (복잡도는 맥케이브 순환 복잡도 정의를 따름)
- value 태그로는 커버리지 지표를 설정 (측정한 커버리지를 어떤 방식으로 보여주는지 설정 & 5가지 방식 존재)
◻️ TOTALCOUNT : 전체 개수
◻️ MISSEDCOUNT : 커버되지 않은 개수
◻️ COVEREDCOUNT : 커버된 개수
◻️ MISSEDRATIO : 커버되지 않은 비율
◻️ COVEREDRATIO(기본값) : 커버된 비율
7.5.2 JaCoCo 테스트 커버리지 확인
- JaCoCo 플러그인으로 테스트 커버리지를 측정하려면 메이븐의 테스트 단계가 선행되어야 함
- 메이븐의 생명주기는 위의 그림과 같이 Maven 탭에서 확인 가능
- JaCoCo는 test 단계 뒤에 있는 package 단계에서 실행 가능
- package를 더블클릭해서 빌드를 진행하면 위와 같이 target 폴더 내 site → jacoco 폴더 생성
(동작되지 않을 시 프로젝트 경로에 한글이 없는지 확인)
- 기본적으로 JaCoCo 리포트 파일은 HTML, CSV, XML 형식으로 제공
(일반적으로 보고서를 바로 보기 위해 HTML 파일 이용) - 웹 브라우저를 통해 HTML 파일을 열면 위와 같이 리포트 결과를 확인 가능
<각 칼럼의 의미>
◻️ Element : 우측 테스트 커버리지를 측정한 단위를 표현 & 링크를 따라 들어가면 세부 사항을 볼 수 있음
◻️ Missed Instructions - Cov.(Coverage) : 테스트를 수행한 후 바이트코드의 커버리지를 퍼센티지와 바 형식 제공
◻️ Missed Branches - Cov.(Coverage) : 분기에 대한 테스트 커버리지를 퍼센티지와 바 형식으로 제공
◻️ Missed - Cxty(Complexity) : 복잡도에 대한 커버리지 대상 개수와 커버되지 않은 수를 제공
◻️ Missed - Lines : 테스트 대상 라인 수와 커버되지 않은 라인 수를 제공
◻️ Missed - Methods : 테스트 대상 메서드 수와 커버되지 않은 메서드 수를 제공
◻️ Missed - Classes : 테스트 대상 클래스 수와 커버되지 않은 메서드 수를 제공
- 각 코드 라인은 초록색과 빨간색으로 표시
- 초록색은 테스트에서 실행됐다는 의미
- 빨간색은 테스트 코드에서 실행되지 않은 라인을 의미
- 조건문의 경우 true와 false에 대한 모든 케이스가 테스트됐다면 초록색 표시, 둘 중 하나만 테스트됐다면 노란색 표시
7.6 테스트 주도 개발(TDD)
- 테스트 주도 개발은 반복 테스트를 이용한 소프트웨어 개발 방법론
- 테스트 코드를 먼저 작성한 후 테스트를 통과하는 코드를 작성하는 과정을 반복하는 소프트웨어 개발 방식
- 애자일 방법론 중 하나인 익스트림 프로그래밍(eXtreme Programming)의 Test-First 개념에 기반
- 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시
7.6.1 테스트 주도 개발의 개발 주기
- 실패 테스트 작성(Write a falling test) : 실패하는 경우의 테스트 코드를 먼저 작성
- 테스트 코드를 통과하는 코드 작성(Make a test pass) : 테스트 코드를 성공시키기 위한 실제 코드 작성
- 리팩토링(Refactor) : 중복 코드를 제거하거나 일반화하는 리팩토링을 수행
- 설계를 진행한 후 개발 코드를 작성하고 마지막으로 테스트 코드를 작성하는 흐름의 일반적인 개발 방법과 다르게
테스트 주도 개발에서는 설계 이후 바로 테스트 코드를 작성하고 개발 코드를 작성한다는 차이가 존재
7.6.2 테스트 주도 개발의 효과
- 디버깅 시간 단축 : 테스트 코드 기반으로 개발이 진행 -> 문제 발생 시 잘못된 부분을 확인하기 쉬움
- 생산성 향상 : 테스트 코드를 통해 지속적으로 애플리케이션 코드의 불안정성에 대한 피드백 -> 리팩토링 횟수가 줄고 생산성이 높아짐
- 재설계 시간 단축 : 작성되어 있는 테스트 코드를 기반으로 코드를 작성 -> 재설계가 필요할 경우 테스트 코드를 조정하는 것으로 재설계 시간 단축 가능
- 기능 추가와 같은 추가 구현 용이 : 테스트 코드를 통해 의도한 기능을 미리 설계하고 코드를 작성 -> 목적에 맞는 코드를 작성하는 데 용이
Quiz
- ( 단위 테스트 )는 애플리케이션의 개별 모듈을 독립적으로 테스트하는 방식이며, ( 통합 테스트 )는 애플리케이션을 구성하는 다양한 모듈을 결합해 전체적인 로직이 의도한 대로 동작하는지 테스트하는 방식이다.
- 테스트 코드를 표현하는 방식 중 하나로, 단계를 설정해서 각 단계의 목적에 맞게 코드를 작성하는 패턴을 ( Given-When-Then 패턴 ) 이라고 한다.
- JUnit의 생명주기에서 ( @BeforeEach )와 ( @AfterAll ) 어노테이션이 지정된 메서드는 전체 테스트 동작에서 처음과 마지막에만 각각 수행한다.
- ( @MockBean )을 사용하는 방식은 스프링에 Mock 객체를 등록해서 주입받는 형식이고
( Mockito.mock() )을 사용하는 방식은 스프링 빈에 등록하지 않고 직접 객체를 초기화해서 사용하는 방식이다. - CRUD의 모든 기능을 한 테스트 코드에 작성하며, 스프링의 모든 설정을 가져오고 빈 객체도 전체를 스캔하기 때문에 의존성 주입에 대해 고민할 필요없이 테스트 가능한 어노테이션은? 답 : @SpringBootTest
- Java Code Coverage의 약자로, JUnit 테스트를 통해 애플리케이션의 코드가 얼마나 테스트됐는지 Line과 Branch를기준으로 한 커버리지로 리포트하는 도구는? 답 : JaCoCo
- 반복 테스트를 이용한 소프트웨어 개발 방법론으로 개발 주기가 짧은 개발 프로세스로 단순한 설계를 중시하는
개발 방법론의 이름은? 답 : 테스트 주도 개발 (TDD)
코드 1)
컨트롤러 객체 테스트 시 getProduct()의 테스트 코드를 given()과 willReturn()을 사용하여 빈 코드를 작성하면?
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // MockMvc : 컨트롤러의 API를 테스트하기 위해 사용된 객체
@MockBean
ProductServiceImpl productService;
// getProduct() 메서드 테스트 코드
@Test
@DisplayName("MockMvc를 통한 Product 데이터 가져오기 테스트")
void getProductTest() throws Exception {
given(productService.getProduct(123L)).willReturn(
new ProductResponseDto(123L, "pen", 5000, 2000));
String productId = "123";
코드 2)
@MockBean 어노테이션을 사용한 테스트 환경 설정을 위해 작성해야하는 빈칸의 어노테이션 두 가지는?
... 생략 ...
@ExtendWith(SpringExtension.class)
@Import({ProductServiceImpl.class})
class ProductServiceTest2 {
@MockBean ProductRepository productRepository;
@Autowired ProductService productService;
... 생략 ...
}
[출처] 장정우, 『스프링 부트 핵심가이드 스프링 부트를 활용한 애플리케이션 개발 실무』, 위키북스(2022), p159-207.
ⓒ 다다