본문 바로가기
백엔드

직접 구현한 스텁으로 이상적인 테스트 작성하기

by hseong 2024. 8. 5.

들어가기에 앞서

본 게시글은 이상적인 테스트를 작성하기 위해 했던 저의 고민과 결론을 공유하는 것에 목적을 두고 있습니다. Java, Spring 환경에서 테스트를 작성해본 경험이 있는 분들을 대상 독자로 하고 있으며 배경 지식을 위해 테스트 더블에 대해서 먼저 정리합니다. 다만, 저의 고민과 결론에 부족한 부분이 있을 수 있습니다. 부족한 부분은 지적해주시고 너그럽게 봐주시길 부탁드립니다.

본 게시글은 우아한테크캠프에서 미니 세미나를 통해 동기 교육생들에게 공유한 내용을 블로그 게시글의 형태로 정리한 것입니다. 이전에 동일한 내용을 가지고 작성했던 게시글이 존재하나 우아한테크캠프의 특강 중 하나인 "테크니컬 라이팅"에서 배운 내용을 적용, 불필요한 내용을 제거하고 퇴고를 거쳐 다시 작성한 게시글입니다. 이전 게시글은 비교를 위해 남겨두었습니다.

테스트 더블

테스트 더블(Test Double)에 대해 이야기 하기 전에 원본이 되는 단어인 스턴트 더블(Stunt Double)에 대해 잠깐 알아보겠습니다. 여러분은 직접 스턴트를 수행하는 것으로 유명한 영화 배우를 알고 계신가요?

<미션 임파서블: 폴아웃 오프닝 시퀀스 중>


톰 크루즈는 세계에서 가장 유명한 영화 배우 중 한 사람이자, 들어가는 영화마다 스턴트 대역 없이 모든 스턴트를 소화하는 것으로 한 번쯤 들어보셨을 것 같습니다. 위의 영상도 안전 줄 하나에만 의지한 채로 실제 이륙하는 비행기에 매달려 촬영한 것입니다. 일반적으로 액션, 위험한 장면은 실제 배우와 유사한 체격을 가진 대역 배우가 대신 수행하곤 합니다.

<영화 쥬만지, 드웨인 존슨과 전담 스턴트 대역 타노아이 리드>


사진을 보시면 두 배우의 체격, 외형, 머리스타일까지 굉장히 닮은 것을 알 수 있습니다. 이처럼 실제 배우를 대신해서 스턴트를 수행하는 대역을 스턴트맨과 구분하여 스턴트 대역, 스턴트 더블(Stunt Double)이라고 부릅니다.

만약 톰 크루즈가 직접 스턴트를 소화하는 것처럼, 우리도 프로덕션 환경에서 실제 상호작용하는 객체를 이용해 테스트를 작성한다면 어떨까요? 외부와 통신하느라 테스트 속도가 느려지고, 사용자에게 테스트 푸시 알림이 가는 아찔한 일이 벌어질 수도 있습니다. 이러한 이유로 테스트 환경에서 실제 객체를 대체하여 사용하는 가짜 객체를 일컬어 테스트 더블(Test Double), 또는 테스트 대역이라고 부릅니다.

<테스트 더블>


테스트 더블 유형

XUnit 테스트 패턴의 저자인 제라드 메자로스(Gerard Meszaros)는 테스트 더블과 상호작용하는 방식에 따라 다섯 가지: 더미, 목, 스텁, 스파이, 페이크로 분류하였습니다.

<테스트 더블 유형>

각 유형에 대해 자세히 설명하기 위해 이번 예제에서 저희가 테스트해 볼 기능에 대해 설명하겠습니다.

public class OrderService {  // 테스트 하고 싶은 시스템

    private final MemberRepository memberRepository;  
    private final CartRepository cartRepository; 
    private final OrderRepository orderRepository;  
    private final EventPublisher eventPublisher;  

    public Long createOrder(Long memberId) {  // 테스트 할 메서드
        Member member = memberRepository.findById(memberId);  
        Cart cart = cartRepository.findByMember(member);  
        OrderItems orderItems = cart.getProductsWantToBuy(); 
        Order order = orderItems.buy(member);  

        eventPublisher.publish(CreateOrderEvent.create(order));  
        return orderRepository.save(order);  
    }  

    public Order findOrder(Long orderId) {  // 테스트 할 메서드
        return orderRepository.findById(orderId);  
    }  
}

public class CartService {  // 테스트 하고 싶은 시스템

    private final MemberRepository memberRepository;  
    private final CartRepository cartRepository;  

    public Cart findCart(Long memberId) {  // 테스트 할 메서드
        Member member = memberRepository.findById(memberId);  
        return cartRepository.findByMember(member);  
    }  
}

OrderService주문 생성(createOrder)주문 조회(findOrder), CartService장바구니 조회(findCart)는 예제를 통해 테스트해 볼 메서드입니다. 각 기능의 구현은 예제를 위해 만들어졌기 때문에 어색해 보일 수 있습니다. 부디 너그럽게 넘어가주셨으면 합니다.

1. 목(Mock)

목은 기능을 실행했을 때, 기대한 대로 상호작용하는지 확인하기 위한 객체입니다.

여러 언어에서는 목을 편리하게 다룰 수 있는 도구를 제공합니다. 자바는 목을 다루기 위한 도구로 모키토(mockito)를 많이 이용하는 편입니다. JUnit5의 @ExtendWith 어노테이션과 모키토가 제공하는 MockitoExtension, @Mock 어노테이션을 이용하면 편리하게 목 객체를 생성할 수 있습니다. 생성한 목 객체는 기능의 중간 실행 과정을 검증하는 행위 기반 검증을 수행하는 데 사용합니다.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Nested
    @DisplayName("목 테스트")  
    class UsingMock {  

        @InjectMocks  
        private OrderService orderService;  // 테스트 할 시스템

        @Mock  
        private MemberRepository memberRepository;

        @Mock  
        private CartRepository cartRepository;

        @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)); 

            //when  
            orderService.createOrder(member.getMemberId());  

            //then  
            then(orderRepository).should().save(any());  
        }  
    }
}

위 테스트 코드에서 모키토를 이용해 생성한 목 객체를 이용해 given 절에서는 특정 메서드가 호출되었을 때 반환할 값을 설정합니다. when 절에서는 테스트하고 싶은 기능인 주문 생성을 실행합니다. then 절에서는 주문 생성을 실행하였을 때, 주문이 생성되었는지 확인하기 위해 orderRepository.save()가 정상적으로 호출되었는지 행위를 검증하고 있습니다.

2. 더미(Dummy)

더미는 테스트하고 싶은 시스템에 전달되지만 사용하지 않는 객체를 뜻합니다. 생성자에 파라미터로 선언되어 있기 때문에 그저 인자로 전달할 뿐입니다.

@DisplayName("더미 테스트")  
class UsingDummy {  

    @InjectMocks  
    private OrderService orderService;  // 테스트 할 시스템

    @Mock  
    private MemberRepository memberRepository;  // 더미

    @Mock  
    private CartRepository cartRepository;  // 더미

    @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);  
    }  
}

위의 테스트에서 주문 조회를 위해 상호작용하는 객체는 orderRepository 뿐입니다. 그러나 테스트 할 시스템인 OrderService의 생성자는 memberRepository, cartRepositoy, orderRepository, eventPublisher가 파라미터로 선언되어 있어 실제로 사용하지 않는 객체인 더미(dummy)를 인자로 전달하고 있습니다.

목 프레임워크인 모키토(mockito)를 이용해서 만든 목 객체라고 해서 무조건 목(mock)으로만 사용하는 것은 아 닙니다. 모키토는 어디까지나 도구일 뿐, 모키토를 사용한 결과로 나온 목 객체는 목뿐만 아니라 더미, 다른 테스트 더블로서 얼마든지 사용할 수 있습니다. 도구로서의 목과 테스트 더블로서의 목은 서로 구분해서 생각해야 합니다.

3. 스텁(Stub)

스텁은 단순하게 구현해서 미리 준비된 답변을 반환하는 객체입니다. 목을 이용해 중간 과정을 검증하는 행위 기반 검증과 반대로 스텁은 기능을 실행한 최종 결과를 검증하는 상태 기반 검증을 수행합니다.

스텁을 이용하는 테스트를 작성하는 방법에는 1. 모키토를 이용하는 방식, 2. 직접 구현하는 방식 2가지가 있습니다. 우선 모키토를 이용하는 첫번째 방식부터 살펴보겠습니다.

@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 절에서는 특정 메서드가 호출되었을 때, 반환할 값을 설정합니다. when 절에서는 실제 기능을 실행합니다. then 절에서는 기능을 실행한 최종 결과로 반환한 객체인 findCart의 상태를 검증하고 있습니다.

이번에는 두번째 방법인 직접 구현하는 방식을 살펴보겠습니다. 저는 다음과 같이 스텁 객체를 위한 클래스를 구현하였습니다.

public class StubMemberRepository implements MemberRepository {  

    private final Map<Long, Member> database = new HashMap<>();  

    public void stub(Member member) {  
        save(member);  
    }  

    @Override  
    public Member findById(Long memberId) {  
        return 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);  
    }  
}

스텁은 저와 같이 별도의 클래스로 만들거나, 테스트에 특화된 구현을 위해 익명 클래스도 만들 수도 있습니다. 저의 경우 여러 테스트에서 재사용하기 위해 별도의 클래스로 구현하였습니다. 또한, 반환할 값을 임의로 설정해주기 위한 스터빙 메서드(stub())를 구현하였습니다.

직접 구현한 스텁으로 작성한 테스트는 다음과 같습니다.

@Nested  
@DisplayName("[구현]")  
class WithImplementation {  

    private OrderService orderService;  
    private StubMemberRepository memberRepository;  
    private StubCartRepository cartRepository;  
    private StubOrderRepository orderRepository;  
    private SpyEventPublisher eventPublisher;  

    @BeforeEach  
    void setUp() {  
        memberRepository = new StubMemberRepository();  
        cartRepository = new StubCartRepository();  
        orderRepository = new StubOrderRepository();  
        eventPublisher = new SpyEventPublisher();  
        orderService = new OrderService(memberRepository, cartRepository,
        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)해 줍니다. when 절에서는 실제 기능인 주문 생성을 실행합니다. then 절에서는 주문 생성을 실행하여 만들어진 최종 결과, 주문 객체를 스텁 리포지토리에서 꺼내와 상태를 검증합니다.

4. 스파이(Spy)

스파이는 얼마나 많은 호출이 이루어졌는지 기록하는 스텁입니다. 이벤트 퍼블리셔, 메일 발송처럼 밖으로 나가는 상호작용을 기록하는 데 유용하게 사용할 수 있습니다. 스텁과 동일하게 테스트 작성 방법에는 1. 모키토 이용, 2. 직접 구현 두가지 방법이 있습니다. 우선 모키토를 이용하는 방식을 알아보겠습니다.

@DisplayName("[Mockito]")  
class WithMockito {  

    @InjectMocks  
    private OrderService orderService;  // 테스트 할 시스템

    @Mock  
    private MemberRepository memberRepository;  

    @Mock  
    private CartRepository cartRepository;

    @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)); 

        //when  
        orderService.createOrder(member.getMemberId());  

        //then  
        then(eventPublisher).should(times(1)).publish(any());  
    }  
}

given 절에서는 메서드 호출 시 반환할 값을 설정합니다. when 절에서는 실제 기능을 실행합니다. then 절에서는 테스트하고 싶은 객체의 메서드가 몇 번이나 호출되었는지 검증합니다. 위 테스트에서는 주문 생성 기능을 실행했을 때, eventPublisher.publish()가 1번 호출되었음을 검증하고 있습니다.

두 번째 방법인 직접 구현하는 방식을 살펴보겠습니다. 다음과 같이 내부에 count 필드 하나만 가지고 있는 간단한 형태로 구현할 수 있습니다.

public class SpyEventPublisher implements EventPublisher {  

    public int count = 0;  

    @Override  
    public void publish(Event event) {  
        count++;  
    }  
}

스파이 객체의 메서드를 호출할 때마다 내부의 count 값은 1씩 증가합니다.

이를 이용해 작성한 테스트는 다음과 같습니다.

@DisplayName("[Implementation]")  
class WithImplementation {  

    private OrderService orderService;
    private StubMemberRepository memberRepository;  
    private StubCartRepository cartRepository;  
    private StubOrderRepository orderRepository;  
    private SpyEventPublisher eventPublisher;

    @BeforeEach  
    void setUp() {  
        memberRepository = new StubMemberRepository();  
        cartRepository = new StubCartRepository();  
        orderRepository = new StubOrderRepository();  
        eventPublisher = new SpyEventPublisher();  
        orderService = new OrderService(memberRepository, cartRepository, 
        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);  
    }  
}

given 절에서는 스텁 리포지토리가 반환해 줄 값을 설정합니다. when 절에서는 실제 기능을 실행합니다. then 절에서는 주문 생성을 실행했을 때, eventPublisher가 몇 번이나 호출되었는지 count 값을 꺼내와 검증을 수행합니다.

5. 페이크(Fake)

마지막 페이크는 실제로 동작은 하지만 프로덕션 환경에는 적합하지 않은 객체입니다. H2와 같은 인 메모리 데이터베이스를 이용하거나, map을 이용한 메모리 리포지토리가 좋은 예입니다.

다음은 Member를 저장하기 위해 내부에 Map을 필드로 가지고 있는 페이크, 메모리 데이터베이스의 구현입니다.

public class MemberMemoryRepository implements MemberRepository {  

    private final Map<Long, Member> database = new ConcurrentHashMap<>();  

    @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);  
    }  
}

페이크 리포지토리 객체는 실제로 동작합니다. 따라서 테스트 역시 실제 데이터베이스와 연결된 리포지토리를 사용하는 것처럼 작성할 수 있습니다.

@DisplayName("Fake 테스트")  
class UsingFake {  

    private OrderService orderService;  
    private MemberMemoryRepository memberRepository;  
    private CartMemoryRepository cartRepository;  
    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,
        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);  
                    });  
            });  
    }  
}

위 테스트 코드에서는 given 절에서 repository.save()를 호출하면서 테스트에 사용할 값을 미리 리포지토리에 넣어줍니다. when 절에서는 실제 기능을 실행합니다. then 절에서는 주문 생성을 실행하여 만들어진 최종 결과, 주문 객체를 페이크 리포지토리에서 가져와 상태를 검증합니다.

이상적인 테스트 작성하기

내가 작성한 테스트가 좋은 단위 테스트였나?

기능의 구현을 검증하기 위해 테스트 코드를 작성하다가 문득, 이런 생각이 들었습니다. "내가 작성한 테스트 코드가 좋은 단위 테스트일까?". 만일 누군가 좋은 단위 테스트가 무엇인지 묻는다면 다음과 같이 답할 수 있습니다.

  • 우리는 테스트를 통해서 코드 수정 후에도 기능이 잘 동작하는 걸 확인할 수 있습니다. 잘 작성된 테스트 코드를 통해 우리는 더 이상 리팩토링을 두려워 하지 않아도 됩니다.
  • 테스트가 실패하면 테스트 코드를 통해 어디가 문제인지 더 쉽게 파악할 수 있고, 그래도 안 되면 변경된 코드를 다 날려버리고 다시 시작하면 됩니다.
  • 테스트는 실행하기 쉬워야 합니다. 테스트가 실행 시간이 짧을 수록 우리는 테스트를 더 많이 실행하고, 더 빨리 피드백을 받을 수 있습니다.
  • 테스트는 이해하기 쉬워야 합니다. 읽기 쉬운 테스트는 그 자체로 우리가 구현한 기능에 대해 문서로서 동작하게 됩니다.

이러한 면에서 제가 그동안 프로젝트를 진행하면서 작성한 단위 테스트는 좋은 단위 테스트와는 거리가 멀었습니다. 제가 작성한 테스트는 코드가 변경되면 깨지기 일수였습니다.

쉽게 깨지는 테스트

저는 어떤 기능의 구현을 검증할 때, 다음과 같이 모키토를 이용해 만든 목 객체를 스텁으로 활용해 상태 기반 검증을 수행하였습니다.

@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(), cartRepository.findByMember()를 호출할 것이라는 사실을 나타내고 있습니다. 만일 리팩토링을 수행하여 두 메서드를 더 이상 호출하지 않는다면, 다른 메서드를 호출하도록 변경된다면 테스트는 실패하게 됩니다.

예를 들어서 기존 주문 생성 로직을 다음과 같이 변경한다고 가정하겠습니다.

// 변경 전
    public Long createOrder(Long memberId) {  // 테스트 할 메서드
        Member member = memberRepository.findById(memberId);  
        Cart cart = cartRepository.findByMember(member);  
        OrderItems orderItems = cart.getProductsWantToBuy(); 
        Order order = orderItems.buy(member);  

        eventPublisher.publish(CreateOrderEvent.create(order));  
        return orderRepository.save(order);  
    }  

// 변경 후
public Long createOrder(Long memberId) {  
    Cart cart = cartRepository.findByMemberId(memberId);
    Order order = cart.buyProductsWantToBuy();  

    eventPublisher.publish(CreateOrderEvent.create(order));  
    return orderRepository.save(order);  
}

로직을 변경한 후 앞서 예제에서 보여드린 모든 테스트를 다시 실행하면 다음과 같이 실패하는 테스트들을 확인할 수 있습니다.



살펴보면 주문 생성의 구현을 검증하기 위한 테스트 중 모키토를 이용해 만든 목 객체를 활용한 경우 공통적으로 테스트가 실패하였습니다. 프로젝트를 수행하면서 이러한 상황이 종종 발생했습니다. 모키토를 이용한 목 객체는 메서드 호출 시 반환할 값을 설정해주는 단계에서 구현 세부 사항과 강하게 결합됩니다. 이로 인해 구현의 변경이 곧 테스트의 실패로 이어집니다.

반면, 직접 만든 구현체를 이용한 테스트는 로직이 변경되었음에도 여전히 테스트가 성공하는 것을 알 수 있습니다. 이러한 결과에 비추어볼 때, 이상적인 테스트를 작성하기 위해 시도해볼 것은 명확합니다. 구현 세부 사항과 결합되는 행위 기반의 검증 보다 기능을 실행한 최종 결과인 상태 기반의 검증을 수행합니다. 그리고 모키토를 이용해 만든 목 객체를 스텁으로 활용하기 보다 직접 구현한 스텁을 이용하는 것입니다. 그럼 리팩토링을 수행해도 깨지지 않는 테스트, 리팩토링 내성을 가진 테스트를 작성할 수 있을 것입니다.

이상적인 테스트 작성하기

이러한 사고 흐름에 따라 실제 프로젝트를 수행하며 저의 결론을 적용시켰습니다. 구현한 기능을 검증하기 위해 대역 객체가 필요한 경우 모키토를 활용하기 보다 직접 구현한 저만의 테스트 대역 클래스를 활용하였습니다. 결과적으로 제가 작성한 테스트는 이전에 수행했던 모든 프로젝트보다 리팩토링 내성을 가진 테스트, 그토록 원했던 좋은 단위 테스트를 작성할 수 있었습니다. 하지만 모든 것이 뜻대로 돌아가지는 않았습니다.

새로운 프로젝트에서는 주로 직접 구현한 스텁을 이용한 상태 기반 검증을 수행했습니다. 여러 테스트에서 재사용할 수 있는 스텁을 위해 익명 클래스보다는 별도의 클래스를 작성했습니다. 해당 클래스는 다음과 같습니다.

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); 
    } 
    ... 
}

map을 필드로 가지는 메모리 데이터베이스는 조인이 불가합니다. 서비스 로직에서 필요로 하는 데이터가 조인을 통해 얻어와야 한다면 까다로운 선택이 필요합니다. 해당 기능의 테스트를 위해 사용할 스텁을 익명 클래스로 구현하거나, 저의 코드처럼 기존의 스텁 클래스가 여러 개의 필드를 가지게 만들 수 있습니다. 어쩌면 고민은 통해 더 좋은 방법을 떠올릴 수 있을 겁니다.

다른 문제로는 아키텍처가 있습니다. 계층형 아키텍처는 단순하고 익숙하다는 장점이 있지만 서비스 계층이 영속성 계층을 의존한다는 단점을 가집니다. 테스트 환경에서 직접 구현한 테스트 대역으로 교체하려면 중간에 인터페이스를 둬서 의존성을 역전시킬 필요가 있습니다.



위의 그림에서 영속성 계층에 대한 서비스 계층의 의존성을 역전시켜 테스트하기 쉬워졌고 변경에도 유연해졌습니다. 하지만 관리해야 할 클래스의 수는 기존 MemberJpaRepository 1개에서 4개로 늘어났습니다. 또한, 프로젝트는 작고, 단순하여 데이터 접근 기술이 바뀔 가능성이 거의 없을 수 있습니다. 이 경우 더 좋은 테스트 하나만을 바라보고 아키텍처를 선택하는 것이 옳은지는 고민이 필요합니다.

결론

테스트에서 의존성을 대체할 대역으로 직접 구현한 객체를 사용한다면 좋은 단위 테스트에 한 걸음 더 다가갈 수 있습니다. 하지만 모키토를 활용할 때보다 더 많은 고민과 시간, 노력이 필요할 것입니다. 반면, 모키토를 사용한다면 리팩토링 내성은 취약하겠지만 쉽고, 빠르게 구현한 기능을 검증할 수 있습니다. 어떤 것이 더 좋은 방법이라고 할 수는 없습니다. 중요한 것은 두 가지 방법 중 어떤 것이 우리에게 더 적합한지 고민하고 상황에 알맞는 선택을 해야한다는 사실입니다.

QnA

마지막으로 미니 세미나를 진행하며 받았던 질문 중 "스텁과 페이크가 유사하여 차이가 무엇인지 잘 모르겠다."가 있었습니다. 이에 대해서 조금 더 이야기하고 글을 마칩니다.

스텁과 페이크의 차이는 무엇인가요?

스텁과 페이크를 엄격히 구분지을 필요는 없습니다. 리팩토링의 저자로 유명한 마틴 파울러도 테스트 대역에 설명한 자신의 블로그 게시글에서 "페이크는 대부분의 상황에 들어맞는 스텁이다."라고 하기도 했습니다. 여러 테스트에서 하나의 스텁 클래스를 재사용하려면 스텁의 단순한 로직으로는 대응하기 여러울 수 있습니다. 이 경우 특정 테스트에 대응하기 위해 익명 클래스를 구현할 수도 있지만, 실제 작동하는 구현인 페이크로 문제를 해결할 수 있습니다.

또한, 아키텍처의 관점에서 페이크는 세부 사항에 대한 결정을 미루기 위해 사용할 수 있습니다. 가장 중요한 핵심 비즈니스 로직을 구현하는 데 집중하고 그보다 덜 중요한 데이터 접근 기술에 대한 선택은 뒤로 미룹니다. 이럴 때 인메모리 데이터베이스나 map을 필드로 가지는 메모리 데이터베이스인 페이크를 이용할 수 있습니다.

참고

최범균, 테스트 주도 개발 시작하기, 가메

Mocks Aren't Stubs, Martin Fowler

Stub 을 이용한 Service 계층 단위 테스트 하기, 기억보단 기록을

효율적인 테스트를 위한 Stub 객체 사용법, 당근 테크 블로그

블라디미르 코리코프, 단위 테스트, 에이콘출판