Test Stub
25 Dec 2021설명에 사용하는 PlayGround를 확인 가능합니다.
1. What is Stub?
이번 글에서는 대표적인 Test Double 중 하나인 Stub에 대해 정리합니다.
Stub은 개발자가 테스트하고자 하는 대상(SUT
, System Under Test)에게 indirect input
을 제공하기 위해 사용되는 객체입니다. 대부분의 객체나 함수(Unit)는 많은 경우 주입된 input에 맞추어 output을 리턴하는 형태로 구성됩니다. 그리고, 우리가 작성하는 테스트는 자연스럽게 “내가 주입한 input에 맞추어 output이 나오는가”를 검증합니다. 이 때, 테스트 작성자는 테스트가 Unit을 적절히 검증하는지 확인하기 위해 최대한 다양한 input을 구성합니다. 이러한 경우 제공하는 input을 indirect input이라고 부릅니다. 또한, SUT
에 indirect input
을 제공하는 것은 Unit에 직접 주입하는 형태로 구성되기도 하지만, 또 다른 객체에서 제공하기도 합니다. 이 또 다른 객체는 SUT
가 의존하는 객체라는 의미에서 DOC
(Depended on Component)라고 부르고, Stub
은 이 DOC
에서 제공하는 input을 대신 제공하는 객체입니다.
SUT
의 행동(behavior)이 input 값에 의존하여 결정될 경우, 이 input을indirect input
이라고 부릅니다.Stub
은indirect input
을 제공하는 테스트 목적의 객체입니다.
2. Stub을 통한 UnitTest 수행 과정
알려진 것처럼 일반적인 테스트는 4단계로 진행됩니다.(setup
, exercise
, verify
, teardown
)
- 테스트 작성자는 이 중 Setup 단계에서
SUT
에 input을 제공하는 시점에서 Stub을 사용할지 여부를 결정합니다. 즉,SUT
가 의존하고 있는 DOC의 인터페이스를 구현합니다. - Exercise 단계에서는
Stub
은SUT
가 input을 요구하는 시점에서 적절한 indirect input을 제공합니다. - Verify 단계에서는
SUT
의 상태가 적절한지 판별합니다.
이를 좀 더 자세히 살펴보기 위해 예시를 살펴보겠습니다. 여기서는 사용자의 프로그램 구독을 on/off하는 HTTP API을 처리하는 SubscriptionWorker
를 생각해보겠습니다. 해당 API는 다음과 같은 json 응답을 제공합니다.
{
"subscribed": true,
"subscriptionCount": 27038
}
이 json을 처리하기 위해 다음과 같은 형태로 SubscriptionWorker
를 구성해볼 수 있습니다.
상세 코드 보기
이렇게 구현한 코드는 아래처럼 호출됩니다.
let urlRequest = URLRequest(url: URL(string: "https://hcn1519.github.io")!)
Subscription.Worker.update(request: .init(urlRequest: urlRequest), completion: { result in
// do something
print(result)
})
이 상황에서 우리는 Subscription.Worker.update(request:completion:)
을 테스트하고 싶습니다. 좀 더 정확히는 “HTTP 요청을 통해 획득한 구독 정보를 앱에서 사용 가능한 모델로 잘 전환”되는지에 대한 UseCase를 테스트하고 싶습니다. 이 때, 테스트 작성자는 여러가지 형태의 응답(indirect input
)을 update()
에 제공하여 해당 함수가 올바르게 동작하는지를 확인하고 싶을 수 있습니다. 그런데 문제는 update()
함수는 dataTask(with:completionHandler)
를 호출하므로 외부 서버에 의존적입니다. 즉, 다양한 응답을 구성하려면 서버에서 이를 대응해주어야 합니다.
이런 경우에 Stub
을 사용하면 서버의 도움 없이도 원하는 테스트를 수행할 수 있습니다.
상세 코드 보기
이전의 코드와 변경된 코드의 가장 큰 차이점은 Stub을 주입할 수 있는지 여부입니다. 만약 Stub을 주입하는 것을 활용한다면, update()
함수는 실제 URL에 접근하지 않고, 직접 주입한 StubResponse
를 반환합니다. 즉, 서버에서 원하는 응답을 내려주지 않더라도, Stub
을 통해 직접 응답 만들어서 제공할 수 있습니다.
import UIKit
import XCTest
func testSuccess() {
let urlRequest = URLRequest(url: URL(string: "https://hcn1519.github.io")!)
let successData = """
{
"subscribed": true,
"subscriptionCount": 27038
}
""".data(using: .utf8)!
let successURLResponse = HTTPURLResponse(url: urlRequest.url!,
statusCode: 200,
httpVersion: nil,
headerFields: [:])!
let successResponse = Stub.Response(response: successURLResponse,
result: .success(successData))
let successStub = Stub.response(successResponse)
let successRequest = Subscription.Request(urlRequest: urlRequest,
stub: successStub)
Subscription.Worker.update(request: successRequest, completion: { result in
switch result {
case .success(let response):
XCTAssert(response.subscribed == true)
XCTAssert(response.subscriptionCount == 27038)
print("\(#function) success")
case .failure(let error):
XCTAssert(false, "Result should succeed \(error.localizedDescription)")
}
})
}
Note: 여기서는
Stub
의 동작 방식을 간결히 보여주기 위해 직접 코드를 작성하였습니다. 이러한 구현은 Moya와 같은 라이브러리에 잘 반영되어 있으니 실제 코드 작성시에는 이를 활용하면 여러모로 편리합니다.
3. Stub을 사용하는 시점
SUT
의 indirect input
주입을 제어하기 어려운 경우 Stub을 활용하면 다양한 indirect input
을 구성하고 이를 통해 SUT
의 동작을 제어할 수 있습니다. 예를 들어서 Stub
을 사용하여 HTTP 응답을 다양한 성공, 실패 케이스로 구성하면 SUT
의 동작이 원하는대로 수행되도록 제어가 가능합니다. 또한, 테스트 환경에서는 접근이 어려운 모듈(e.g. 결제 모듈)을 사용하는 경우, 해당 모듈을 통해 제공받는 값을 Stub
을 통해 받을 수 있도록 구성하여 test를 수행할 수도 있습니다.