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
를 구성해볼 수 있습니다.
상세 코드 보기
protocol RequestConvertible {
var urlRequest: URLRequest { get }
}
enum Subscription {
enum SubscriptionError: Swift.Error {
case unExpected(response: HTTPURLResponse)
}
struct Request: RequestConvertible {
let urlRequest: URLRequest
}
struct Response: Decodable {
let subscribed: Int
let subscriptionCount: Bool
}
struct Worker {
static func update(request: RequestConvertible,
completion: @escaping ((Result<Response, Error>) -> Void)) {
let dataTask = URLSession(configuration: .default)
.dataTask(with: request.urlRequest, completionHandler: { data, urlResponse, error in
if let error = error {
completion(.failure(error))
}
guard
let data = data,
let urlResponse = urlResponse as? HTTPURLResponse else {
return
}
switch urlResponse.statusCode {
case 200:
do {
let response = try JSONDecoder().decode(Response.self,
from: data)
completion(.success(response))
} catch {
completion(.failure(error))
}
default:
completion(.failure(SubscriptionError.unExpected(response: urlResponse)))
}
})
dataTask.resume()
}
}
}
이렇게 구현한 코드는 아래처럼 호출됩니다.
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
을 사용하면 서버의 도움 없이도 원하는 테스트를 수행할 수 있습니다.
상세 코드 보기
import Foundation
public protocol RequestConvertible {
var urlRequest: URLRequest { get }
var stub: Stub? { get }
}
public enum Stub {
case response(Response)
public struct Response {
public let response: URLResponse
public let result: Result<Data, Error>
public init(response: URLResponse, result: Result<Data, Error>) {
self.response = response
self.result = result
}
}
public enum Error: Swift.Error {
case emptyStubResponse
case statusCode(Int)
}
}
public enum Subscription {
public enum Error: Swift.Error {
case unExpected(response: HTTPURLResponse)
}
public struct Request: RequestConvertible {
public let urlRequest: URLRequest
public var stub: Stub?
public init(urlRequest: URLRequest, stub: Stub?) {
self.urlRequest = urlRequest
self.stub = stub
}
}
public struct Response: Decodable {
public let subscribed: Bool
public let subscriptionCount: Int
}
public struct Worker {
public static func update(request: Request,
completion: @escaping ((Result<Response, Swift.Error>) -> Void)) {
let dataTask = URLSession(configuration: .default)
.dataTask(request: request, completionHanlder: { data, urlResponse, error in
if let error = error {
completion(.failure(error))
}
guard
let data = data,
let urlResponse = urlResponse as? HTTPURLResponse else {
return
}
switch urlResponse.statusCode {
case 200:
do {
let response = try JSONDecoder().decode(Response.self,
from: data)
completion(.success(response))
} catch {
completion(.failure(error))
}
default:
completion(.failure(Error.unExpected(response: urlResponse)))
}
})
dataTask?.resume()
}
}
}
extension URLSession {
public typealias CompletionHandler = (Data?, URLResponse?, Swift.Error?) -> Void
public func dataTask(request: RequestConvertible,
completionHanlder: @escaping CompletionHandler) -> URLSessionDataTask? {
guard let stub = request.stub else {
return dataTask(with: request.urlRequest, completionHandler: completionHanlder)
}
switch stub {
case .response(let stubResponse):
switch stubResponse.result {
case .success(let data):
completionHanlder(data, stubResponse.response, nil)
case .failure(let error):
completionHanlder(nil, stubResponse.response, error)
}
}
return nil
}
}
이전의 코드와 변경된 코드의 가장 큰 차이점은 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를 수행할 수도 있습니다.