얼마 전, 우아한테크캠프에 참여하며 미니 세미나를 통해 테스트 더블과 이상적인 단위 테스트를 작성하기 위해 했던 고민을 다른 사람들과 공유하였습니다. 본 게시글을 통해 당시 진행했던 세미나의 내용을 글로써 정리하고 다시 한 번, 이 글을 읽으실 분들과 저의 고민을 공유해보고자 합니다.
테스트 더블
스턴트 더블
이 장면을 보신적이 있나요?
이 장면은 미션 임파서블 폴아웃의 오프닝 시퀀스의 장면 일부를 따온 것으로, 톰 크루즈가 이륙하는 실제 비행기에 매달려서 스턴트를 소화하고 있는 모습입니다. 보시는 바와 같이 톰 크루즈는 이런 위험한 스턴트 장면도 대역 없이 직접 소화하는 것으로 유명한 배우입니다. 보통은 실제 배우 대신 스턴트 장면을 대신 촬영할 위한 전담 대역을 두는 것이 일반적입니다.
위 사진은 드웨인 존슨과 그의 전담 스턴트 대역인 타노아이 리드가 영화 쥬만지를 촬영하며 함께 찍은 사진입니다. 둘의 체격이 유사하고 분장도 닮게 한 것을 알 수가 있습니다. 이처럼 위험한 스턴트 장면에서 실제 배우를 대체하기 위한 대역을 스턴트맨과 구분하여 스턴트 대역, 스턴트 더블(Stunt Double)이라고 부릅니다.
테스트 더블이라는 용어는 이러한 스턴트 더블이라는 용어에서 따온 개념입니다.
테스트 더블
톰 크루즈가 스턴트 장면을 직접 소화하는 것처럼, 우리가 프로덕션 환경에서 실제 사용하는 객체를 가지고 테스트를 진행할 수는 없습니다. 외부 시스템과 상호작용하느라 테스트를 진행하는 시간도 오래 걸릴 것이고, 사용자에게 테스트 알림이 전달되는 아찔한 상황이 발생할지도 모릅니다. 때문에 테스트를 위해 대신 사용하는 가짜 객체를 일컬어 테스트 대역, 테스트 더블(Test Double) 이라고 부릅니다.
테스트 더블 유형
XUnit 테스트 패턴의 저자 제라드 메자로스(Gerard Meszaros)는 테스트 더블을 유형에 따라 다섯 가지: 더미, 목, 스텁, 스파이, 페이크로 분류하였습니다. 각 테스트 더블은 상호작용하는 객체의 종류, 검증 방식, 구현 방식에 있어서 차이를 보입니다.
지금부터 예시를 통해서 각 테스트 더블 유형에 대해 좀 더 알아보겠습니다.
public class OrderService { // 테스트 하고 싶은 시스템
private final MemberRepository memberRepository;
private final CartRepository cartRepository;
private final OrderItemRepository orderItemRepository;
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
public Long createOrder(Long memberId) { // 테스트 할 메서드
Member member = memberRepository.findById(memberId)
.orElseThrow(NoSuchElementException::new);
Cart cart = cartRepository.findByMember(member)
.orElseThrow(NoSuchElementException::new);
List<OrderItem> orderItems = cart.getProductsWantToBuy().stream()
.map(cartItem -> OrderItem.create(
orderItemRepository.getNextId(),
cartItem
)).toList();
Order order = Order.create(orderRepository.getNextId(), member, orderItems);
eventPublisher.publish(CreateOrderEvent.create(order));
return orderRepository.save(order);
}
public Order findOrder(Long orderId) { // 테스트 할 메서드
return orderRepository.findById(orderId)
.orElseThrow(NoSuchElementException::new);
}
}
public class CartService { // 테스트 하고 싶은 시스템
private final MemberRepository memberRepository;
private final CartRepository cartRepository;
public Cart findCart(Long memberId) { // 테스트 할 메서드
Member member = memberRepository.findById(memberId)
.orElseThrow(NoSuchElementException::new);
return cartRepository.findByMember(member)
.orElseThrow(NoSuchElementException::new);
}
}
위는 테스트 더블에 대해 설명하기 위한 예시 코드입니다.
OrderService
와 CartService
는 이번 예제에서 테스트하고 싶은 시스템입니다. 테스트 더블에 대한 설명을 위해 createOrder()
, findOrder()
, findCart()
메서드의 테스트를 작성해 볼 것입니다. 이 중 주의깊게 봐주실 부분은 createOder()
입니다. 주문 생성을 위해 다양한 리포지토리들과 협력하고 마지막에 이벤트 발행기를 이용해 주문 생성 이벤트까지 발행함을 알 수 있습니다.
참고로 이 코드는 어디까지나 이번 예제를 위해 만들어진 샘플로서 어색해보이거나 구현이 마음에 들지 않는 부분이 있을 수 있습니다. 그러한 부분은 너그럽게 넘어가주십사 부탁드립니다.
더미(Dummy)
더미는 테스트하고 싶은 시스템에 전달되지만 실제로는 사용하지 않는 객체를 말합니다. 생성자에서 매개변수로 선언되어 있기 때문에 그저 인자로 전달하는 객체일 뿐입니다.
@Nested
@DisplayName("더미 테스트")
class UsingDummy {
@InjectMocks
private OrderService orderService;
@Mock
private MemberRepository memberRepository;
@Mock
private CartRepository cartRepository;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private OrderRepository orderRepository;
@Mock
private EventPublisher eventPublisher;
@Test
@DisplayName("[Mockito] 주문을 조회한다.")
void findOrder() {
//given
Order order = new Order(1L, MemberFixture.member());
given(orderRepository.findById(any())).willReturn(Optional.of(order));
//when
Order findOrder = orderService.findOrder(1L);
//then
assertThat(findOrder).isEqualTo(order);
}
}
위의 테스트 코드를 보시면 OrderSerivce
가 의존하는 객체들을 자바의 목 프레임워크인 모키토(Mockito)를 이용해 목(mock)으로 대체한 것을 알 수 있습니다. 이 중에서 findOrder()
라는 메서드를 테스트하기 위해 사용되는 객체는 OrderRepository
뿐이고 나머지 객체들은 그저 OrderService
를 생성하기 위한 더미(dummy)로서 사용되고 있을 뿐입니다.
목(Mock)
목은 테스트 대상 메서드를 실행했을 때, 기대한 대로 상호작용하는지 확인하기 위한 객체입니다.
여러 언어에서 목 객체를 편리하게 다룰 수 있는 도구를 제공합니다. 보통 자바는 목을 다루기 위한 도구로 모키토(mockito)를 많이 이용하는 편입니다. 이렇게 생성한 목은 기능의 중간 실행 과정을 검증하는 행위 기반 검증을 수행하는데 사용할 수 있습니다.
@Nested
@DisplayName("목 테스트")
class UsingMock {
@InjectMocks
private OrderService orderService;
@Mock
private MemberRepository memberRepository;
@Mock
private CartRepository cartRepository;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private OrderRepository orderRepository;
@Mock
private EventPublisher eventPublisher;
@Test
@DisplayName("[Mockito] 주문이 생성된다.")
void createOrder() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품1", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
given(memberRepository.findById(any())).willReturn(Optional.of(member));
given(cartRepository.findByMember(any())).willReturn(Optional.of(cart));
given(orderItemRepository.getNextId()).willReturn(1L, 2L, 3L);
//when
orderService.createOrder(member.getMemberId());
//then
then(orderRepository).should().save(any());
}
}
위 테스트 코드에서는 생성한 목(mock)을 이용하여 given 절에서 목 객체의 특정 메서드가 호출되었을 때 반환할 값을 설정하고 있습니다. then 절에서는 테스트 대상 메서드인 orderService.createOrder()
를 실행하였을 때, orderRepository.save()
가 호출되었음을 검증하고 있습니다.
스텁(Stub)
스텁은 단순하게 구현해서 미리 준비된 답변만을 반환하는 객체입니다. 목을 이용한 행위 기반의 검증과는 반대로 스텁을 이용한 테스트는 메서드를 실행한 최종 결과의 상태를 가지고 상태 기반의 검증을 수행합니다.
이러한 스텁을 이용한 테스트는 모키토를 이용해서 만든 목을 스텁 객체로 이용하는 방법과 직접 구현하는 방법 2가지가 있습니다.
@Nested
@DisplayName("[Mockito]")
class WithMockito {
@InjectMocks
private CartService cartService;
@Mock
private MemberRepository memberRepository;
@Mock
private CartRepository cartRepository;
@Test
@DisplayName("회원의 장바구니를 조회한다.")
void publishEventWithMockito() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
given(memberRepository.findById(any())).willReturn(Optional.of(member));
given(cartRepository.findByMember(any())).willReturn(Optional.of(cart));
//when
Cart findCart = cartService.findCart(member.getMemberId());
//then
assertThat(findCart).satisfies(result -> {
assertThat(result.getMember()).isEqualTo(member);
assertThat(result.getItems()).hasSize(1)
.first()
.satisfies(cartItem -> {
assertThat(cartItem.getProduct()).isEqualTo(product);
assertThat(cartItem.getAmount()).isEqualTo(2);
});
});
}
}
먼저 모키토를 이용하는 방법을 살펴보겠습니다. 앞서 목을 이용한 테스트에서 보셨던 것과 같이 given 절에서는 협력 객체의 특정 메서드 호출 시 반환할 값을 설정합니다. 그리고 then 절에서는 메서드를 실행하여 반환받은 객체의 최종 상태에 대한 검증을 수행합니다.
두 번째 방법인 스텁을 직접 구현해서 테스트에 사용하는 방법은 순수한 계층형 아키텍처에서는 사용하기 어렵습니다. 계층형 아키텍처 특성상 DB 의존적이라는 단점으로 인해 의존성을 변경할 수 없기 때문입니다.
스텁을 직접 만든다면 익명 클래스를 구현하거나 별도의 클래스로 구현할 수 있습니다.
public class StubMemberRepository implements MemberRepository {
private final Map<Long, Member> database = new TreeMap<>();
public void stub(Member member) {
save(member);
}
@Override
public Optional<Member> findById(Long memberId) {
return database.values().stream().findFirst();
}
@Override
public Long save(Member member) {
database.put(getNextId(), member);
return (long) database.size();
}
private Long getNextId() {
return (long) (database.size() + 1);
}
}
위는 제가 작성한 스텁의 구현입니다. 사람마다 구현 방식이 좀 다를 수 있는데 저의 경우는 여러 테스트에서 원활하게 재사용할 수 있도록 스터빙을 위한 별도의 메서드(stub()
)를 두는 편입니다. 나머지 구현의 경우 단순하게 구현하였습니다.
@Nested
@DisplayName("[구현]")
class WithImplementation {
private OrderService orderService;
private StubMemberRepository memberRepository;
private StubCartRepository cartRepository;
private StubOrderItemRepository orderItemRepository;
private StubOrderRepository orderRepository;
private SpyEventPublisher eventPublisher;
@BeforeEach
void setUp() {
memberRepository = new StubMemberRepository();
cartRepository = new StubCartRepository();
orderItemRepository = new StubOrderItemRepository();
orderRepository = new StubOrderRepository();
eventPublisher = new SpyEventPublisher();
orderService = new OrderService(memberRepository, cartRepository, orderItemRepository,
orderRepository, eventPublisher);
}
@Test
@DisplayName("[Implementation] 주문이 생성된다.")
void createOrder() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품1", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
memberRepository.stub(member);
cartRepository.stub(cart);
//when
Long orderId = orderService.createOrder(member.getMemberId());
//then
Optional<Order> optionalOrder = orderRepository.findById(orderId);
assertThat(optionalOrder).isPresent().get()
.satisfies(order -> {
assertThat(order.getMember()).isEqualTo(member);
assertThat(order.getOrderItems()).hasSize(1)
.first()
.satisfies(orderItem -> {
assertThat(orderItem.getAmount()).isEqualTo(2);
assertThat(orderItem.getProduct()).isEqualTo(product);
});
});
}
}
위 테스트 코드는 직접 구현한 스텁을 이용하여 작성해본 것입니다. given 절에서 각 리포지토리가 반환할 값을 스터빙(stubbing)해주고, then 절에서는 orderService.createOrder()
실행한 최종 결과인 생성된 주문의 상태를 검증하고 있습니다.
테스트 코드에서 주목할 점은 스텁 리포지토리로부터 Order
를 꺼내서 검증하는 부분입니다. 이는 첫번째 방법인 모키토로 만든 스텁을 이용한 테스트에 비해 두번째 방법인 직접 구현한 스텁을 이용할 때 얻을 수 있는 장점 중 하나입니다. orderSerivce.createOrder()
의 반환 타입은 Long
이기 때문에 첫번째 방법의 경우 생성된 주문에 대한 상태 검증이 쉽지 않습니다. 반면 두번째 방법은 스텁 리포지토리에 건네진 값을 꺼내올 수 있기 때문에 이를 이용하면 상태 기반 검증을 원활하게 수행할 수 있습니다. 이 외에도 몇가지 장점이 더 있습니다만 그건 아래에서 좀 더 이어가보겠습니다.
스파이(Spy)
스파이는 얼마나 많은 호출이 이루어졌는가를 기록하는 스텁입니다. 주로 이벤트 퍼블리셔, 메일 발송처럼 밖으로 나가는 상호작용을 기록하는데 유용하게 사용할 수 있습니다. 스텁과 동일하게 mockito를 이용하는 방법, 직접 구현하는 방법 두가지를 이용해 테스트를 작성할 수 있습니다.
@Nested
@DisplayName("[Mockito]")
class WithMockito {
@InjectMocks
private OrderService orderService;
@Mock
private MemberRepository memberRepository;
@Mock
private CartRepository cartRepository;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private OrderRepository orderRepository;
@Mock
private EventPublisher eventPublisher;
@Test
@DisplayName("주문 생성 이벤트가 발행된다.")
void publishEventWithMockito() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
given(memberRepository.findById(any())).willReturn(Optional.of(member));
given(cartRepository.findByMember(any())).willReturn(Optional.of(cart));
given(orderItemRepository.getNextId()).willReturn(1L, 2L);
//when
orderService.createOrder(member.getMemberId());
//then
then(eventPublisher).should(times(1)).publish(any());
}
}
모키토를 이용한다면 위와 같이 테스트를 작성할 수 있습니다. 기본적으로 목을 이용한 행위 기반의 검증과 유사한 방식으로 작성할 수 있으며 차이점은 then 절의 times()
라는 메서드를 이용하고 있는 부분입니다. 해당 메서드는 특정 행위가 몇번이나 호출되었는지 검증하는데 사용하며 여기서는 eventPublisher.publish()
1번 호출되었음을 검증하고 있습니다.
public class SpyEventPublisher implements EventPublisher {
public int count = 0;
@Override
public void publish(Event event) {
count++;
}
}
반면, 직접 구현하게 되면 위와 같이 내부에 count 값을 가지고 있어서 메서드 호출 시 count 값이 증가되도록 구현을 할 수 있습니다.
@Nested
@DisplayName("[Implementation]")
class WithImplementation {
private OrderService orderService;
private StubMemberRepository memberRepository;
private StubCartRepository cartRepository;
private StubOrderItemRepository orderItemRepository;
private StubOrderRepository orderRepository;
private SpyEventPublisher eventPublisher;
@BeforeEach
void setUp() {
memberRepository = new StubMemberRepository();
cartRepository = new StubCartRepository();
orderItemRepository = new StubOrderItemRepository();
orderRepository = new StubOrderRepository();
eventPublisher = new SpyEventPublisher();
orderService = new OrderService(memberRepository, cartRepository, orderItemRepository,
orderRepository, eventPublisher);
}
@Test
@DisplayName("주문 생성 이벤트가 발행된다.")
void publishEventWithMockito() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
memberRepository.stub(member);
cartRepository.stub(cart);
//when
orderService.createOrder(member.getMemberId());
//then
assertThat(eventPublisher.count).isEqualTo(1);
}
}
테스트 코드 작성 시에는 then 절에서 스파이 객체 내부의 상태를 꺼내와서 검증을 수행할 수 있습니다.
페이크(Fake)
마지막 페이크는 실제로 동작은 하지만 프로덕션 환경에는 적합하지 않은 객체입니다. H2와 같은 인 메모리 데이터베이스를 이용하거나, map을 이용한 메모리 리포지토리가 좋은 예입니다.
public class MemberMemoryRepository implements MemberRepository {
private final Map<Long, Member> database = new HashMap<>();
@Override
public Optional<Member> findById(Long memberId) {
return Optional.ofNullable(database.get(memberId));
}
@Override
public Long save(Member member) {
database.put(getNextId(), member);
return (long) database.size();
}
private Long getNextId() {
return (long) (database.size() + 1);
}
}
위는 HashMap
을 이용하여 실제 서비스 로직과 상호작용할 수 있도록 작성한 페이크인 메모리 리포지토리입니다.
@Nested
@DisplayName("Fake 테스트")
class UsingFake {
private OrderService orderService;
private MemberMemoryRepository memberRepository;
private CartMemoryRepository cartRepository;
private OrderItemMemoryRepository orderItemRepository;
private OrderMemoryRepository orderRepository;
private SpyEventPublisher eventPublisher;
@BeforeEach
void setUp() {
memberRepository = new MemberMemoryRepository();
cartRepository = new CartMemoryRepository();
orderItemRepository = new OrderItemMemoryRepository();
orderRepository = new OrderMemoryRepository();
eventPublisher = new SpyEventPublisher();
orderService = new OrderService(memberRepository, cartRepository, orderItemRepository,
orderRepository, eventPublisher);
}
@Test
@DisplayName("[Implementation] 주문이 생성된다.")
void createOrder() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품1", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
memberRepository.save(member);
cartRepository.save(cart);
//when
Long orderId = orderService.createOrder(member.getMemberId());
//then
Optional<Order> optionalOrder = orderRepository.findById(orderId);
assertThat(optionalOrder).isPresent().get()
.satisfies(order -> {
assertThat(order.getMember()).isEqualTo(member);
assertThat(order.getOrderItems()).hasSize(1)
.first()
.satisfies(orderItem -> {
assertThat(orderItem.getAmount()).isEqualTo(2);
assertThat(orderItem.getProduct()).isEqualTo(product);
});
});
}
}
이러한 페이크를 이용해 서비스 로직의 테스트 코드를 작성한다면 실제 리포지토리를 이용하는 것과 유사한 방식으로 테스트를 작성할 수 있습니다.
목과 스텁 이야기
좋은 단위 테스트를 위한 고민
지금까지 테스트 더블이 무엇인지, 어떤 것이 있는지 알아봤습니다. 이러한 개념들을 이용해서 단위 테스트를 잘 작성하면 좋겠지만, 어느 순간부터 제가 작성한 테스트가 좋은 단위 테스트인가하는 고민이 생겨났습니다. 목과 스텁에 대해 조금 더 이야기하기 전에, 좋은 단위 테스트란 무엇일까요?
우리는 테스트를 통해서 코드 수정 후에도 기능이 잘 동작하는 걸 확인할 수 있습니다. 테스트가 실패하면 테스트 코드를 통해 어디가 문제인지 더 쉽게 파악할 수 있고, 그래도 안 되면 변경된 코드를 다 날려버리고 다시 시작하면 됩니다. 잘 작성된 테스트 코드를 통해 우리는 더 이상 리팩토링을 두려워 하지 않아도 됩니다.
테스트는 이해하기 쉬워야하고 실행하기 쉬워야 합니다. 테스트가 실행 시간이 짧을 수록 우리는 테스트를 더 많이 실행하고, 더 빨리 피드백을 받을 수 있습니다. 그리고 읽기 쉬운 테스트는 그 자체로 우리가 구현한 기능에 대해 문서로서 동작하게 됩니다.
이러한 면에서 제가 그동안 프로젝트를 진행하면서 작성한 단위 테스트는 좋은 단위 테스트와는 거리가 멀었습니다.
깨지기 쉬운 테스트
@Test
@DisplayName("회원의 장바구니를 조회한다.")
void publishEventWithMockito() {
//given
Member member = MemberFixture.member();
Cart cart = CartFixture.cart(member);
Product product = Product.create(1L, "상품", 10);
cart.addCartItem(CartItem.create(1L, product, 2));
given(memberRepository.findById(any())).willReturn(Optional.of(member));
given(cartRepository.findByMember(any())).willReturn(Optional.of(cart));
//when
Cart findCart = cartService.findCart(member.getMemberId());
//then
assertThat(findCart).satisfies(result -> {
assertThat(result.getMember()).isEqualTo(member);
assertThat(result.getItems()).hasSize(1)
.first()
.satisfies(cartItem -> {
assertThat(cartItem.getProduct()).isEqualTo(product);
assertThat(cartItem.getAmount()).isEqualTo(2);
});
});
}
보통 저는 이런 스타일로 테스트 코드를 작성했습니다. 모키토를 이용해 만든 스텁 객체를 가지고 상태 기반의 검증을 수행했습니다. 이 테스트 코드는 좋은 단위 테스트라는 말이 어울리지 않는 문제점을 한 가지 가지고 있습니다.
given(memberRepository.findById(any())).willReturn(Optional.of(member));
given(cartRepository.findByMember(any())).willReturn(Optional.of(cart));
문제점은 given 절에서 값을 스터빙해주는 부분에 있습니다. 이 테스트 코드는 서비스 로직에서 어떤 메서드를 호출하는지에 대한 구현 세부 사항과 강하게 결합되어 있습니다. 이는 보통 테스트 더블로서 목을 활용한 행위 기반의 검증이 가지는 문제점이지만, 도구로써 목을 스텁이나 스파이로 사용한 경우에도 동일한 문제점을 공유하게 됩니다. 만약 서비스 로직의 리팩토링으로 memberRepository.findById()
나 cartRepository.findByMember()
를 호출한다는 사실이 변한다면 우리가 작성한 테스트 코드는 너무나 쉽게 깨지게 될 것입니다.
예를 들어 기존의 주문 생성 로직을 다음과 같이 변경을 시도한다고 해보겠습니다.
// 변경 전
public Long createOrder(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(NoSuchElementException::new);
Cart cart = cartRepository.findByMember(member)
.orElseThrow(NoSuchElementException::new);
List<OrderItem> orderItems = cart.getProductsWantToBuy().stream()
.map(cartItem -> OrderItem.create(
orderItemRepository.getNextId(),
cartItem
)).toList();
Order order = Order.create(orderRepository.getNextId(), member, orderItems);
eventPublisher.publish(CreateOrderEvent.create(order));
return orderRepository.save(order);
}
// 변경 후
public Long createOrder(Long memberId) {
Cart cart = cartRepository.findByMemberId(memberId)
.orElseThrow(NoSuchElementException::new);
Order order = Order.create(orderRepository.getNextId(), cart, orderItemRepository);
eventPublisher.publish(CreateOrderEvent.create(order));
return orderRepository.save(order);
}
앞서 예제를 통해 보여드린 모든 테스트를 실행하는 경우 다음과 같은 결과를 확인할 수 있습니다.
살펴보면 orderService.createOrder()
, 주문 생성의 테스트 중 모키토를 이용한 경우 테스트가 실패하는 것을 확인할 수 있습니다.
기존 주문 생성의 로직은 memberRepository.findById()
, cartRepository.findByMember()
두 번의 호출을 통해 memberId
로부터 cart
를 획득하여 서비스 로직에서 활용하고 있었습니다. 그러나 변경된 로직에서는 cartRepository.findByMemberId()
한 번의 호출로 memberId
로부터 곧바로 cart
를 획득합니다.
// 변경 전
Member member = memberRepository.findById(memberId)
.orElseThrow(NoSuchElementException::new);
Cart cart = cartRepository.findByMember(member)
.orElseThrow(NoSuchElementException::new);
// 변경 후
Cart cart = cartRepository.findByMemberId(memberId)
.orElseThrow(NoSuchElementException::new);
테스트 코드에서는 전자의 메서드 호출에 대한 응답값은 스터빙되어 있지만, 후자의 경우에는 그렇지 않습니다. cartRepository.findByMemberId()
를 호출했을 때 어떠한 값도 반환하지 않으면서 자연스럽게 서비스 로직은 NoSuchElementException
을 던지면서 테스트는 실패하게 됩니다.
단위 테스트의 좋은 점은 우리가 리팩토링을 해도 기능이 정상적으로 작동한다는 사실이지만, 정작 리팩토링을 수행했을 때는 그것과는 상반된 결과를 확인하였습니다.
이상적인 테스트
이러한 문제점을 해결하려면 구현 세부 사항과 결합된 테스트를 수행하는 행위 기반의 검증보다 기능을 실행한 최종 결과인 상태 기반의 검증을 수행하고, 모키토를 이용해 만든 스텁을 활용하기보다 직접 구현한 스텁을 이용하면 될 것이라고 기대할 수 있습니다.
리팩토링을 할때마다 세부 사항과 결합된 테스트 코드로 인해 테스트가 실패하는 상황을 피하고 이상적인 테스트를 작성하고 싶은 욕구는 점점 커져만 갔습니다. 결국 새로운 프로젝트를 진행하면서 데이터베이스나 이벤트 퍼블리셔와 같은 의존성은 모키토를 이용하는 대신 직접 구현한 스텁으로 대체하여 테스트를 작성하기에 이르렀습니다. 그 결과 해당 프로젝트의 테스트는 이전에 진행했던 그 어떤 프로젝트보다 강한 리팩토링 내성을 가지게 되었습니다.
은총알은 없다
은총알은 없다. 유명한 격언입니다. 직접 구현한 테스트 더블을 이용함으로써 더 이상 리팩토링을 두려워하지 않을 수 있었지만 생각만큼 모든 상황이 이상적이지는 않았습니다.
public class StubLinkDomainRepository implements LinkDomainRepository {
private final Map<Long, LinkDomain> memory = new HashMap<>();
private final Map<LinkDomain, List<Link>> domainLinks = new HashMap<>();
private final Trie searchAutoComplete = new Trie();
public void stub(LinkDomain linkDomain) {
memory.put(nextId(), linkDomain);
searchAutoComplete.insert(linkDomain.getRootDomain());
}
public void stub(LinkDomain linkDomain, Link... links) {
memory.put(nextId(), linkDomain);
domainLinks.put(linkDomain, List.of(links));
}
@Override
public LinkDomainPaginationResult findDomains(String keyword, int page, int size) {
List<LinkDomain> list = memory.values().stream().toList();
int totalElements = list.size();
boolean hasNext = false;
if(list.size() > size) {
list = list.subList(0, size);
hasNext = true;
}
return new LinkDomainPaginationResult(list, totalElements, hasNext);
}
@Override
public LinkDomainLinkPaginationResult findDomainLinks(LinkDomain linkDomain, int page, int size) {
List<Link> links = domainLinks.get(linkDomain);
List<LinkDomainLinkResult> content = links.stream()
.map(link -> new LinkDomainLinkResult(link.getLinkId(), link.getUrl(), links.size()))
.toList();
return new LinkDomainLinkPaginationResult(content, links.size(), links.size() > size);
}
...
}
위의 스텁 리포지토리는 제가 실제 프로젝트를 진행하며 구현한 것입니다. 객체를 저장하기 위해 사용하는 필드가 여러개이고, 마냥 단순하게 값을 반환하지는 않습니다.
스텁을 직접 구현하기로 결정한 경우, 요구사항이 복잡해지는 경우 하나의 스텁 구현만으로는 해결할 수 없는 상황도 있습니다. 필요에 따라 해당 테스트에서 사용하기 위한 별도의 스텁을 구현해야합니다. 저의 경우는 관리해야 할 클래스를 줄이고자 여러 요구사항을 하나의 스텁 클래스로만 해결하고 싶었기에 위와 같은 형태가 되었습니다.
그리고 앞서 스텁에 대해 설명하면서 순수한 계층형 아키텍처에서는 직접 구현하기 어렵다는 이야기를 하였습니다. 단순히 더 좋은 테스트만을 위해서 다른 아키텍처를 선택한다거나, 리포지토리로의 의존성을 역전시키고자 한다면 정말로 필요한 일인지 고민해야합니다. 좋은 아키텍처는 변경에 유연하고, 테스트하기 쉽다는 장점을 가지지만 그만큼 관리해야할 패키지, 클래스가 늘어난다는 단점을 가지고 있습니다. 요구사항이 복잡하지 않고 변경이 발생할 가능성도 낮은 프로젝트라서 굳이 의존성 역전을 적용할 필요가 없음에도 테스트만을 위해서 선택을 한다는 것은 꽤 부담스러울 수 있다고 생각합니다.
결론
제가 한 고민, 그리고 경험과 같이 직접 구현한 스텁을 활용한다면 테스트 코드가 리팩토링에 대해 가지는 내성은 분명 더 높아질 것입니다. 하지만 모키토를 이용해서 단순하고, 빠르게 테스트를 작성할 떄보다는 좀 더 많은 노력과 고민, 그리고 시간을 들여야만 합니다. 우리는 둘 사이의 적절한 지점에서 타협하고, 현 상황에 알맞는 선택을 해야합니다.
이 글을 읽는 분들에게 부디 제가 한 고민과 경험이 좋은 테스트를 작성하는데 조금이라도 도움이 되었으면 좋겠습니다.
하나 더, 스텁과 페이크 이야기
스텁과 페이크의 차이는 무엇인가요?
페이크는 대부분의 상황에 들어맞는 스텁이라고도 부릅니다.
저도 처음 테스트 더블에 대해 알아보기 시작했을 때 둘을 어떻게 구분해야할지 난감하였습니다. 단순하게 구현하면 스텁이고, 실제 작동할 수 있도록 구현하면 페이크인가? 그렇게 정확히 구분할 필요는 없습니다. 요구사항이 복잡한 기능의 테스트를 작성하는데 기존의 구현으로 대응할 수 없을 때, 조금 더 복잡하거나 실제 작동하는 구현을 통해 문제를 해결할 수 있습니다.
또한, 페이크는 테스트를 위해서 사용할 수 있을 뿐만 아니라 어떤 DB를 사용할지, 어떤 기술을 사용할지와 같은 세부 사항에 대한 결정을 미루기 위해서도 사용할 수 있습니다. 비즈니스 로직과 같은 중요한 부분에 집중하고 어떤 식으로 데이터에 접근할 것인지와 같이 중요하지 않은 부분은 메모리 리포지토리를 통해서 단순하게 동작하도록 만들 수 있을 겁니다.
사실 페이크뿐만 아니라 다른 테스트 더블 모두 구현 세부 사항을 미루기 위해 이용할 수 있습니다. 저의 경우 이미지 삭제 로직이 구현되지 않아 대신 목으로 의존성을 대체하고 개발을 진행했던 적이 있습니다. 당시에는 이미지 삭제 기능이 호출되었다는 사실만 필요하여 별도의 로직을 구현하지 않았지만, 만약 좀 더 구체적으로 상호작용하는 로직이 필요했다면 페이크로 구현했을 겁니다.
이러한 부분은 로버트 마틴의 클린 아키텍처, 17장 경계: 선 긋기를 참고하시면 더 도움이 될 것입니다.
참고
다음은 제가 미니 세미나를 준비하며 참고한 자료들입니다. 테스트에 대해 인사이트를 얻고자 하시는 분들은 한 번쯤 읽어보시면 좋을 것 같습니다.
Mocks Aren't Stubs, Martin Fowler
Stub 을 이용한 Service 계층 단위 테스트 하기, 기억보단 기록을
'백엔드' 카테고리의 다른 글
[우아한 티켓팅] 대기열 시스템 10,000명 부하 테스트하기 (4) | 2024.09.29 |
---|---|
직접 구현한 스텁으로 이상적인 테스트 작성하기 (0) | 2024.08.05 |
테스트 더블과 mock 객체 사용 시 주의점 (1) | 2024.01.27 |
프로젝션, 인덱스로 조회 성능 개선하기(feat.ngrinder) (0) | 2023.12.16 |
낙관적 락, 데드락, 비관적 락 (1) | 2023.12.10 |