테스트 코드는 매우 중요하며, 개발자에게 있어 필수적인 핵심 역량이다. 서비스 규모가 작을 때야 코드를 수정한 후 api 몇 개 직접 호출하면 금방 테스트할 수 있겠지만, 규모가 커져서 api가 수백 수천 개, 메서드가 수천 수만 개 된다면 반드시 테스트 코드가 필요할 것이다. 미리 잘 짜놓은 테스트 코드는, 배포하기 전 매번 테스트를 직접 수행하는 번거로움을 줄여주며 정확히 어디에서 오류가 발생했는지 쉽게 디버깅할 수 있게 해 준다. 개발이라는 분야에 있어서 기능의 구현 만큼이나 코드의 유지보수성과 확장성이 중요하다는 것을 생각한다면, 테스트 코드는 비즈니스 로직 만큼이나 중요하다고 해도 과언은 아닐 것이다.
스프링 부트 테스트 전략은 크게 [단위 테스트, 통합 테스트, E2E/기능 테스트] 로 나뉜다. 이 중에서 단위 테스트와 통합 테스트가 가장 큰 비중을 차지하며, 단위(70%) -> 통합(20%) -> E2E/기능(10%) 정도의 비중으로 나열된다. E2E/기능 테스트는 상대적으로 테스트 코드 작성이 복잡하기 때문에 중요한 비즈니스 프로세스에 한정해서 작성되므로, 단위 테스트와 통합 테스트에 대한 이해를 선행하는 것이 우선이라고 볼 수 있다.
1. 단위 테스트 (Unit Test)
단위 테스트는 애플리케이션을 구성하는 가장 작은 단위의 기능을 독립적으로 테스트 하는 방법이다. 여기서 "단위"란 보통 하나의 클래스나 메서드를 의미한다. 단위 테스트의 핵심은 외부 의존성을 제거하고 해당 기능만 격리해서 테스트한다는 점이다.
단위 테스트의 장점
- 빠른 실행 속도 (수 밀리초 ~ 수백 밀리초)
- 특정 기능의 논리적 오류를 정확히 발견 가능
- 코드 수정/리팩토링 후에도 기능이 제대로 동작하는지 확인 가능
스프링 부트에서의 단위 테스트 구현
스프링 부트에서 단위 테스트를 작성할 때는 주로 JUnit과 Mockito를 사용한다.
예시 코드
간단한 상품 관리 시스템을 예로 들어보겠다.
1) 도메인 모델
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private int stockQuantity;
// 생성자, getter, setter 생략
public void decreaseStock(int quantity) {
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
this.stockQuantity = restStock;
}
}
2) 리포지토리
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findByName(String name);
List<Product> findByPriceGreaterThan(BigDecimal price);
}
3) 서비스
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Product createProduct(String name, BigDecimal price, int stockQuantity) {
Product product = new Product();
product.setName(name);
product.setPrice(price);
product.setStockQuantity(stockQuantity);
return productRepository.save(product);
}
public Product getProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다. ID: " + id));
}
public void decreaseStock(Long productId, int quantity) {
Product product = getProduct(productId);
product.decreaseStock(quantity);
productRepository.save(product);
}
public List<Product> getExpensiveProducts(BigDecimal priceThreshold) {
return productRepository.findByPriceGreaterThan(priceThreshold);
}
}
4) 서비스 단위 테스트
단위 테스트에서는 ProductRepository 같은 외부 의존성을 실제로 사용하지 않고, Mockito를 이용해 목(mock, 가짜) 객체로 대체한다. 이것을 모킹(mocking) 이라고 하며, mock으로 대체된 객체는 해당 객체의 메서드를 호출해도 null 또는 의미없는 값을 리턴하게 된다.
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
/** 외부 의존성을 mock으로 대체 */
@Mock
private ProductRepository productRepository;
/**
* 테스트 대상은 mock으로 대체되지 않음
* @InjectMocks 모킹된 외부 의존성을 주입 ( 여기서는 productRepository )
*/
@InjectMocks
private ProductService productService;
@Test
@DisplayName("상품 생성 테스트")
void createProduct() {
// given
/** 테스트에 필요한 데이터 세팅 */
String name = "테스트 상품";
BigDecimal price = new BigDecimal("10000");
int stockQuantity = 100;
Product product = new Product();
product.setId(1L);
product.setName(name);
product.setPrice(price);
product.setStockQuantity(stockQuantity);
/** productRepository.save 호출 시 product 를 리턴하도록 설정 */
when(productRepository.save(any(Product.class))).thenReturn(product);
// when
/** 테스트 대상 메서드 호출 */
Product createdProduct = productService.createProduct(name, price, stockQuantity);
// then
/**
* 검증
* 테스트 결과가 null이 아니고 필드가 기댓값과 일치하는지
*/
assertNotNull(createdProduct);
assertEquals(name, createdProduct.getName());
assertEquals(price, createdProduct.getPrice());
assertEquals(stockQuantity, createdProduct.getStockQuantity());
/** save() 메서드가 단 한 번만 호출되었는지 */
verify(productRepository, times(1)).save(any(Product.class));
}
@Test
@DisplayName("상품 조회 - 존재하는 ID")
void getProductWithExistingId() {
// given
Long productId = 1L;
Product product = new Product();
product.setId(productId);
product.setName("테스트 상품");
when(productRepository.findById(productId)).thenReturn(Optional.of(product));
// when
Product foundProduct = productService.getProduct(productId);
// then
assertNotNull(foundProduct);
assertEquals(productId, foundProduct.getId());
verify(productRepository, times(1)).findById(productId);
}
@Test
@DisplayName("상품 조회 - 존재하지 않는 ID")
void getProductWithNonExistingId() {
// given
Long productId = 99L;
when(productRepository.findById(productId)).thenReturn(Optional.empty());
// when & then
/** 존재하지 않는 ID 조회 시 적절한 예외를 뱉는지 */
assertThrows(EntityNotFoundException.class, () -> {
productService.getProduct(productId);
});
verify(productRepository, times(1)).findById(productId);
}
}
단위 테스트는 위와 같이 정상적인 흐름과 예외 상황을 검증하는 테스트 메서드를 작성한다. 모킹된 객체의 메서드는 의미없는 값을 반환하므로, when() 메서드를 이용해 임의의 값을 리턴하도록 설정한다.
테스트는 일반적으로 given when then 패턴을 이용한다.
given( 이런 상황이 주어졌을 때 ) -> 전제조건 ( 테스트 데이터 및 환경 세팅 )
when( 이런 행동을 하면 ) -> 검증할 대상
then( 이런 결과가 나오는가? ) -> 예상하는 결과가 나왔는지 검증
구체적인 흐름은 아래와 같다.
given - 테스트 데이터 셋팅 및 when() 을 이용한 모킹된 객체 리턴값 설정
when - 검증 대상 메서드 호출
then - 검증 대상 메서드의 결과가 기댓값과 일치하는지, assert와 verify를 이용해 검증
그런데 의문이 들 수 있다. 특히 상품 생성 테스트 createProduct() 같은 테스트를 보면 말이다.
이런 정상 로직을 검증하는 테스트 코드는, 검증 대상 메서드가 반드시 답을 내놓도록 해 놓고 그 답을 검증한다. 이게 의미가 있을까? 위 코드에서 createProduct() 서비스 메서드에서 호출되는 메서드는 save() 이다. 그런데 when() 으로 save()의 리턴값을 우리가 만든 product 객체로 이미 정해 놓았다. 즉 무슨 짓을 해도 save() 는 product를 반환할 것이고, 그걸 또 then 에서 검증하고 있다. 뭔가 무의미해 보인다. 그러나 무의미하지 않다.
단위 테스트 결과를 정해놓고 검증하는 것이 무의미하지 않은 이유
- 테스트라는 건 검증 대상 메서드를 말 그대로 "검증" 하는데 의의가 있지만, "보장" 의미도 있다. 누가 봐도 정상동작 할 것 같이 생긴 코드도 돌려보기 전 까지는 모른다. 미리 답을 정해 놓고 결과가 뻔히 보이는 테스트를 돌리는 것은 무의미한 짓이 아니라 동작을 "보장" 하고 "증명" 하는 데에도 의의가 있는 것이다.
- 확장성 측면의 관점도 있다. 특히 단순한 비즈니스 로직을 가진 메서드일 경우 테스트가 더욱 무의미해 보일 수 있는데, 외부 의존성을 제외했을 때 검증 대상 메서드 자신만의 특별한 비즈니스 로직이 존재하지 않는 경우가 그렇다. 그러나 그 메서드가 영원히 변경되지 않을까? 그럴 거란 보장은 없다. 언제든 그 메서드는 수정될 수 있고 자신만의 비즈니스 로직이 추가될 수 있다. 따라서 미리 테스트 코드를 작성해 놓으면, 추후 메서드가 수정되었을 때 제대로 동작하는지 쉽게 확인할 수 있다.
2. 통합 테스트 (Integration Test)
통합 테스트는 여러 컴포넌트가 함께 작동할 때 제대로 동작하는지 확인하는 테스트이다. 스프링 부트에서는 주로 여러 계층(컨트롤러, 서비스, 리파지터리) 가 함께 작동하는 것을 테스트한다. 단위 테스트가 모킹하는 것과 달리 실제 컴포넌트를 사용하며, 테스트용 DB(H2 인메모리 DB)를 활용한다.
통합 테스트의 장점
- 실제 애플리케이션 동작과 유사한 환경에서 테스트
- 컴포넌트 간 상호작용 문제 발견
- DB 관련 이슈 조기 발견
H2 데이터베이스 사용
실제 환경에서는 MySQL이나 다른 DB를 사용하겠지만, 테스트 환경에서는 H2 인메모리 DB 를 활용한다. H2는 외부 DB 의존성 없이 테스트 실행이 가능하며, 매우 빠르고 각 테스트마다 독립적인 DB 환경을 제공한다. 또한 별도 설치나 복잡한 구성 없이 의존성만으로 간단히 사용할 수 있다. H2 DB를 사용하더라도 개발 단계 단위/통합 테스트에서는 충분히 효과적이다. 그러나 실제 prod 환경과는 약간의 차이가 있고 성능 테스트를 하기에도 부적합하기 때문에 이런 경우엔 실제 DB를 연동하는 것이 좋다.
H2 데이터베이스 설정
H2 DB를 사용하기 위해서 의존성 추가와 application-test.properties 를 생성하고 테스트 환경 설정을 해야 하지만, 이미 했다고 가정하겠다.
예시 코드
리포지토리, 서비스, 컨트롤러 대상으로 각각 통합 테스트 코드를 작성했다.
1) 리포지토리 통합 테스트 ( 리포지토리 + DB 통합 테스트 )
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
@DisplayName("상품명으로 조회")
void findByName() {
// given
/** 테스트 데이터 생성 및 저장 */
String productName = "테스트 상품";
Product product = new Product();
product.setName(productName);
product.setPrice(new BigDecimal("10000"));
product.setStockQuantity(100);
productRepository.save(product); // DB에 저장
// when
/** 조회 쿼리 실행 */
Optional<Product> foundProduct = productRepository.findByName(productName);
// then
/** 실제로 저장하고 조회한 값을 검증 */
assertTrue(foundProduct.isPresent());
assertEquals(productName, foundProduct.get().getName());
}
@Test
@DisplayName("가격으로 상품 필터링")
void findByPriceGreaterThan() {
// given
BigDecimal threshold = new BigDecimal("15000");
Product product1 = new Product();
product1.setName("저가 상품");
product1.setPrice(new BigDecimal("10000"));
product1.setStockQuantity(100);
Product product2 = new Product();
product2.setName("고가 상품");
product2.setPrice(new BigDecimal("20000"));
product2.setStockQuantity(50);
productRepository.saveAll(Arrays.asList(product1, product2));
// when
/** threshold(15000) 가격보다 비싼 상품 조회 */
List<Product> expensiveProducts = productRepository.findByPriceGreaterThan(threshold);
// then
assertEquals(1, expensiveProducts.size());
assertEquals("고가 상품", expensiveProducts.get(0).getName());
}
}
2) 서비스 통합 테스트 ( 서비스 + 리포지토리 2계층 통합 테스트 )
@SpringBootTest
@Transactional
public class ProductServiceIntegrationTest {
@Autowired
private ProductService productService;
/** 모킹하지 않는다 */
@Autowired
private ProductRepository productRepository;
@Test
@DisplayName("상품 생성 통합 테스트")
void createProduct() {
// given
String name = "통합 테스트 상품";
BigDecimal price = new BigDecimal("15000");
int stockQuantity = 30;
// when
/** DB에 저장 */
Product createdProduct = productService.createProduct(name, price, stockQuantity);
// then
assertNotNull(createdProduct.getId());
assertEquals(name, createdProduct.getName());
assertEquals(price, createdProduct.getPrice());
assertEquals(stockQuantity, createdProduct.getStockQuantity());
/** DB에서 직접 조회해서 확인 */
Optional<Product> foundProduct = productRepository.findById(createdProduct.getId());
assertTrue(foundProduct.isPresent());
assertEquals(name, foundProduct.get().getName());
}
}
3) 컨트롤러 통합 테스트 ( 3계층 통합 테스트 )
@SpringBootTest
@AutoConfigureMockMvc
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ProductRepository productRepository;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
productRepository.deleteAll();
}
@Test
@DisplayName("상품 생성 API 테스트")
void createProduct() throws Exception {
// given
/** 요청 DTO 제작 */
CreateProductRequest request = new CreateProductRequest();
request.setName("API 테스트 상품");
request.setPrice(new BigDecimal("25000"));
request.setStockQuantity(40);
// when & then
/** 요청 DTO 기반으로 api 호출과 동시에 결과 검증 */
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value(request.getName()))
.andExpect(jsonPath("$.price").value(25000))
.andExpect(jsonPath("$.stockQuantity").value(40));
/** product 1개가 생성되었는지 repository에서 직접 검증 */
assertEquals(1, productRepository.count());
}
}
3개의 통합 테스트를 보면, 의존성을 모킹하지 않고 직접 사용하여 실제로 DB에 접근하고 있다.
각 통합 테스트가 거치는 계층은 아래와 같다.
리포지토리 통합 테스트 -> 리포지토리 + DB
서비스 통합 테스트 -> 서비스 + 리포지토리
컨트롤러 통합 테스트 (3계층 통합 테스트) -> 컨트롤러 + 서비스 + 리포지토리
의존성을 모킹하지 않고 여러 컴포넌트를 거치는 테스트를 통합 테스트라고 하기 때문에, 위 테스트 코드들은 전부 통합 테스트라고 말할 수 있다. 컨트롤러가 가장 넓은 범위의 계층을 테스트하는 범용적인 통합 테스트라고 한다면, 리포지토리는 DB와의 상호작용만을 검증하는 비교적 좁은 범위의 통합 테스트라고 할 수 있다.
그렇다면 어떤 게 좋은 것일까? 좁게 하는 것일까, 넓게 하는 것일까?
넓은 계층 통합 테스트( ex: 컨트롤러 통합 테스트 )
장점 : 실제 사용자 시나리오와 유사한 흐름을 테스트하기 때문에 신뢰성이 높으며, 계층 간 상호작용 및 통합 문제를 발견할 수 있다.
단점 : 테스트 코드 작성이 복잡하고 외부 의존성이 많아 격리가 어려우며, 실패 시 정확한 문제 지점을 파악하기 어렵다.
좁은 계층 통합 테스트( ex: 리포지토리 통합 테스트 )
장점 : 테스트 코드 작성이 단순해 유지보수가 쉽고 엣지 케이스 등 꼼꼼한 테스트가 가능하다. 또한 실패 원인을 정확히 찾기 쉽다.
단점 : 계층 간 상호작용 및 통합 문제를 발견하기 어렵고, 실제 사용자 시나리오를 완벽히 커버하지 못 한다.
각 방식마다 장단점이 있으므로 무엇 하나를 선택하기 보다는, 상황에 따라 두 방식 모두를 적절히 선택하는 것이 중요하다.
테스트 작성 우선순위
1. 서비스 계층, 도메인 모델을 대상으로 단위 테스트를 작성
2. 리포지토리(특히 커스텀 쿼리가 있는) 대상으로 좁은 통합 테스트 작성
3. 주요 API 엔드포인트가 존재하는 컨트롤러 대상으로 넓은 통합 테스트 작성
=============
아래는 내 개인 프로젝트 썬카에서 단위/통합 테스트 코드를 구현한 과정을 담은 포스팅이다.
https://yskisking.tistory.com/320
[썬카/백엔드] 서비스 계층 및 커스텀 리파지터리 테스트 코드 작성, 팩토리/빌더 클래스 설계와
썬카의 기능이 조금씩 많아지니, 코드를 수정할 때마다 매번 Postman으로 api를 호출해보는 나를 발견할 수 있었다. 이 작업이 번거롭다고 느껴지는 순간, 테스트 코드를 도입할 때가 왔다고 느꼈
yskisking.tistory.com
'Development > Spring Boot' 카테고리의 다른 글
JPA 쿼리 최적화 - Lazy loading, fetchJoin(), BatchSize (0) | 2025.04.08 |
---|---|
스프링 입문 - 회원 서비스 테스트 (0) | 2023.11.07 |
스프링 입문 - 회원 서비스 개발 (0) | 2023.11.07 |
스프링 입문 - 회원 리포지토리 테스트 케이스 작성 (0) | 2023.11.01 |
스프링 입문 - 회원 도메인과 리포지토리 만들기 (2) | 2023.10.29 |