- 견고한 백엔드 시스템을 구축하기 위해서는 코드를 작성하는 것만큼이나 이를 검증하는 과정이 중요합니다. 이번 글에서는 주문 도메인의 복잡한 비즈니스 로직과 수많은 예외(Edge Case) 상황들을 Mock 객체를 활용하여 어떻게 독립적으로 검증했는지 회고해 봅니다.
💻 실제 코드 (OrderServiceTest.java - 주요 시나리오 발췌)
@ExtendWith(MockitoExtension.class) // 스프링 컨텍스트 로딩 없이 순수 Mockito 환경으로 실행함.
public class OrderServiceTest {
@InjectMocks
private OrderServiceV1 orderService; // 테스트 대상이 되는 진짜 서비스 객체
// 외부 의존성(DB)을 배제하기 위해 Repository들을 가짜 객체(Mock)로 주입함.
@Mock private OrderRepository orderRepository;
@Mock private StoreRepository storeRepository;
@Mock private UserRepository userRepository;
// ... 생략
private UUID userId;
private UserEntity mockUser;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
mockUser = UserEntity.builder().email("test@example.com").role(UserRole.CUSTOMER).build();
// JPA Auditing이나 로직에 의해 생성되는 불변 ID 값을 리플렉션으로 강제 주입함.
ReflectionTestUtils.setField(mockUser, "id", userId);
ReflectionTestUtils.setField(mockUser, "isDeleted", false);
}
/**
* [보안 재검증] validateUserRoleFromDB 검증
*/
@Test
@DisplayName("[보안 실패] DB 권한 재검증 - 토큰의 Role과 실제 DB의 Role이 다를 경우 차단")
void security_Fail_AuthorityMismatch() {
ReflectionTestUtils.setField(mockUser, "userRole", UserRole.MASTER); // DB 상의 권한을 MASTER로 세팅
given(userRepository.findById(userId)).willReturn(Optional.of(mockUser));
// 토큰 권한은 CUSTOMER인데 DB 권한은 MASTER이므로 차단되어야 함을 검증.
assertThrows(CustomException.class, () ->
orderService.createOrder(
ReqCreateOrderDtoV1.builder().build(),
userId,
UserRole.CUSTOMER
)
);
}
/**
* [성공 케이스] 사장님(OWNER)의 주문 상태 업데이트 순차 흐름 테스트
*/
@Test
@DisplayName("[성공] 상태 업데이트 흐름 - 사장님이 비즈니스 규칙에 맞게 단계를 하나씩 진행")
void updateOrderStatus_FullFlow_ByOwner() {
// ... Given 데이터 세팅 생략 ...
// When & Then (상태 머신이 순차적으로 정상 작동하는지 모두 검증함)
orderService.updateOrderStatus(
order.getOrderId(),
OrderStatus.ACCEPTED,
ownerId,
UserRole.OWNER
);
assertThat(order.getStatus()).isEqualTo(OrderStatus.ACCEPTED);
orderService.updateOrderStatus(
order.getOrderId(),
OrderStatus.COOKING,
ownerId,
UserRole.OWNER
);
assertThat(order.getStatus()).isEqualTo(OrderStatus.COOKING);
// ... DELIVERED, COMPLETED 검증 생략 ...
}
/**
* [비즈니스 방어] 상태에 따른 수정 불가 검증
*/
@Test
@DisplayName("[비즈니스 실패] 요청사항 수정 차단 - 이미 조리 중인 주문은 수정할 수 없음")
void updateOrderRequest_Fail_WhenAlreadyCooking() {
// ... Given 데이터 세팅 생략 ...
OrderEntity cookingOrder = OrderEntity.builder()
.userId(userId)
.status(OrderStatus.COOKING)
.build();
given(orderRepository.findById(any()))
.willReturn(Optional.of(cookingOrder));
// PENDING 상태가 아니므로 커스텀 예외(ORDER_REQUEST_UPDATE_FAILED)가 발생하는지 검증함.
CustomException ex = assertThrows(CustomException.class, () ->
orderService.updateOrderRequest(
UUID.randomUUID(),
"지금이라도 오이 빼주세요",
userId,
UserRole.CUSTOMER
)
);
assertThat(ex.getErrorCode())
.isEqualTo(ErrorCode.ORDER_REQUEST_UPDATE_FAILED);
}
/**
* [시간 제약 룰 테스트] 5분 이내 취소 로직 검증
*/
@Test
@DisplayName("[실패] 주문 취소 - CUSTOMER가 5분을 초과한 경우 취소 실패 (요구사항)")
void cancelOrder_Fail_TimeExceeded() {
// ... Given 데이터 세팅 생략 ...
OrderEntity order = OrderEntity.builder()
.userId(userId)
.status(OrderStatus.PENDING)
.build();
// [핵심] ReflectionTestUtils를 활용하여 주문 생성 시간을 현재 시간 기준 '6분 전'으로 강제 조작함.
ReflectionTestUtils.setField(
order,
"createdAt",
LocalDateTime.now().minusMinutes(6)
);
given(orderRepository.findById(any()))
.willReturn(Optional.of(order));
// When & Then: 5분이 넘었으므로 시간 초과 예외가 발생하는지 검증함.
CustomException ex = assertThrows(CustomException.class, () ->
orderService.cancelOrder(
UUID.randomUUID(),
userId,
UserRole.CUSTOMER
)
);
assertThat(ex.getErrorCode())
.isEqualTo(ErrorCode.ORDER_CANCEL_TIME_EXCEEDED);
}
}
🤔 왜 이렇게 짰는가? (설계 의도와 이유)
순수 단위 테스트(Unit Test)를 통한 검증 속도와 격리성 확보
- 애플리케이션의 모든 계층을 메모리에 올리는 통합 테스트(@SpringBootTest)는 DB 의존성이 높아 실행 속도가 느립니다. 이를 개선하기 위해 @ExtendWith(MockitoExtension.class)와 Mock 객체를 활용했습니다. @ExtendWith(MockitoExtension.class)는 Mockito 기반 테스트 환경을 활성화하여 Mock 객체를 자동으로 주입해주는 역할을 합니다. Mock 객체는 실제 DB나 외부 의존성 대신 동작을 흉내 내는 가짜 객체입니다.
- 이를 통해 스프링 컨텍스트 로딩이나 데이터베이스 연결 없이 서비스 계층의 순수한 비즈니스 로직만을 가볍고 빠르게 검증하도록 테스트 환경을 격리했습니다.
예외(Edge Case) 중심의 방어 시나리오 구축
- 주문 도메인은 결제와 맞닿아 있어 정상 작동(Happy Path)보다 실패 상황에 대한 대비가 훨씬 중요합니다. 따라서 권한불일치, 최소 주문 금액 미달, 상태 전이 규칙 위반, 타인 주문 조회 등 발생 가능한 다양한 예외 상황(Fail Path)을 개별 시나리오로 상정하고, 의도한 CustomException이 정확히 발생하는지 검증하는 데 테스트의 초점을 맞추었습니다.
Reflection을 활용한 상태 조작
- JPA @PrePersist나 Auditing에 의해 자동 생성되는 시간 필드(createdAt)는 시스템에 의해 관리되므로 테스트 환경에서 임의로 수정하기 어렵습니다. "5분 초과 시 취소 불가"라는 시간 제약 비즈니스 룰을 단위 테스트하기 위해, 스프링이 제공하는 ReflectionTestUtils를 활용했습니다. 엔티티의 생성 시간을 강제로 과거로 조작함으로써 시간에 의존적인 로직을 실제 대기 없이 안정적으로 검증해 냈습니다.
💡 정리
- Mock을 활용한 격리된 비즈니스 로직 검증: 무거운 스프링 컨텍스트 로딩 없이 Mockito를 활용하여 외부 의존성(DB, 프레임워크)을 철저히 배제한 순수 단위 테스트를 작성했습니다. 이를 통해 테스트 실행 속도를 높이고 서비스 로직 자체의 결합 여부를 독립적으로 판단할 수 있는 테스트 가능한 구조를 구성했습니다.
- Edge Case 중심의 테스트 구성: 상태 전이 순서, 시간 제약, 권한 차단 등 도메인 규칙을 위반하는 다양한 실패 시나리오를 단위 테스트로 개별 검증하여, 실제 운영 시 발생 가능한 사이드 이펙트를 사전에 차단했습니다.
- Reflection 기반의 시간 제약 룰 테스트 기법: 프레임워크에 의해 은닉된 불변 필드(createdAt)를 ReflectionTestUtils로 유연하게 조작하여, 시간에 의존적인 비즈니스 로직도 실제 대기 없이 빠르게 검증할 수 있도록 구성했습니다.