본문 바로가기
아직 카테고리 미정

테스트 더블을 강력한 위력을 이해해보자.

by simplify-len 2020. 12. 28.

 아래 내용은 Effective Unit Testing - 라쎼 코스켈라 에서 발췌한 내용으로 이루어져 있습니다.

0. 들어가기

  Stub과 Dummy는 왜 태어났을까? 이 둘의 처음 등장된 가장 큰 이유는 제품에 다른 제품이 준비되기 전까지 대신 사용할 용품이 필요했기 때문이다. 그러나 오늘날, Stub과 Dummy는 처음 나왔던 목적보다, 테스트 추종자에 의해 쓰음새가 다양해졌다.

  • 다양한 테스트 전용 장치가 만들어지면서, 테스트 대상 코드를 격리
  • 속도 개선
  • 예측 불가능한 요소 제거
  • 특수한 상을 시뮬레이션
  • 감춰진 정보를 얻어내는 용도

이같이 목적은 비슷하면서도 다른 객체를 사용하는데 그 전부를 테스트 더블이라고 한다.

그럼 테스트 더블은 우리에게 어떤 득이 있을까?

1. 테스트 더블의 목적

"세상이 변하길 바란다면 너 자신이 그 변화가 되어라"
- 마하트마 간디

 테스트 더블은 간디가 말하듯, 몸소 실천하여 우리가 코드에 바라던 변화가 되어준다. 

코드는 덩어리다. 서로 참조하는 코드들이 그물처럼 얽혀있다. 각각의 조각은 약속된 동작을 수행한다. 동작을 정의하는 건 우리, 즉 프로그래머다. 어떤 동작은 원자적이라 클래스나 매서드 안에서 모두 처리되는 반면 어떤 동작은 다른 코드 조작과의 교류를 통해 완성된다.

종종 어떤 코드 조각이 원하는 동작을 올바로 수행하는지 검증하려 할 때, 주변 코드를 모두 교체하여 테스트 환경 전반을 통제할 수 있다면 가장 좋다. 이렇게 테스트 대상 코드와 협력 객체를 잘 분리하면 아래 그림처럼 될 것이다.

그림1 테스트 더블을 이용하면 테스트 대상 코드를 주변으로부터 쉽게 떼어낼 수 있어 원하는 대로 마음껏 검사할 수 있다.

 

테스트하려는 코드를 주변에서 분리하는 것이 테스트 더블을 활용하는 가장 기본적인 이유이다. 그럼 이쯤에서 테스트 전용 장치가 필요한 이유가 무엇일까?

  • 테스트 대상 코드를 격리한다.
  • 테스트 속도를 개선한다.
  • 예측 불가능한 실행 요소를 제거한다.
  • 특수한 상황을 시뮬레이션한다.
  • 감춰진 정보를 얻어낸다.

1.1 테스트 대상 코드를 격리한다.

 객체지향 프로그래밍 언어의 관점에서 보면 테스트 대상 코드를 격리한다는 것은 세상의 모든 것을 두가지로 분류한다는 뜻

  • 테스트 대상 코드
  • 테스트 대상 코드와 상호작용하는 코드

테스트 대상 코드를 격리하겠다는 것은 테스트하려는 코드를 그 외의 모든 코드에서 떼어 놓겠다는 의미다. 그 결과로 테스트는 초점이 분명해지고, 이해하기도 쉬워지고, 설정하기도 간편해진다. 그 외의 모든 코드에는 테스트 대상 코드가 호출하는 코드도 포함된다.

package statepattern.lagarcy;

public class Car {
  
  private Engine engine;

  public Car(Engine engine) {
    this.engine = engine;
  }
  
  public void start(){
    engine.start();
  }
  
  public void drive(Route route){
    for (Directions directions: route.directions()) {
      directions.follow();
    }
  }
  
  public void stop(){
    engine.stop();
  }
}

 등장인물로 Car, Engine, Directions로 이루어진 Route(주행 경로)로 구성되는데, 이때 Car를 대상으로 테스트한다면, 협력객체는 Engine과 Router 이다. Directions이 아닌 이유는 그림2 처럼 Route 에 속해있기 때문이다. 

그림 2 Car는 Engine과 Route를 직접 사용하지만, Directions는 Route를 통해 간접적으로만 사용한다.

1.2 테스트 속도를 개선한다.

 테스트 더블을 이용해서 사전에 계산해둔 경로를 반환하도록 하면 쓸데없이 기다리는 시간이 줄어들어 테스트는 빨라집니다. 왜일까요? 그림2 에서 보여지는 것과 같이 어떤 Directions으로 가야할 지 원본을 활용해 구현해야 한다면, 최단 경로를 구할 때 초기화해야 하는 것들이 많아지기 때문에 비용이 비싸질 수 있습니다.

1.3 예측 불가능한 실행 요소를 제거한다.

 테스트란 동작을 정의하고 명세와 일치하는지 확인하는 작업이다. 대상 코드가 완벽히 결정적이라서 불확정 요소가 전혀 없다면 꽤 간단하고 명확한 일일 것이다. 코드가, 물론 테스트 코드도 결정적이 되려면 몇 번을 테스트하건 항상 같은 결과가 나오도록 해야 한다.

 예측할 수 없는 요인을 다뤄야 할 때에도 역시나 테스트 더블이 해결책이 될 수 있다.

1.4 특수한 상황을 시뮬레이션한다.

네트워크를 끊어버린다는 등, 특수한 상황을 경험시키기 위해서는 테스트 더블로 대체해서 예외를 발생시키면 된다.

1.5 감춰진 정보를 얻어낸다.

   존재이유 중 마지막은 테스트가 얻을 수 없었던 정보에 접근하기 위해서다.

특히 자바에서라면 정보 노출이 다른 객체의 private 속성을 읽고 쓴다는 뜻으로 해석될 여지가 있다. 그런 목적으로 써야 할 경우도 있겠지만(객체 바깥에서 안쪽을 들춰보는 건 일반적으로 잘못된 생각이다. 내가 이런 유혹을 느낀 경우는 예외 없이 필요한 추상화를 빠뜨리고 잘못 설계했기 때문이다.), 그보다는 테스트 대상 코드와 협력 객체 사이의 상호작용을 확인하는 것.

 다시 그림2 의 예제 코드 Car를 가져오자.

public class Car {
  
  private Engine engine;

   ...
  
  public void start(){
    engine.start();
  }
  
  ...
}

 누군가  Car의 시동을 걸면 Car는 engine을 가동한다. 이 동작이 실제로 확인하려면 어떻게 해야될까 Car 의 private필드인 engine을 테스트가 접근할 수 있게끔 노출하고 Engine에는 가동 여부를 알려주는 메서드를 추가하는 방법이 있다. 

2. 테스트 더블의 종류

 테스트 더블이란 총 네 종류의 객체를 포괄하는 통칭이다.

그림 3 테스트 더블은 총 4가지를 포함하는 통칭이다

 테스트 더블의 분류는 이와 같다. 그럼 이것들이 대체 무엇이고, 어떻게 다르고, 각각의 주요 쓰임새는 무엇인지 살펴보자.

2.1 테스트 스텁은 유난히 짧다.

 스텁(Stub)의 정의 - 끝이 잘렸거나 유난히 짧은 것

테스트 스텁의 목적은 원래의 구현을 최대한 단순하 것으로 대체하는 것이다. 이해를 돋돕기 위해 로그 서버로 전송하는 코드를 가정해보자. 

public class LoggerStub implements Logger {
    public void log(LogLevel level, String message){
      // 여전히 아무 일도 하지 않는다.
    }
    public LogLevel getLogLevel(){
      return LogLevel.WARN; // 하드코딩된 값을 반환
    }
  }

  여기서 logger는 아무 역할도 하지 않기 때문에 스텁이라 할 수 있다. 그렇다고 아무것도 하지 않는 것이 능사는 아니다. getLogLevel()와 같이 메서드를 반환하도록 한다.

이렇게 Logger가 아닌, LoggerStub을 사용하는 이유로는 아래와 같다.

  1. 테스트는 대상 코드가 로깅하는 내용에는 전혀 관심 없다.
  2. 가동 중인 로그 서버가 없으니 로깅은 어차피 실패했을 거다.
  3. 테스트 스워트가 콘솔로 대량의 정보를 쏟아내는 건 바라지 않는다.(파일에 쓰는 건 별로 상관없다.)

2.2 가짜 객체는 뒤끝 없이 처리한다.

 테스트 스텁에 비하면 가짜 객체는 정성이 꽤 들어간 테스트 더블이다. 반환값을 하드코딩하는 테스트 스텁의 특성상 테스트 각자의 시나리오에 맞는 스텁을 따로 구현해야 한다. 그에 반해 가짜 객체는 진짜 객체의 행동을 흉내 내지만, 진짜 객체를 사용할 때 생기는 부수 효과나 연쇄 동작이 일어나지 않도록 경량화하고 최적화한 것이라 볼 수 있다.

  예를 들어 영속성이라면, 인메모리 데이터베이스를 구현하는 가짜 객체를 만드는 것이다.

2.3 테스트 스파이는 기밀을 훔친다.

 다음 메서드는 어떻게 검사할 것인가?

public String concat(String first, String second){ ... }

 이 메서드에서 가장 궁금한 것은 결국 "올바른 값을 반환하는가?" 가 아니겠는가? 그렇다면 다음 메서드는 어떤가? 어떻게 검사할 것인가?

public void filter(List<?> list, Predicate<?> predicate) { ... }

 단언해야 할 반환값이 보이지 않을 경우에는 어떻게 올바르게 동작했는지 확인할 수 있을까? 

테스트 스파이는 입력 인자로 사용되는 객체가 테스트에 필요한 정보를 알려주는 API를 제공하지 않을 때 유용하다.

public class Dlog {
  
  private final DLogTarget[] targets;
  // DLog에 다수의 DLogTarget을 건넨다.
  public Dlog(DLogTarget... targets) {
    this.targets = targets;
  }
  // 각각의 동일한 메시지를 전달한다.
  public void write(Level level, String message){
    for (DLogTarget each: targets){
      each.write(level, message);
    }
  }
  // DLogTarget에 정의된 메서드는 write()뿐이다.
  public interface DLogTarget {
    void write(Level level, String message);
  }
}

 위 코드를 테스트 해야 한다라고 헀을 때, DLogTarget의 구현체가 없기 때문에 테스트 입장에서는 참 골치아픈 상황이다.

이럴 때 테스트 스파이를 잠입시키자. 다음 코드에서는 영특한 프로그래머가 비밀 요원을 잠입시켜 사건을 해결하는 모습을 보여준다.

import static org.junit.Assert.assertTrue;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

class DlogTest {

  @Test
  void writesEachMessageToAllTargets() throws Exception {
    SpyTarget spy1 = new SpyTarget();
    SpyTarget spy2 = new SpyTarget();
    Dlog log = new Dlog(spy1, spy2);
    log.write(Level.INFO, "Message");
    assertTrue(spy1.received(Level.INFO, "message"));
    assertTrue(spy2.received(Level.INFO, "message"));
  }

  private class SpyTarget implements Dlog.DLogTarget {

    private List<String> log = new ArrayList<>();

    @Override
    public void write(Level level, String message) {
      log.add(concatenated(level, message));
    }

    private String concatenated(Level level, String message) {
      return level.getName() + ":" + message;
    }
    
    boolean received(Level level, String message){
      return log.contains(concatenated(level,message));
    }
  }
}

 SpyTarget이 나중에 assertTrue에서 잘 받았는지 확인하는 메소드를 지닌다. 즉, 요약하면 테스트 스파이는 목격한 일을 기록해두었다가 나중에 테스트가 확인할 수 있게끔 만들어진 테스트 더블이다. 이 차이가 Mock 객체와 차이를 만들어 내는데, 테스트 스파이를 잠복 경찰에 비유했다면 Mock 객체는 갱단 본거지까지 침투한 원격 조종 사이보그라 할 수 있다.

2.4 Mock 객체는 예기치 않은 일을 막아준다.

 Mock 객체는 특수한 형태의 테스트 스파이다. 특정 조건이 발생하면 미리 약속된 행동을 취한다. UserRepository 인터페이스를 예로 Mock 객체를 설명하자면, findById()의 파라미터로 123을 주면 null을 반환하고, 124를 주면 앞서 저장한 User 객체를 반환하는 식이다. 아직은 메서드를 파라미터에 따라 다르게 처리하게 한 스텁 수준이다.

 

3. 테스트 더블 활용 가이드

3.1 용도에 맞는 더블을 선택하라.

가장 명확한 원칙은 테스트를 가장 읽기 쉽게 만들어주는 선택을 하라는 것.

그래도 힘들다면 아래와 같은 방법을 시도하자.

  • 두 객체 간 상호작용의 결과로 특정 메서드가 호출되었는지 확인하고 싶다면 Mock객체를 써야 할 가능성이 높다.
  • Mock 객체를 사용하기로 했는데, 테스트 코드가 생각만큼 깔끔하게 정리되지 않는다면 더 단순한 테스트 스파이를 손수 작성해서도 똑같은 마술을 부릴 수 있는지 생각해보자.
  • 협력 객체는 자리만 지키면 되고 협력 객체가 대상 객체에 넘겨줄 응답도 테스트에서 통제할 수 있다면 스텁이 정답이다.
  • 필요한 서비스나 컴포넌트를 미처 준비하지 못해 스텁을 대용품으로 사용하고 있는데, 시나리오가 너무 복잡해서 벽에 부딪혔거나 테스트 코드가 관리하기 어려울 만큼 복잡해졌다면 가짜 객체를 구현하는 걸 고려해보자.

간단한 원칙으로, 스텁은 질문하고 Mock은 행동한다.

3.2 준비하고, 시작하고 단언하라.

단위 테스트의 구조에 관한 규약으로, 준비-시작-단언 이라는 규약이 있다. 필요한 객체들을 준비하고, 실행하고, 결과를 단언하는 총 세 단계로 테스트를 구성한다.

 그 외에도 '행위주도개발' 진영에서는 GIVEN, WHEN,THEN 이라는 용어가 구조가 사용된다.

준비-시작-단언 이라는 구조는 꽤 광범위하게 쓰이며, 테스트가 산만해지지 않게 지탱해준다. 

혹 세 영역 중 하나가 비대하다고 느껴진다면, 너무 많은 것을 한꺼번에 검사하려는 테스트일 가능성이 높다. 더 작은 단위의 기능을 집중적으로 검사하려는 테스트일 가능성이 높다. 더 작은 단위의 기능을 집중적으로 검사하는 테스트로 나눌 필요가 있다는 신호다.

3.3 구현이 아니라 동작을 확인하라.

 Mock 객체에 예상을 너무 상세하게 설정하는 것은 비효율적이며, 실수다. 테스트와 관련된 모든 것을 Mock객체로 만들고, 객체 간의 사소한 메서드 호출 하나까지 깐깐하게 정의하는 걸 말한다.

테스트는 무엇가 잘못 변경되면 즉시 실패해서 우리에게 알려주리라는 믿음을 주어야 한다. 그런데 그게 문제가 될 수 있다. 검증 목적과 관련없는 지극히 사소한 변경마저도 테스트를 실패하게 한다면 마치 못질을 너무 많이 해서 구멍이 송송 뚫린 불쌍한 목판처럼 되어 버린다.

테스트는 오직 한 가지만 검사해야 하고 그 의도를 명확히 전달하도록 작성되어야 한다. 그러니 대상 객체를 지긋이 바라보며 검사하려는 동작은 무엇이고 굳이 확인할 필요 없는 부수적인 구현은 무엇인지 자문해볼 필요가 있다.

Mock에는 정말 바라고 의도한 핵심 동작만 설정하면 된다. 부수적인 구현은 호출 횟수에 전혀 개의치 않는 관대한 Mock객체나 스텁만으로 충분하다.

구현이 아니라 동작을 검증하자. 

3.4 자신의 도구를 선택하라.

3.5 종속 객체를 주입하라

댓글