웹 개발/무신사 스토어 watcher

[무신사 watcher] 테스트에 대한 고찰

제리 . 2021. 6. 1. 20:29

테스트 코드에 대해 작성하고 리팩토링해가면서 깨달은 점과 적용한 부분에 대해서 개인적인 생각을 정리해보려고한다.

 

얼마전까지는 테스트 코드를 커버리지 위주로 생각하려는 경향이 강했었다. 라인 커버리지와 브랜치 커버리지를 높여서 단순히 이 수치로 얼마만큼 로직이 검증되었는지 표현하려고했다. 하지만 이 수치가 완벽하게 시스템의 안정성을 보장해주지는 않는다는 것을 느꼈다.

 

1. 무엇이 테스트의 대상인가?

아마도 테스트를 하면서 가장 중요한 대목이 아닌가싶다. 달리 표현하면 어떤걸 테스트해야하는가?라는 표현도 맞겠다. 

 

하나의 예시를 들어보자. 무신사 왓쳐에서는 오늘 역대 최저가인 상품을 제공한다. 

 

 

그렇다면 이 기능을 검증하기 위해서는 무엇을 테스트 해야할까? 생각하기에 앞서 오늘 역대 최저가인 상품은 어떤 과정으로 조회되는지 생각보자. 오늘 역대 최저가 상품은 DB에서 통계쿼리를 사용해 조회해온다. 이외에 별다른 비즈니스 로직이 포함되지않는다. 서비스 계층에서는 repository에서 조회한 쿼리를 반환하는 작업만을 갖는다.

  @Test
  @DisplayName("오늘 할인 폭이 큰 상품 조회")
  public void 오늘할인() {
    when(productRepository.findTodayMinimumPriceProduct(any(), any(), any()).thenReturn(results);
        
    productService.findTodayMinimumPriceProduct(category, pageable, sort);
    
    verify(productRepository, times(1)).findTodayMinimumPriceProduct(any(), any(), any());
  }

그렇다면 위의 서비스단의 단위 테스트가 큰 의미가 있을까? 그렇다고 생각하지는 않는다. 위의 코드는 단순히 서비스의 메서드가 호출되면 respository의 조회 메서드가 호출되는지 여부를 확인한다. 정작 중요한 부분은 내가 작성한 복잡한 통계 쿼리가 의도대로 값을 조회해오는지가 관심사이다. 그러므로 비즈니스로직이 없는 단위테스트보다는 통합 테스트가 더 필요하다고 판단된다. 아래에 한가지 예시를 보자.

  @Test
  @DisplayName("오늘 최저가인 상품중 가격 데이터 표본 수가 기준치 미만이라면 조회대상에서 제외된다.")
  public void findMinimumProductWithLowSampleCount() {
    int avgPrice = 10000;
    int todayPrice = 8000;
    int count = 3;
    saveProduct(productId, Category.TOP);
    setTodayMinimumPriceWithAvgPriceAndMinPriceAndCount(productId, avgPrice, todayPrice, count);

    Page<TodayMinimumPriceProductDto> results = todayMinimumProductQueryRepository
        .findTodayMinimumPriceProducts(Category.TOP, PageRequest.of(0, 20));

    assertEquals(results.getTotalElements(), 0);
  }

데이터의 표본 수를 기준으로 조회 대상을 구분하는 것은 쿼리에 설정된 조건이며 단위 테스트에서 이 부분을 확인할 방법은 없다. 내가 의도한 대로 쿼리가 잘 이뤄졌는지는 통합 테스트를 통해 실제 결과를 얻어와 확인해야한다.

 

반면, 단위 테스트가 의미를 갖는 기능도 있다. 아래의 테스트는 로컬 캐시와 글로벌 캐시를 이중화한 뒤 상황에 따라 어떻게 캐시가 관리되는지를 확인하는 코드이다. 

  @Test
  @DisplayName("글로벌 캐시에서 조회한 값은 로컬 캐시에 저장된다.")
  public void putCache() {
    ValueWrapper globalCacheValue = mock(ValueWrapper.class);
    setLocalCacheValue(null);
    setGlobalCacheValue(globalCacheValue);

    ValueWrapper result = chainedCache.get(anyString());

    verify(localCache, times(1)).put(any(), eq(result));
  }

  @Test
  @DisplayName("캐시를 초기화하면 로컬, 글로벌 캐시 둘 다 초기화 된다.")
  public void clearCache() {
    chainedCache.clear();

    verify(localCache, times(1)).clear();
    verify(globalCache, times(1)).clear();
  }

코드가 추상화되어있긴하지만 내부적으로 여러군데에 mocking이 되어있다. 이 테스트에서는 어떤 상황에 대해서 무슨 메서드가 호출되는지가 중요하다.  예를들어 캐시를 초기화한다고했을 때 로컬 캐시와 글로벌 캐시가 둘 다 초기화되야한다. 또, 글로벌 캐시에 값이 저장되면 로컬 캐시에서 put메서드가 호출되어 저장되는지가 중요하다. 물론, 이것도 통합 테스트로 확인하면 되지않는지 의문이 들 수도 있다. 하지만 결과 자체는 의도대로 나왔지만 내부에 호출되면 안되는 메서드가 호출되었다던지, 우연히 결과가 맞아 떨어졌을 뿐 정작 중요한 메서드는 호출을 하지 않는 상황이 발생할 수 있다. 이런 측면에서 단위 테스트는 여전히 유용하다.

 

테스트 커버리지 관점에서보면 오늘 최저가 상품을 조회하는 쿼리는 호출되기만하면 커버리지에 포함된다. 쿼리가 의도한 대로 결과를 조회해서 왔는지는 커버리지 관점에서는 무의미하다.

 

정리해보자면, 느낀점은 이렇다. 테스트는 단위 테스트, 통합 테스트 모두 중요하다. 하지만, 어떤 테스트를 더 중점으로 해야할지는 상황에 따라 다른 것 같다. 단순히 커버리지가 높다고해서 안심해서는 안된다.

 

2. 깨끗한 테스트

깨끗한 테스트는 쉽게 말해 읽기 쉬운 테스트이다. 얼마전에 읽었던 `클린 코드`에서 테스트에 관한 챕터를 읽고 가장 감명 깊게 느꼈던건 테스트 코드에서 제일 중요한 항목이 가독성이라는 것이다. 심지어 실제 코드에서 보다도 더. 읽기 복잡하고 어려운 코드는 유지 보수가 어렵고 테스트가 무엇을 말하는 지 본질을 놓치기 쉽다. 이것도 기존에 작성되어있었던 코드로 예시를 들어보자.

  @Test
  @DisplayName("로컬 캐시가 없다면 글로벌 캐시를 조회한다")
  public void useGlobalCache() {
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);
    when(globalCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);

    ValueWrapper result = cache.get(key);

    assertEquals(result, valueWrapper);
  }

이 테스트 코드가 말하고 싶은 바는 @DisplayName에 도움을 받아 적어놨지만 코드만 봐서는 이해가 쉽게 가지 않는다.

우선 예시로 사용할 key, value 값을 각각 만들고 캐시 값을 감싼 ValueWrapper를 mock으로 만든다. 이후 cache조회 여부에 따라 값을 반환하도록 stub한다. 사용된 대부분의 코드는 테스트 환경을 만들기위해 stub하는 부분들이 많다. 하지만 이 테스트의 관심사는 이런 자잘한 부분이 아니다. 아래 리팩토링한 결과를 보자.

  @Test
  @DisplayName("로컬 캐시가 없다면 글로벌 캐시를 조회한다")
  public void useGlobalCache() {
    ValueWrapper globalCacheValue = mock(ValueWrapper.class);
    setLocalCacheValue(null);
    setGlobalCacheValue(globalCacheValue);

    ValueWrapper result = chainedCache.get(anyString());

    assertEquals(result, globalCacheValue);
  }

 일단 코드가 짧다. 이 것만으로도 큰 수확이다. 이외에도 각 부분들이 어떤 역할을 하는지 명확하다. globalCache값을 mocking해서 만들고, 로컬 캐시에는 null, 글로벌 캐시에는 mocking한 객체를 넣는다. 그 아래에서는 캐시를 조회하고 조회한 결과와 mocking한 결과를 비교한다. 테스트 코드의 구성은 내가 확인하고 싶은 관심사에 대해서만 추상화 되어있다. 아래는 실제 테스트 환경 구성에 사용된 메서드들이다. 

  @Before
  public void setUp() {
    List<Cache> caches = new ArrayList<>();
    caches.add(localCache);
    caches.add(globalCache);
    this.chainedCache = new ChainedCache(caches);
  }

  private void setLocalCacheValue(ValueWrapper valueWrapper) {
      if (valueWrapper == null) {
        when(localCache.get(any())).thenReturn(null);
        return;
      }
      when(localCache.get(any())).thenReturn(valueWrapper);
      when(valueWrapper.get()).thenReturn(LocalDateTime.now());
    }

    private void setGlobalCacheValue(ValueWrapper valueWrapper) {
      if (valueWrapper == null) {
        when(globalCache.get(any())).thenReturn(null);
        return;
      }
      when(globalCache.get(any())).thenReturn(valueWrapper);
      when(valueWrapper.get()).thenReturn(LocalDateTime.now());
    }

 

결론

테스트 코드 커버리지의 함정에 속아서는 안된다.  검증이 필요한 부분을 생각하여 통합/단위 테스트 어디에 더 초점을 둘지, 둘 다 중요하게 여길지 판단하는 것이 중요하다. 테스트 코드는 가능한 읽기 쉽게 만드는 편이 좋다!