이번에 내가 맡은 API는 총 5개로, 개수는 많지 않다.
하지만 각 API마다 예외처리 상황이 다양하고, 최근에 테스트 코드 관련 강의도 수강했기 때문에,
이번 기회에 테스트 코드를 직접 작성해보기로 했다.
내가 테스트 코드를 작성한 기준은 다음과 같다:
- 사용자 ID나 메뉴 ID가 존재하지 않는 등의 경우는 대부분 JPA에서 제공하는 기본 메소드에서 자동으로 예외를 던지도록 처리되어 있기 때문에, 별도로 테스트 코드를 작성할 필요성을 크게 느끼지 않았다.
- 이처럼 단순한 조회나 프레임워크에서 이미 보장되는 기본적인 검증에 대해서는 굳이 테스트를 작성하기보다는, 도메인 로직이 개입되거나, 역할에 따라 흐름이 분기되거나, 예외 처리 조건이 명확히 존재하는 경우에만 테스트를 작성하는 것이 더 효율적이라고 판단했다.
- 따라서 이번 API 구현에서는 모든 흐름을 테스트하기보다는, 실제로 로직상 중요하거나 변경 가능성이 있는 부분 위주로 테스트 코드를 작성하였다.
- 주문 내역 조회도 별다른 예외 처리 없이 응답이 일관되기 때문에, 필요성을 크게 느끼지 않았다.
1. 주문 생성 (POST /orders)
해당 API의 테스트 시나리오는 다음과 같다.
✅ 성공 케이스
- 사용자가 정상적인 메뉴 리스트로 주문 생성 시 주문이 생성된다.
❌ 실패 케이스
- 주문한 메뉴 중 하나라도 삭제 상태면 예외가 발생한다.
- 동일 가게가 아닌 메뉴들이 섞여 있으면 예외가 발생한다.
- 최소 주문 금액 미만이면 예외가 발생한다.
- 가게가 영업 시간이 아니면 예외가 발생한다.
주문 생성 API의 테스트 코드를 작성하면서 있었던 두 가지 트러블 슈팅은 아래와 같다.
문제 1. 테스트에서 Menu의 ID가 null로 발생하는 NPE
OrderService의 로직 중 하나는 요청된 메뉴들의 ID를 기반으로 전체 가격을 계산하는 것이다.
for (OrderCreateRequestDto.MenuOrderDto item : request.menus()) {
Menu menu = menus.stream()
.filter(m -> m.getId().equals(item.menuId()))
.findFirst()
.orElseThrow();
totalPrice += menu.getPrice() * item.count();
}
Menu 엔티티는 실제 서비스에서는 JPA가 자동으로 ID를 생성하지만, 테스트코드에서는 직접 Menu.builder()를 통해 객체를 생성했기 때문에 ID 값이 null이었습니다.
Menu menu1 = Menu.builder().name("짜장면").price(7000).isOption(false).storeId(1L).build();
Menu menu2 = Menu.builder().name("짬뽕").price(8000).isOption(false).storeId(1L).build();
해결 방법: ReflectionTestUtils를 활용한 ID 주입
Spring에서는 테스트 목적으로 private 필드에 접근할 수 있도록 ReflectionTestUtils라는 유틸리티를 제공한다.
Menu menu1 = Menu.builder().name("짜장면").price(7000).isOption(false).storeId(1L).build();
Menu menu2 = Menu.builder().name("짬뽕").price(8000).isOption(false).storeId(1L).build();
ReflectionTestUtils.setField(menu1,"id",1L);
ReflectionTestUtils.setField(menu2,"id",2L);
이렇게 하면 setter 없이도 테스트 목적의 ID 세팅이 가능하고, 캡슐화를 깨지 않아도 된다.
⚠ 엔티티에는 가능하면 @Setter를 사용하지 않는 것이 좋다. 불변성을 유지하고, 의도치 않은 변경을 방지할 수 있기 때문이다.
문제 2. 현재 시각에 따라 테스트가 실패하는 경우
서비스 코드에는 가게의 운영 시간에만 주문이 가능하도록 비즈니스 로직이 있다.
LocalTime now = LocalTime.now();
checkStoreIsWorking(now,store);
private void checkStoreIsWorking(LocalTime now,Store store) {
if (now.isBefore(store.getWeekdayWorkingStartTime()) || now.isAfter(store.getWeekdayWorkingEndTime())) {
throw new BusinessException(ResultCode.STORE_NOT_WORKING);
}
}
실행 시각에 따라 테스트가 성공하기도, 실패하기도 하는 현상이 발생하였다.
해결 방법: Clock 객체 주입
Java 8부터 제공되는 Clock을 활용하면 테스트에서 고정된 시각을 주입할 수 있다.
Clock을 도입하여, 운영 코드에는 현재 시각을, 테스트코드에서는 내가 임의로 고정한 시각을 넣어주었다.
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final MenuRepository menuRepository;
private final StoreRepository storeRepository;
private final OrderMenuRepository orderMenuRepository;
private final Clock clock;
protected LocalTime now() {
return LocalTime.now(clock);
}
...
@Transactional
public Long createOrder(OrderCreateRequestDto request, long userId) {
...
LocalTime now = now();
checkStoreIsWorking(now,store);
...
}
package com.ddukbbegi.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class TimeConfig {
@Bean
public Clock systemClock() {
return Clock.systemDefaultZone();
}
}
이렇게 하면 테스트는 항상 2024년 1월 1일 오전 10시로 고정된 환경에서 실행되어 테스트 코드가 일관된 환경에서 실행된다.
@BeforeEach
void setUp() {
Clock fixedClock = Clock.fixed(
LocalDateTime.of(2024, 1, 1, 10, 0).toInstant(ZoneOffset.UTC),
ZoneId.systemDefault()
);
orderService = new OrderService(orderRepository, userRepository, menuRepository, storeRepository, orderMenuRepository,fixedClock);
...
}
2. 주문 취소 (PATCH /orders/{orderId}/cancel)
✅ 성공 케이스
- 일반 회원이 자신의 WAITING 상태 주문을 취소하면 성공한다.
❌ 실패 케이스
- 주문 상태가 WAITING이 아닌 경우 (ACCEPTED, COOKING 등) 예외가 발생한다.
- 주문이 본인의 것이 아닐 경우 예외가 발생한다.
@ParameterizedTest
테스트 코드를 짜다보니, 주문 상태가 WAITING이 아닌 경우(ACCEPT, COOKING, REJECT .. 등) 에 대해 모두 검증하려고 하니 여러 개의 테스트코드 메소드를 작성해야한다.
JUnit에는 이렇게 여러 개의 테스트를 한 번에 작성하기 위한 @ParameterizedTest 라는 애노테이션을 제공한다. 기본적인 사용 방법은 @Test 대신 @ParameterizedTest라는 어노테이션을 사용하는 것 외에는 동일하다.
이 때 파라미터로 넘겨줄 값들을 지정해주어야 하는데, @ValueSorce 어노테이션을 사용해서 테스트에 주입해줄 수 있다.
테스트에 주입할 값을 해당 어노테이션에 배열로 지정한다. 테스트를 실행하면 배열을 순회하면서, 테스트 메소드에 인자로 배열에 지정된 값들을 주입해서 테스트한다. 이 때, 하나의 테스트에는 하나의 인수(argumnet)만 전달할 수 있다.
@ParameterizedTest
@DisplayName("상태가 WAITING이 아닌 주문은 취소할 수 없다")
@ValueSource(strings = {"ACCEPTED","COOKING","DELIVERING","DELIVERED","REJECTED", "CANCELED"})
void cancelOrder_fail_dueToStatusIsNotWaiting(String status) {
//given
OrderStatus orderStatus = OrderStatus.valueOf(status);
Order order = Order.builder().user(user).store(store).requestComment(REQUEST_COMMENT).build();
ReflectionTestUtils.setField(order, "id", 1L);
ReflectionTestUtils.setField(order, "orderStatus", orderStatus);
given(orderRepository.findByIdOrElseThrow(1L)).willReturn(order);
//when & then
assertThatThrownBy(() -> orderService.cancelOrder(1L, user.getId()))
.isInstanceOf(BusinessException.class)
.hasMessageContaining(ORDER_CANNOT_BE_CANCELED.getDefaultMessage());
}
@ValueSource에 사용할 수 있는 자료형은 다음과 같다.
- byte, short, int, long, float, double, char, boolean
- String, Class
이를 통해 다음과 같이 하나의 메서드로 여러 가지 인자를 테스트하였다.

다양한 인자 애노테이션
만약 여러 개의 인자를 중복된 메소드에 테스트하기 위해서 방법은 없을까?
@CsvSource에는 콤마(,)로 구분된 값들을 인자로 받아 전달할 수 있다. 인자 개수에 제한은 없다!
int multiplyBy2(int number) {
return number * 2;
}
@ParameterizedTest
@CsvSource(value = {"1,2", "2,4", "3,6"})
void multiplyBy2Test(int input, int expected) {
assertThat(multiplyBy2(input)).isEqualTo(expected);
}
복잡한 인자를 넘겨주기 위해서는, @MethodSource를 활용해서 테스트 인자를 직접 생성해주는 메서드로부터 받아올 수도 있다.
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForBlankStrings(String input, boolean expected) {
assertThat(input.isBlank()).isEqualTo(expected);
}
// 메소드명은 애노테이션 인자와 동일해야한다.
// static으로 선언해야한다.
private static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}
UnnecessaryStubbingException 발생
주문 취소 테스트코드를 모두 작성하고 주문 서비스 코드에 대한 전체 테스트를 돌렸더니, UnnecessaryStubbingException이 발생했다. 이 오류는 Mockito의 "불필요한 stubbing(Unnecessary stubbing)" 감지 기능 때문으로, 에러 메세지를 분석해보면
@BeforeEach
void setUp() {
...
given(userRepository.findByIdOrElseThrow(1L)).willReturn(user);
}
이 설정은 주문 생성 메소드 테스트에서 잘 작동했기 때문에, 테스트 전체에서 공통적으로 사용될 거라 생각하고 @BeforeEach에 배치했다. 그런데 이후 주문 취소 관련 테스트를 추가하고 전체 테스트를 실행하자, 아래와 같은 예외가 발생했다.
UnnecessaryStubbingException: Unnecessary stubbings detected.
...
1. -> at OrderServiceTest.setUp(OrderServiceTest.java:XX)
setUp()에 구현되어 있는 위 given(...) 구문은 userRepository.findByIdOrElseThrow()를 사용하는 테스트에서만 유효한데, 현재 테스트에서 사용되지 않거나 중복 선언된 것으로 판단된 것이다.
즉, 주문 생성 메소드에는 userRepository.findByIdOrElseThrow()를 사용해서, 초기화 메소드에 넣어두었던 것인데, 주문 취소 메소드에 대한 테스트코드를 새로 작성하면서 위 메소드를 사용하지 않게 됐다. 그래서, Mockito가 불필요한 stubbing을 감지한 것이다!
따라서, 해당 구문을 주문 생성 메소드에만 따로 넣어주니 해결되었다.
✨ Mockito는 테스트가 깨지지 않아도 정리되지 않은 Stubbing을 찾아낸다는 사실을 알게 되었다. 테스트 코드는 메서드 단위로 나누는 것도 가독성, 유지보수성 측면에서 훨씬 좋을 것이라는 생각이 들었다.
3. 주문 상태 변경 (PATCH /orders/{id}?status=...)
✅ 성공 케이스
- WAITING → ACCEPTED → COOKING → DELIVERING → DELIVERED 순으로 정상 변경된다.
- WAITING → REJECTED 로 바로 변경 가능하다.
❌ 실패 케이스
- 상태 변경 흐름이 잘못되었을 경우 예외 발생 (ex. WAITING → COOKING, DELIVERING → ACCEPTED)
- REJECTED 상태가 된 주문은 더 이상 상태 변경이 불가능하다.
- DELIVERED 상태 이후에는 상태 변경 불가능하다.
- 권한이 없는 사용자가 상태를 변경하려고 하면 예외 발생(본인 가게의 사장님이어야 함)
@Transactional
- 구현하면서 마주친 문제: 각각의 테스트 메소드를 실행시키면 성공하는데, 두 메소드를 동시에 실행시키면 오류가 발생
- 해결: @BeforeEach로 각자 동일한 user를 db에 save하면서 생기는 문제. -> @Transactional을 클래스에 추가하여, 테스트 메서드 종료시 자동으로 롤백하도록 해줌.
🏋️♀️ @BeforeEach, @AfterEach, @AfterAll, @BeforeAll 순서
- 순서: @BeforeAll -> @BeforeEach -> @AfterEach -> @AfterAll
- @BeforeAll 과 @AfterAll은 클래스에서 딱 한번만 실행되기 때문에, static 으로만 선언되고, static 변수만 참조가능하다.
- @TestInstance(TestInstance.Lifecycle.PER_CLASS) 로 선언해도, 비 static으로 필드를 메소드 간 공유가 가능하다!
- 기본은, @TestInstance(TestInstance.Lifecycle.PER_METHOD) 이기 때문에, 메서드마다 인스턴스가 생성되어 변수는 메서드 간 공유하지 않는다.
- @SpringBootTest 또는 @DataJpaTest , 이 어노테이션들이 붙은 테스트 클래스는 Spring Context를 로딩하며, H2 인메모리 DB는 이 컨텍스트와 함께 관리된다. 즉, 테스트 클래스 전체에 걸쳐 DB가 공유된다.
결론
API마다 테스트 시나리오를 꼼꼼하게 정리하면서, 기능 명세를 문서화하는 데도 큰 도움이 된다는 걸 느꼈다. 단순히 코드의 안정성을 검증하는 차원을 넘어, 기획을 명확히 하고, 협업 시 기준점을 제시하는 역할도 할 수 있기 때문이다.
하지만 처음부터 무조건 TDD로 접근하는 것은 오히려 비효율적일 수 있다. 백지 상태에서 도메인 설계나 전체 흐름이 머릿속에 완전히 잡히지 않은 상황이라면, 일단 기능을 어느 정도 구현해 본 뒤 테스트를 작성하는 편이 더 빠르고 실용적이었다.
이후에는 명확해진 구조를 바탕으로 TDD 방식으로 안정적으로 기능을 확장해 나가는 것이 가장 효율적이라는 것을 경험을 통해 깨달았다.
무엇보다 중요한 것은, 테스트 코드도 하나의 “설계”이자 “문서”라는 점.
테스트를 잘 짜는 능력은 결국 유지보수성에 직결된다는 것을 다시 한 번 실감하게 됐다.
'Coding > 사이드 플젝' 카테고리의 다른 글
| [CtrlU] 아키텍처 (feat. Docker, Redis, 테스트 인스턴스) (0) | 2025.05.03 |
|---|---|
| [뚝배기] 동시성 제어 (비관적 락, 낙관적 락) (2) | 2025.04.27 |
| [뚝배기] 페이징과 페치조인 (0) | 2025.04.25 |
| [뚝배기] JPA 벌크연산 (feat.ID 생성 전략) (0) | 2025.04.24 |
| [뚝배기] RESTful한 API 작성하기 (0) | 2025.04.23 |