[실용주의 단위 테스트] 10장_Mock

Mock 객체를 사용해서 의존성을 끊는 방법을 정리한다.

1. 테스트 도전 과제

다음 클래스의 메서드는, 좌표를 기반으로 Address 객체를 생성해서 반환한다. retrieve() 메서드의 테스트를 작성해보자.
주의할점은, HttpImpl 클래스가 REST 호출을 실행한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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);
}
}

HttpImp 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();
}
}
}

public interface Http {
String get(String url) throws IOException;
}

HTTP 호출을 실행하면 다음 두 문제가 있다.

  1. 실제 호출에 대한 테스트는, 다른 빠른 테스트들에 비해 느리다.
  2. HTTP API 가 항상 가용한지는 보장할 수 없다. 즉, 우리의 통제 밖이다.

2. 번거로운 동작을 Stub 으로 대체

HTTP 호출에서 반환되는 JSON 응답을 이용해서 Address 객체를 생성하는 로직을 검증해보자.
그러기 위해, HttpImpl 클래스의 get() 메서드 동작을 변경해보자. 단지, 테스트를 위해 하드 코딩한 JSON 문자열을 반환하도록 하자.
이렇게, 테스트 용도로 하드 코딩한 값을 반환하는 구현체를 Stub 이라고 한다.

1
2
3
4
5
6
7
8
9
Http http = (String url) -> 
"{\"address\":{"
+ "\"house_number\":\"324\","
+ "\"road\":\"North Tejon Street\","
+ "\"city\":\"Colorado Springs\","
+ "\"state\":\"Colorado\","
+ "\"postcode\":\"80903\","
+ "\"country_code\":\"us\"}"
+ "}";

위와 같이 Stub 을 작성하고, HttpImpl 클래스에 있는 프로덕션 구현 대신에 Stub 을 사용할 것이라고 AddressRetriever 클래스에 알려줘야 한다.
즉, 스텁을 인스턴스로 전달하거나 주입을 해야한다. 다음과 같이 생성자 주입을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
// ...
}
}

이를 기반으로 테스트를 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class AddressRetrieverTest {

@Test
public void answersAppropriateAddressForValidCoordinates()
throws IOException, ParseException {
Http http = (String url) ->
"{\"address\":{"
+ "\"house_number\":\"324\","
+ "\"road\":\"North Tejon Street\","
+ "\"city\":\"Colorado Springs\","
+ "\"state\":\"Colorado\","
+ "\"postcode\":\"80903\","
+ "\"country_code\":\"us\"}"
+ "}";
AddressRetriever retriever = new AddressRetriever(http);

Address address = retriever.retrieve(38.0,-104.0);

assertThat(address.houseNumber, equalTo("324"));
assertThat(address.road, equalTo("North Tejon Street"));
assertThat(address.city, equalTo("Colorado Springs"));
assertThat(address.state, equalTo("Colorado"));
assertThat(address.zip, equalTo("80903"));
}
}

3. 설계 변경

처음에, Http 인스턴스는 retrieve() 메서드에서 생성이 되었다. 즉, AddressRetriever 클래스의 세부사항이었다.
그런데 이제, AddressRetriever 클래스와 상호 작용하는 클아이언트가 다음과 같이 적절한 Http 인스턴스를 생성해서 넘겨줘야 한다.

1
AddressRetriever retriever = new AddressRetriever(new HttpImpl());

설계가 더 나아졌다. Http 객체애 대한 의존성이 깔끔한 방식으로 선언되었다. 인터페이스에 대한 의존성은 결합도를 느슨하게 한다.

4. 인자 검증

스텁에 Http 클래스의 get() 메서드에 전달되는 URL 을 검증하는 보호절을 추가할 수 있다.
기대하는 인자 문자열이 포함되지 않으면 그 시점에 명시적으로 테스트를 실패 처리하는 것이다.
이것이 Mock 이다. Mock 은 의도적으로 흉내 낸 동작을 제공하고, 수신한 인자가 모두 정상인지 여부를 검증하는 테스트 구조물이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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\"}"
+ "}";
};
AddressRetriever retriever = new AddressRetriever(http);
// ...
}
}

5. Mock 을 사용해서 테스트 단순화

mockito 를 사용해보자. 기대사항을 충족하면 mock 은 지정된 값을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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\"}"
+ "}");
AddressRetriever retriever = new AddressRetriever(http);

Address address = retriever.retrieve(38.0,-104.0);

assertThat(address.houseNumber, equalTo("324"));
// ...
}
}

6. 주입 도구

mockito 의 내장 DI 기능을 사용해보자.
MockitoAnnotations.initMocks(this) 는 다음과 같이 실행된다.

  1. 테스트 클래스에서 @Mock 이 붙은 필드를 가져와서 각각에 대해 Mock 인스턴스를 합성한다. mock(Http.class) 를 직접 호출한 것과 동일하다.
  2. 그 다음에, @InjectMocks 가 가 붙은 필드를 가져와서 Mock 객체들을 거기에 주입한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class AddressRetrieverTest {
@Mock private Http http;
@InjectMocks private AddressRetriever retriever;

@Before
public void createRetriever() {
retriever = new AddressRetriever();
MockitoAnnotations.initMocks(this);
}

@Test
public void answersAppropriateAddressForValidCoordinates()
throws IOException, ParseException {
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\"}"
+ "}");

Address address = retriever.retrieve(38.0,-104.0);

assertThat(address.houseNumber, equalTo("324"));
assertThat(address.road, equalTo("North Tejon Street"));
assertThat(address.city, equalTo("Colorado Springs"));
assertThat(address.state, equalTo("Colorado"));
assertThat(address.zip, equalTo("80903"));
}
}

Mock 객체를 주입하려고 mockito 는 가장 먼저 적절한 생성자를 탐색한다. 없으면, 적절한 setter 메서드를 탐색한다. 없으면, 필드 타입과 매칭되는 적잘한 필드를 찾는다.
이러한 기능때문에, AddressRetriever 클래스의 생성자를 제거할 수 있다.

1
2
3
4
5
public class AddressRetriever {
private Http http = new HttpImpl();

public Address retrieve(double latitude, double longitude) {
...

Mock 을 사용하면 테스트 커버리지에서 간극이 형성된다. 프로덕션 코드를 직접 테스트하고 있지 않기 때문이다.

그래서, 실제 클래스의 종단 간 사용성을 보여주는 적절한 통합 테스트가 필요하다.


자바와 JUnit 을 활용한 실용주의 단위 테스트 <제프 랭어, 앤디 헌트, 데이브 토마스>

Comments