테스트 대상 결정: 왜 서비스 계층인가?
전형적인 Spring Boot 기반 MVC 아키텍처에서는 컨트롤러, 서비스, 데이터 접근(DAO) 세 계층으로 구성된다. 단위 테스트의 핵심 목표는 비즈니스 로직의 정확성 검증이므로, 가장 중요한 비즈니스 규칙이 포함된 서비스 계층(Service Layer)을 주 대상으로 삼는 것이 바람직하다.
- Controller: HTTP 요청 파싱 및 응답 변환 처리. 비즈니스 로직은 거의 없으므로 통합 테스트에서 다루는 것이 적절함.
- Service: 핵심 로직, 트랜잭션 제어, 여러 DAO 호출 조합 등이 위치. 단위 테스트로 철저히 검증해야 할 영역.
- DAO: 데이터베이스 쿼리 실행 담당. 이 계층 자체는 MyBatis 또는 JPA를 통해 충분히 검증되며, 서비스 테스트 시에는
mock처리하여 의존성을 차단함.
검증 방법: Assert를 이용한 결과 확인
테스트 성공 여부는 실제 출력값과 기대값의 일치 여부로 판단한다. 이를 위해 JUnit의 Assert 클래스를 사용하지만, 과도한 중간 검증은 가독성을 해칠 수 있으므로, 최종 결과에 대한 단일 포인트 검증을 권장한다.
@Test
void shouldReturnUpdatedEntityWhenModify() {
// given
String id = UUID.randomUUID().toString();
Product product = new Product(id, "노트북");
// when
Product result = productService.updateProduct(id, product);
// then
Assert.assertEquals("노트북", result.getName());
}
의존성 제어: Mockito를 활용한 Mocking 전략
서비스 계층의 단위 테스트에서는 외부 리소스(DB, 외부 API 등)에 의존하지 않도록 관련 객체를 모의(mock) 처리해야 한다. 주로 다음 두 가지 방식을 사용한다.
1. 반환값 모의 (Stubbing Return Values)
특정 메서드 호출 시 미리 정의된 값을 반환하도록 설정한다. 데이터베이스 조회 결과 등을 시뮬레이션할 때 유용하다.
@Test
void shouldReturnSavedProductAfterInsert() {
// given
Product input = new Product(UUID.randomUUID().toString(), "마우스");
when(productDao.save(any(Product.class))).thenReturn(1);
// when
Product saved = productService.createProduct(input);
// then
Assert.assertNotNull(saved.getKeyId());
}
2. 동작 모의 (Mocking Behavior)
반환값이 없는(void) 메서드나, 특정 동작이 발생했는지 검증하고 싶을 때 doAnswer 또는 doNothing을 사용한다. 아래 예제는 리스트 업데이트 시 인자로 전달된 내용을 검증하는 방식이다.
@Test
void shouldUpdateProductListWithCorrectItems() {
// given
ProductList list = new ProductList();
list.add(new Product(UUID.randomUUID().toString(), "키보드"));
doAnswer(invocation -> {
List<Product> arg = invocation.getArgument(0);
Assert.assertEquals(1, arg.size());
Assert.assertEquals("키보드", arg.get(0).getName());
return null;
}).when(productDao).batchUpdate(anyList());
// when
productService.updateProducts(list);
// then
// 검증은 doAnswer 내부에서 수행됨
}
테스트 케이스 설계: 분기 조건 커버리지 중심
단순히 한 메서드에 대해 하나의 테스트 케이스만 작성하면 부족하다. 중요한 것은 모든 조건 분기(branch)를 커버하는 것이다. 예를 들어 다음과 같은 메서드가 있다고 하자:
public boolean isValidOrder(Order order) {
if (order == null) return false;
if (order.getAmount() <= 0) return false;
if (!"KRW".equals(order.getCurrency())) return false;
return true;
}
이 경우 네 개의 분기 조건이 존재하므로, 최소한 다음 네 가지 테스트 케이스가 필요하다:
- 주문 객체가 null일 때 →
false반환 - 금액이 0 이하일 때 →
false반환 - 통화가 KRW가 아닐 때 →
false반환 - 모든 조건이 유효할 때 →
true반환
이러한 설계를 위해서는 각 메서드의 흐름도를 그려보고, 모든 종단 노드(end node)에 대해 테스트 케이스를 작성하는 것이 효과적이다.
실제 테스트 클래스 예제
다음은 반사(reflection) 기법을 사용해 서비스 객체 내부의 의존성을 직접 주입하고, Spring 컨텍스트 없이 순수 단위 테스트를 수행하는 예시이다. 이 방식은 테스트 실행 속도를 크게 향상시킨다.
public class ProductServiceTest {
private ProductService productService;
private ProductDao productDao;
@Before
public void setUp() throws Exception {
// 서비스 인스턴스 직접 생성
productService = new ProductService();
// DAO mock 생성
productDao = mock(ProductDao.class);
// 리플렉션을 통해 private 필드 주입
Field daoField = ProductService.class.getDeclaredField("productDao");
daoField.setAccessible(true);
daoField.set(productService, productDao);
}
@Test
public void createProduct_ShouldSetIdAndReturnInstance() {
when(productDao.save(any())).thenReturn(1);
Product input = new Product(null, "스피커");
Product result = productService.createProduct(input);
Assert.assertNotNull(result.getKeyId());
Assert.assertEquals("스피커", result.getName());
}
@Test
public void updateProductList_ShouldPassCorrectArgument() {
ProductList list = new ProductList();
list.add(new Product("P001", "모니터"));
doAnswer(invocation -> {
List<Product> args = invocation.getArgument(0);
Assert.assertEquals(1, args.size());
Assert.assertEquals("모니터", args.get(0).getName());
return null;
}).when(productDao).batchUpdate(anyList());
productService.updateProducts(list);
}
}
테스트 실행 및 커버리지 확인
테스트 클래스 작성 후 IDE(IntelliJ 또는 Eclipse)에서 실행할 수 있다. 특히 코드 커버리지 도구(JaCoCo 등)와 연동하면, 어떤 라인이 테스트되었는지 시각적으로 확인할 수 있다.
- IDE에서 "Run with Coverage" 옵션 선택
- 실행 후, 원본 클래스 파일에서 초록색은 커버된 코드, 빨간색은 미커버 코드를 의미
- 목표는 핵심 비즈니스 로직의 분기 조건에 대해 80% 이상의 커버리지를 확보하는 것
이러한 접근 방식을 통해 Spring Boot 애플리케이션의 신뢰성 있는 유지보수와 리팩터링이 가능해진다.