10장. 목 객체 사용.

이 장에서는 목 객체를 도입하여 고통을 주는 협력자에 대한 의존성을 끊는 방법과 항상 존재하는 장애물을 넘을 수 있게 도와주는 도구 활용법에 대해 알아본다. 목(mock)과 함께 단위테스트의 터널 끝에서 한 줄기의 빛을 볼 수 있을 것이라고 한다.


주소를 입력하는 대신 사용자는 지도에서 Profile 주소를 나타내는 지점을 선택할 수 있다.
애플리케이션은 선택된 지점은 위도와 경도 좌표를 AddressRetriever 클래스의 retrieve() 메서드로 넘긴다.
이 메서드는 좌표를 기반으로 생성된 Address 객체를 반환해야 한다.

import java.io.*;
import org.json.simple.*;
import org.json.simple.parser.*;
import util.*;

public class AddressRetriever {
   public Address retrieve(double latitude, double longitude)
         throws IOException, ParseException {
      String parms = String.format("lat=%.6flon=%.6f", latitude, longitude);
      String response = new HttpImpl().get(
        "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&"
        + parms);

      JSONObject obj = (JSONObject)new JSONParser().parse(response);

      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
            "cannot support non-US addresses at this time");

      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}

HTTPImpl 클래스는 Http 인터페이스를 구현하고 아파치의 HttpComponents 클라이언트와 상호작용하여 REST 호출을 실행한다.

import java.io.*;
import org.apache.http.*;
import org.apache.http.client.methods.*;
import org.apache.http.impl.client.*;
import org.apache.http.util.*;

public class HttpImpl implements Http {
   public String get(String url) throws IOException {
      CloseableHttpClient client = HttpClients.createDefault();
      HttpGet request = new HttpGet(url);
      CloseableHttpResponse response = client.execute(request);
      try {
         HttpEntity entity = response.getEntity();
         return EntityUtils.toString(entity);
      } finally {
         response.close();
      }
   }
}


AddressRetriever 클래스의 retrieve() 메서드에 대한 테스트는 실제 HTTP 호출을 실행하기 때문에 다음 두 가지 중대한 시사점이 있다.

  • 실제 호출에 대한 테스트는 나머지 대다수의 빠른 테스트들에 비해 속도가 느릴 것이다.
  • Nominatim HTTP API가 항상 가용한지 보장할 수 없다. 이는 통제 밖이다.

목표에 집중하기 위해 의존성이 있는 다른 코드와 분리하여 retrieve() 메서드의 로직에 관한 단위 테스트를 작성해야 한다.
HttpImpl 클래스를 신뢰할 수 있다면 남은 것은 HTTP 호출을 준비하는 로직과 그 호출에 대한 응답에서 생성되는 Address 객체를 생성하는 로직을 테스트하는 것이다.

이런 번거로운 동작을 스텁으로 대체할 수 있는데 여기서 스텁이란 테스트 용도로 하드 코딩한 값을 반환하는 구현체라고 할 수 있다.
람다를 활용하여 혹은 익명 내부 클래스로 스텁 구현을 동적으로 생성해주면 된다.

// 익명 내부 클래스 
Http http = new Http() {
    @Override
    public String get(String url) throws IOException {
        return "{\"address\":{"
            + "\"house_number\":\"324\","
            + "\"road\":\"North Tejon Street\","
            + "\"city\":\"Colorado Springs\","
            + "\"state\":\"Colorado\","
            + "\"postcode\":\"80903\","
            + "\"country_code\":\"us\"}"
            + "}";
        }
    };

이제 스텁을 사용하는 방법을 AddressRetriever 클래스에게 알려주자. 
여기서 DI(의존성 주입) 기법을 활용하는데 이것은 단지 스텁을 AddressRetriever 인스턴스로 전달하거나 그것을 주입하는 것을 의미한다.

지금은 AddressRetriever 클래스의 생성자를 이용하여 스텁을 주입하는 방법을 선택한다. 
생성자 의존성 주입을 지원하기 위해 Http 인스턴스를 인자로 하는 생성자를 추가하고 새로운 http 필드에 할당한다.
retrieve() 메서드에서는 http 필드를 역으로 참조하여 get() 메서드를 호출한다.

import java.io.*;
import org.json.simple.*;
import org.json.simple.parser.*;
import util.*;

public class AddressRetriever {
   private Http http;

   public AddressRetriever(Http http) {
      this.http = http;
   }

   public Address retrieve(double latitude, double longitude)
         throws IOException, ParseException {
      String parms = String.format("lat=%.6flon=%.6f", latitude, longitude);
      String response = http.get(
         "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&"
         + parms);

      JSONObject obj = (JSONObject)new JSONParser().parse(response);
      // ...

      JSONObject address = (JSONObject)obj.get("address");
      String country = (String)address.get("country_code");
      if (!country.equals("us"))
         throw new UnsupportedOperationException(
               "cannot support non-US addresses at this time");

      String houseNumber = (String)address.get("house_number");
      String road = (String)address.get("road");
      String city = (String)address.get("city");
      String state = (String)address.get("state");
      String zip = (String)address.get("postcode");
      return new Address(houseNumber, road, city, state, zip);
   }
}


 그 다음 테스트를 작성해서 실행하면 아래와 같은 일들이 발생한다고 한다.

  • 테스트는 Http의 스텁 인스턴스를 생성한다. 스텁은 get(String url) 단일 메서드가 있으며 하드 코딩된 JSON 문자열을 반환한다.
  • 테스트는 AddressRetriever 객체를 생성하고 생성자에 스텁을 전달한다.
  • AddressRetriever 객체는 스텁을 저장한다.
  • 실행될 때 retrieve() 메서드는 먼저 넘어온 파라미터의 포맷을 정한다. 그다음 스텁이 저장된 http 필드에 get() 메서드를 호출한다. retrieve() 메서드는 http 필드가 스텁을 참조하는지 프로덕션 구현을 참조하는지 관여하지 않는다. 메서드가 아는 것은 get() 메서드를 구현한 객체와 상호 작용하고 있다는 점이다.
  • 스텁은 테스트에 하드 코딩된 JSON 문자열을 반환한다.
  • 나머지 retrieve() 메서드는 하드 코딩된 JSON 문자열을 파싱하고 그에 따라 Address 객체를 구성한다.
  • 테스트는 반환된 Address 객체의 요소를 검증한다.

 이제 테스트를 지원하기 위한 설계를 변경해야 하는데,
AddressRetriever 클래스와 상호 작용하는 어떤 클라이언트는 다음과 같이 적절한 Http 인스턴스를 생성하여 넘겨주어야 한다.

AddressRetriever retriever = new AddressRetriever(new HttpImpl())

Http 객체에 대한 의존성은 가능한 훨씬 깔끔한 방식으로 선언되고 인터페이스에 대한 의존성은 결합도를 느슨하게 만들었다.

Http 스텁은 get() 메서드에 넘겨진 위도와 경도 값과는 무관하게 항상 동일하게 하드 코딩된 JSON 문자열을 반환한다.
그러나 실제로는 AddressRetriever 객체가 인자를 정확하게 넘기지 않으면 결함이 발생한다.
따라서 스텁에 Http 클래스의 get() 메서드에 전달되는 URL을 검증하는 보호절을 추가한다.
기대하는 파라미터 문자열을 포함하지 않으면 그 시점에 명시적으로 테스트를 실패 처리한다.

import java.io.*;
import org.json.simple.parser.*;
import org.junit.*;
import util.*;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = (String url) -> 
         { 
           if (!url.contains("lat=38.000000&lon=-104.000000")) 
              fail("url " + url + " does not contain correct parms");
           return "{\"address\":{"
                 + "\"house_number\":\"324\","
                 + "\"road\":\"North Tejon Street\","
                 + "\"city\":\"Colorado Springs\","
                 + "\"state\":\"Colorado\","
                 + "\"postcode\":\"80903\","
                 + "\"country_code\":\"us\"}"
                 + "}";
         };
      // ...
   }
}

이제 스텁을 목으로 변환해 볼 것이다. 그러기 위해서는 아래와 같은 일들이 필요하다.

  • 테스트에 어떤 인자를 기대하는지 명시하기
  • get() 메서드에 넘겨진 인자들을 잡아서 저장하기
  • get() 메서드에 저장된 인자들이 기대하는 인자들인지 테스트가 완료될 때 검증하는 능력 지원하

직접 하는 방법보다는 목 도구를 설계해 둔 모키토를 활용하자.
모키토 설정은 어떤 JAR 파일들을 내려받고 그것을 가리키도록 프로젝트를 설정하면 된다.
한번 설정되면 모키토를 사용하는 테스트 코드는 org.mockito.Mockito 패키지에 있는 모든 것을 정적으로 임포트해야 한다.
다음은 모키토를 사용하는 테스트 코드이다.

import java.io.*;
import org.json.simple.parser.*;
import org.junit.*;
import util.*;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
// ...
import static org.mockito.Mockito.*;

public class AddressRetrieverTest {
   @Test
   public void answersAppropriateAddressForValidCoordinates() 
         throws IOException, ParseException {
      Http http = mock(Http.class);
      when(http.get(contains("lat=38.000000&lon=-104.000000"))).thenReturn(
            "{\"address\":{"
            + "\"house_number\":\"324\","
           // ...
            + "\"road\":\"North Tejon Street\","
            + "\"city\":\"Colorado Springs\","
            + "\"state\":\"Colorado\","
            + "\"postcode\":\"80903\","
            + "\"country_code\":\"us\"}"
            + "}");
      //...
   }
}

retrieve() 메서드가 호출되었을 때 코드는 모키토 목과 상호 작용한다.
모키토 목의 기대 사항이 맞다면 하드 코딩된 JSON 문자열이 반환된다. 그렇지 않으면 테스트는 실패한다.
when(...).thenReturn(...) 패턴은 모키토를 사용하여 목을 설정하는 여러 방법 중 하나이다.
이 외에도 어떤 메서드가 호출되었는지 검증하고 싶으면 모키토의 verify() 메서드를 사용한다.

when...then(...) 패턴을 사용하는 것처럼 목을 사용한 테스트는 진행하길 원하는 내용을 분명하게 기술해야 한다.
목을 안전하게 사용하고 있는지 다음과 같은 사항을 고려할 필요가 있다.
"목이 프로덕션 코드의 동작을 올바르게 묘사하고 있는가? 프로덕션 코드는 생각하지 못한 다른 형식으로 반환하는가? 프로덕션 코드는 예외를 던지는가? null을 반환하는가?" 이들 각 조건에 대해 다른 테스트가 필요할 수도 있다.

목을 끄고 retrieve() 메서드를 HttpImpl 프로덕션 코드와 상호 작용을 한다면 테스트를 할 때 약간 느려질 것이다.
이 때 할 수 있는 한 가지 단순한 방법은 임시로 프로덕션 코드에서 런타임 예외를 던져보는 것이다.
테스트를 실행할 때 예외가 보인다면 프로덕션 코드가 동작하고 있는 것이다. 테스트를 고친 후 throw문을 제거하는 것도 잊지 말자.