046. (Pragmatic Unit Testing in Kotlin with JUnit) 10. Mock 객체

10. Mock 객체

원제 : Using Mock Objects

이번 포스팅에서는 Mock 객체를 이용하여 협력자의 의존성을 끊어내고 단위 테스트를 작성하는 방법에 대해 알아보자.

10.1. 도전 과제

원제 : A Testing Challenge

이번 포스팅에서 도전할 과제는 다음과 같다.

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

기존에 작성된 코드는 아래와 같다.

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

@Throws(IOException::class, ParseException::class)
fun retrieve(latitude: Double, longitude: Double): Address {

val parms = String.format("lat=%.6flon=%.6f", latitude, longitude)

val response: String = HttpImpl().get("http://open.mapquestapi.com/nominatim/v1/reverse?format=json&$parms")
val obj: JSONObject = JSONParser().parse(response) as JSONObject
val address: JSONObject = obj.get("address") as JSONObject
val country = address.get("country_code") as String

if (country != "us") {
throw UnsupportedOperationException("cannot support non-US addresses at this time")
}

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

AddressRetriever 클래스에 대한 테스트는 어떻게 작성해야할까?

코드의 길이는 길지만, 조건문은 하나이기때문에 상대적으로 쉬워보인다.

Http 프로토콜을 호출하는 HttpImpl 클래스는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class HttpImpl : Http {
@Throws(IOException::class)
override operator fun get(url: String): String {
val client: CloseableHttpClient = HttpClients.createDefault()
val request = HttpGet(url)
val response: CloseableHttpResponse = client.execute(request)
return try {
val entity: HttpEntity = response.getEntity()
EntityUtils.toString(entity)
} finally {
response.close()
}
}
}

HttpImpl 클래스는 아래와 같은 Http 인터페이스를 구현하고 있다.

1
2
3
4
interface Http {
@Throws(IOException::class)
operator fun get(url: String): String?
}

HttpImpl 클래스가 정상적으로 잘 동작하리라는 것은 충분히 검증된 기댓값이므로 별도의 테스트를 작성할 필요는 없다.

하지만 문제는 HttpImpl 클래스가 Http 프로토콜을 호출해 외부에 존재하는 서버 로부터 데이터를 받아온다는 것이다.

즉, AddressRetriever 클래스의 retrieve() 메서드가 Http 프로토콜을 호출하므로 이에 대한 테스트도 직접 호출해야하는 문제가 생긴다.

문제점은 크게 두 가지로 정리가 가능하다.

  • 실제 호출을 수행하는 테스트는 다른 테스트에 비해 속도가 느릴 것이다.
  • Http Api가 항상 가용한지 검증할 수 있는 방법이 없다.

이제 이 문제를 해결하는 방법에 대해 알아보자.

10.2. Stub을 통한 동작 간소화

원제 : Replacing Troublesome Behavior with Stubs

위에서 정의한 문제점을 해결하려면 어떻게 해야할까?

통제할 수 없는 외부 의존성이 문제이므로, 이를 대체하는 방식으로 진행해야 한다.

AddressRetriever 클래스를 다시 살펴보면 Http 호출에서 반환되는 json 형태의 응답값을 이용하여 Address 객체를 생성함을 알 수 있다.

이를 위해 HttpImpl 클래스의 get() 메서드를 수정하여 테스트를 위한 고정된 json 응답값을 반환하도록 처리할 수 있다.

이처럼 테스트 용도로 하드 코딩한 값을 반환하는 구현체를 Stub 이라고 한다.

먼저 응답값을 하드코딩해보자.

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

이제 HttpImpl 클래스를 Stub으로 대체할 수 있도록 AddressRetriever 클래스를 수정한다.

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
class AddressRetriever(
private val http: Http,
) {

@Throws(IOException::class, ParseException::class)
fun retrieve(latitude: Double, longitude: Double): Address {

val parms = String.format("lat=%.6flon=%.6f", latitude, longitude)

val response: String? = http["http://open.mapquestapi.com/nominatim/v1/reverse?format=json&$parms"]
val obj: JSONObject = JSONParser().parse(response) as JSONObject
val address: JSONObject = obj.get("address") as JSONObject
val country = address.get("country_code") as String

if (country != "us") {
throw UnsupportedOperationException("cannot support non-US addresses at this time")
}

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

이제 AddressRetriever 클래스는 Http 인터페이스 구현체를 외부로부터 주입받는다.

이 구현체가 프로덕션인지, Stub인지는 이제 관심사가 아니게 되었다.

따라서 아래와 같은 단위 테스트를 수행할 수 있게 되었다.

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
34
35
36
37
38
39
40
41
42
43
44
45
46
class AddressRetrieverTest {
@Test
@Throws(IOException::class, ParseException::class)
fun answersAppropriateAddressForValidCoordinates() {
val http = object : Http {
override fun get(url: String): String = ("{\"address\":{"
+ "\"house_number\":\"324\","
+ "\"road\":\"North Tejon Street\","
+ "\"city\":\"Colorado Springs\","
+ "\"state\":\"Colorado\","
+ "\"postcode\":\"80903\","
+ "\"country_code\":\"us\"}"
+ "}")
}

val retriever = AddressRetriever(http)
val 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"))
}

@Test
@Throws(IOException::class, ParseException::class)
fun returnsAppropriateAddressForValidCoordinates() {
val http = object : Http {
override fun get(url: String): String = ("{\"address\":{"
+ "\"house_number\":\"324\","
+ "\"road\":\"North Tejon Street\","
+ "\"city\":\"Colorado Springs\","
+ "\"state\":\"Colorado\","
+ "\"postcode\":\"80903\","
+ "\"country_code\":\"us\"}"
+ "}")
}
val retriever = AddressRetriever(http)
val 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"))
}
}

10.3. Testable한 코드를 위한 설계 변경

원제 : Changing Our Design to Support Testing

위의 예제에서는 AddressRetriever 클래스의 설계를 변경하므로서 외부 의존성을 끊어내고, 단위 테스트를 쉽게 작성할 수 있었다.

이처럼 외부 의존성에 결합을 느슨하게하고 세부사항을 분리하는 작업은 보다 나은 설계를 위한 작업이라고 볼 수 있다.

10.4. Stub을 통한 인자 검증

원제 : Adding Smarts to Our Stub: Verifying Parameters

Http에 대해 작성한 Stub을 다시 살펴보자.

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

하드코딩된 값은 테스트를 용이하게 해주지만, 항상 동일한 동작을 수행하기때문에 완벽한 테스트라곤 볼 수 없다.

이를 해소하기위해 Stub 자체에 예외를 처리할 수 있는 로직을 추가하고, 이를 통해 테스트를 실패하도록 처리하는 방법을 생각해볼 수 있다.

먼저 예외처리 로직을 아래와 같이 추가해보자.

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
@Test
@Throws(IOException::class, ParseException::class)
fun answersAppropriateAddressForValidCoordinates() {
val http = object : Http {
override fun get(url: String): String {
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\"}"
+ "}")
}
}

val retriever = AddressRetriever(http)
val 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"))
}

의도와는 다른 위도 경도 값이 다르면 Stub을 통해 테스트를 실패하도록 처리하였다.

10.5. Mock을 사용한 테스트 단순화

원제 : Simplifying Testing Using a Mock Tool

지금까지 단순한 Stub을 만들고, 특정 로직을 부여해보았다.

이를 좀 더 개선하여 Mock으로 변환할 수 있는데, 변환을 위해 아래와 같은 작업이 필요하다.

  • 테스트에 어떤 인자를 기대하는지 명시할 것 (Stub에 작성한 예외 로직의 반대)
  • get() 메서드에 넘겨진 인자들을 저장할 것
  • get() 메서드에 저장된 인자들이 기대하는 인자들인지 검증할 것

위 작업들을 처음부터 다 하는 것은 작업량이 매우 많으므로 Mockito 등의 Mocking 라이브러리를 이용해 쉽게 작성할 수 있다.

아래는 Http 인터페이스를 Mocking한 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
@Throws(IOException::class, ParseException::class)
fun answersAppropriateAddressForValidCoordinates() {
val http = mock(Http::class.java)
`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\"}"
+ "}"
)

val retriever = AddressRetriever(http)
val 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"))
}

Mocking 라비브러리를 이용하면 특정 클래스를 만들고, 특정 동작에 대한 응답값을 지정할 수 있다.