Mock??

먼저 사전에 알아야할 간단한 지식을 알아봅시다.

Test Double 이란,

테스트 더블은 영화를 촬영할 때 배우를 대신하여 위험한 역할을 하는 스턴트 더블(Stunt Double)이라는 용어에서 유래된 단어이다. 자동화된 테스트를 작성할 때, 여러 객체들이 의존성을 갖는 경우 테스트 하기 까다로운 경우가 있다. 예를 들어서 프로덕션 코드에서 Service Layer는 Dao에 직접적으로 의존하고, 따라서 Database 까지 의존하는 형태를 갖는다.

라고 한다.

종류

간단하게 종류들을 알아보자

  1. Dummy : 아무 동작도 하지 않는 그냥 말 그대로 Dummy 이다. 주로 파라미터로 전달하기 위해 사용한다.
  2. Fake : 실제로 같은 동작을 수행하지만 프로덕션에 사용하기에는 적합하지 않은 방법이다. (database 대신 java의 map을 사용)
  3. Stub : Dummy와 비슷하지만 실제로 동작하는 척 하는 객체이다.
  4. Spy : Stub과 비슷하지만 다른 정보들을 기록하는 것이다. (메소드의 호출 횟수 등)
  5. Mock : 가장 많이 사용하는 방식으로, 호출에 대해 기대값을 정해주고 정확히 그 동작을 하게끔 도와주는 것이다.

간단히 알아봤다. 혹시 이해가 안되는 부분은 직접 찾아보면 된다.

그럼 가장 많이 쓰이는 방식으로 모든 것을 Mock 하면 되는거 아닌가

당연히 그렇다고 생각하여 이번 장바구니 미션에서 테스트하고자 하는 객체의 모든 의존성을 Mocking 했다. 이렇게 함으로써 테스트 실행 속도가 매우 빨라졌다. 하지만 단점으로는 가독성이 너무 떨어지고, 하나의 테스트를 하기위해서 많은 준비가 필요했다. 코드로 보자

아래는 실제 테스트하고자 하는 메서드의 중요한 부분만 추출 한 것이다.

public class OrderService {

    public Long register(OrderRequest orderRequest, AuthMember authMember) {
        List<CartItem> cartItems = orderRequest.getCartItemIds().stream()
                .map(this::findCartItem)
                .collect(Collectors.toList());

        MemberCoupon memberCoupon = findMemberCoupon(orderRequest.getCouponId(), member);

        Order order = Order.of(member, cartItems, orderRequest.getDeliveryFee(), memberCoupon);

        Order savedOrder = orderRepository.save(order);
        cartItems.stream()
                .map(CartItem::getId)
                .forEach(cartItemRepository::deleteById);
        if (memberCoupon.isExists()) {
            memberCouponRepository.delete(memberCoupon);
        }
        return savedOrder.getId();
    }
 }

차례대로 하나씩 본다면

  1. Cart Item ID 리스트를 순회하면서 repository에서 CartItem을 찾아 반환하는 메서드
  2. MemberCoupon repository에서 찾아오는 메서드
  3. 주문 도메인의 로직
  4. 주문 도메인의 로직을 수행한 뒤 주문 정보를 저장하는 메서드
  5. 주문한 상품을 Cart Item들을 순회하며 하나씩 지우는 메서드
  6. 쿠폰을 사용한다면 쿠폰을 삭제하는 메서드

이렇게 이루어져있다. 그럼 여기서 실제 테스트를 위해 필요한 준비할 과정은 1, 2번이다. 그리고 3번은 Order 객체의 단위테스트를 통해 검증했다. 그리고 4, 5, 6번은 잘 저장되었나 삭제되었나가 궁금한 것이다.

이 메서드를 통해 우리가 알고 싶은 결과는 내가 원하는 정보들로만 잘 저장이 되고 삭제가 되는 것이다. 그리고 저장한 주문 객체의 id를 잘 반환하는 것이다. 그래서 Mocking을 통해 테스트를 해보자.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

   ...
   
    @Test
    void 주문을_등록한다() {
        given(cartItemRepository.findById(anyLong()))
                .willReturn(Optional.of(장바구니_밀리_치킨_10개), Optional.of(장바구니_밀리_피자_1개));
        given(orderRepository.save(any()))
                .willReturn(주문_밀리_치킨_피자_3000원);
        given(memberCouponRepository.findById(anyLong()))
                .willReturn(Optional.of(밀리_쿠폰_10퍼센트));
        given(memberRepository.findById(anyLong()))
                .willReturn(Optional.of(밀리));

        Long id = orderService.register(new OrderRequest(of(1L, 2L), 1L, 3000, valueOf(111000)), 밀리_인증_정보);

        verify(cartItemRepository, times(2)).deleteById(any());
        verify(memberCouponRepository, times(1)).delete(any());
        assertThat(id).isEqualTo(1L);
    }
}

Mocking을 사용한다면 이런 식의 테스트를 짤 수 있다. 그래도 Test Fixture를 만들어 두어 수월하게 했지만, 엄청나게 많은 given() 메서드를 세팅해야한다는 것을 알 수 있다. 뭐 귀찮지만 가벼운 테스트를 위하고 외부 의존을 무시한 단 하나의 객체에 대해 로직만을 테스트할 수 있기때문에 단위테스트라는 조건에 부합하는 것 같다.

그리고 마지막에 verify() 라는 spy를 통해서 해당 메서드가 몇번 호출되었는지도 검증할 수 있다. 이렇게 되면 삭제라는 로직의 구현은 우리의 관심사가 아니고 이 메서드를 정확히 호출했는지를 알 수 있기때문에 이 부분도 나는 문제 없다고 생각한다. (하지만 반대 의견도 있긴하다)

이렇게 구현을 할 때까지만 해도 아주 만족스러웠고 happy case가 아닌 다른 bad case를 검증하는 부분도 원하는 대로 잘 동작했다.

하지만 이제 문제가 생겼다. 리뷰어가 Cart Item의 id 갯수만큼 메서드를 호출하는 것은 성능상 좋지 않기 때문에 한번의 메서드로 한꺼번에 찾아오고, 삭제하는 메서드로 변경해보는 것이 어떻겠냐는 리뷰를 받았고, 나는 그 방법이 훨씬 낫다고 생각해서 CartItemRepository#findById()CartItemRepository#findAllByIds() 로 변경했다. 그리고 CartItemRepository#deleteById()CartItemRepository#deleteByIds() 로 수정했다. 그러니 아래와 같이 수정되었고 쿼리도 한번에 나가고 아주 기분이 좋았다.

public class OrderService {

    ...
    
    public Long register(OrderRequest orderRequest, AuthMember authMember) {
        List<CartItem> cartItems;
        if (cartItemIds.isEmpty()) {
            cartItems = Collections.emptyList();
        }
        // 이 메서드 바뀜
        cartItems = cartItemRepository.findByIds(orderRequest.getCartItemIds());
        
        ...
        
        List<Long> cartItemIds = cartItems.stream()
                .map(CartItem::getId)
                .collect(Collectors.toList());
        // 여기도 바뀜
        if (!cartItemIds.isEmpty()) {
            cartItemRepository.deleteAllByIds(cartItemIds);
        }
        
        ...
        
        return savedOrder.getId();
    }
}

하지만 여기서 큰 문제를 발견했다. 해당 메서드의 테스트가 다 터지는 것이다.

생각해보면 내가 query를 한번 날리거나, 여러번 날린다고 도메인의 주문 방식이 바뀌는게 아니다. 나는 그냥 order service가 의존하고 있는 메서드를 좀 더 효율적인 방법으로 갈아끼웠을 뿐인데 이 테스트가 터지는 것은 말도 안된다.

그래서 테스트가 다 터지기 때문에 아래와 같이 테스트를 다 수정했다.

class OrderServiceTest {

    ...
    
    @Test
    void 주문을_등록한다() {
        // 이 메서드가 변경되어 수정
        given(cartItemRepository.findByIds(any()))
                .willReturn(List.of(장바구니_밀리_치킨_10개, 장바구니_밀리_피자_1개));
        given(orderRepository.save(any()))
                .willReturn(주문_밀리_치킨_피자_3000원);
        given(memberCouponRepository.findById(anyLong()))
                .willReturn(Optional.of(밀리_쿠폰_10퍼센트));
        given(memberRepository.findById(anyLong()))
                .willReturn(Optional.of(밀리));

        Long id = orderService.register(new OrderRequest(of(1L, 2L), 1L, 3000, valueOf(111000)), 밀리_인증_정보);
        // 여기도 cartItem의 갯수만큼 호출하던 것을 수정
        verify(cartItemRepository, times(1)).deleteAllByIds(any());
        verify(memberCouponRepository, times(1)).delete(any());
        assertThat(id).isEqualTo(1L);
    }
 }

Mock을 사용했을 때의 가장 치명적인 문제다.

내가 해당 메서드를 Mocking을 통한 테스트를 했을 때의 장단점은

  • 장점 : 테스트가 가볍다, 다른 의존성을 끊고 내가 원하는 동작을 하는지 검증할 수 있다는 것
  • 단점 : 테스트를 작성하기 귀찮다, 메서드의 내부 구현을 테스트가 알고 있기 때문에 메서드의 내부구현에 따라 테스트 코드도 같이 수정해줘야 한다는 점

나는 귀찮은 것을 아주 싫어한다. 귀찮다는 것은 수정이 많이 일어나는 것이다. 그래서 이 상황에서 Mocking 하는 것은 좋지 않다고 생각이 들었다.

그럼 언제 Mock?

내 생각엔 Mocking을 할만한 경우는 완전 내가 제어할 수 없는 곳에서 사용하는 것이다.

외부 라이브러리라던지, 대표적으로 email을 보내주는 서비스를 사용할 때는 이 방법이 좋을 것이다. 왜냐하면 외부 라이브러리에 테스트를 의존하는 것은 좋지 않다고 생각을 한다. 혹시 외부 라이브러리가 변경되거나 했을 때도 같은 동작을 수행하기 원하는데 외부 라이브러리가 변경됐다고 테스트를 또 수정하게 되는 것은 아주 귀찮다. 그리고 느릴 수 있다.

또 Mock을 사용할만한 곳은 우리가 만든 코드의 제일 마지막부분이다.(End-Point) 예를 들어 Controller 클래스이다. Controller 클래스에서는 우리가 만든 모든 코드들이 의존하고 모이는 곳이다. 만약 거기서 통합테스트를 하고자한다면 수많은 데이터 삽입과 귀찮음 덩어리들을 해야할 것이다. 그래서 Controller에 도착하는 Service 클래스들을 Mocking하면 아주 편하고 힘들지 않게 단위 테스트를 할 수 있다고 생각한다.

그럼 Service 테스트는 무조건 통합테스트를 해야하나

그건 아닌 것 같다 아까 얘기했던 test double 중 fake를 쓰는 방법이 좋을 것 같다. 하지만 이 fake도 귀찮긴 하다. 하지만 이정도는 어쩔 수 없는 부분이라고 생각한다. 그럼 이것을 어떻게 쓰냐


public class FakeCartItemRepository implements CartItemRepository {
    
    private static final Map<Long, CartItem> idOfCartItem = new HashMap<>();
    
    @Override
    public List<CartItem> findAllByMemberId(Long id) {
        return null;
    }

    @Override
    public CartItem save(CartItem cartItem) {
        return null;
    }

    @Override
    public Optional<CartItem> findById(Long id) {
        return Optional.empty();
    }

    @Override
    public void deleteById(Long id) {

    }
}


이런 식으로 구현을 하는 것이다. 물론 구현하는 데에 수고가 들겠지만 그래도 가장 비용이 적게들고 신뢰할만한 테스트를 작성할 수 있다고 생각한다.

결론

Mocking 을 남용하지 말고 상황에 맞게 잘 쓰자

댓글남기기