HCN DEV

Read in English

Test Stub

Test Stub feature image

설명에 사용하는 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이라고 부릅니다. 또한, SUTindirect input을 제공하는 것은 Unit에 직접 주입하는 형태로 구성되기도 하지만, 또 다른 객체에서 제공하기도 합니다. 이 또 다른 객체는 SUT가 의존하는 객체라는 의미에서 DOC(Depended on Component)라고 부르고, Stub은 이 DOC에서 제공하는 input을 대신 제공하는 객체입니다.

  • SUT의 행동(behavior)이 input 값에 의존하여 결정될 경우, 이 input을 indirect input이라고 부릅니다.
  • Stubindirect input을 제공하는 테스트 목적의 객체입니다.

2. Stub을 통한 UnitTest 수행 과정

알려진 것처럼 일반적인 테스트는 4단계로 진행됩니다.(setup, exercise, verify, teardown)

  1. 테스트 작성자는 이 중 Setup 단계에서 SUT에 input을 제공하는 시점에서 Stub을 사용할지 여부를 결정합니다. 즉, SUT가 의존하고 있는 DOC의 인터페이스를 구현합니다.
  2. Exercise 단계에서는 StubSUT가 input을 요구하는 시점에서 적절한 indirect input을 제공합니다.
  3. 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을 사용하는 시점

SUTindirect input 주입을 제어하기 어려운 경우 Stub을 활용하면 다양한 indirect input을 구성하고 이를 통해 SUT의 동작을 제어할 수 있습니다. 예를 들어서 Stub을 사용하여 HTTP 응답을 다양한 성공, 실패 케이스로 구성하면 SUT의 동작이 원하는대로 수행되도록 제어가 가능합니다. 또한, 테스트 환경에서는 접근이 어려운 모듈(e.g. 결제 모듈)을 사용하는 경우, 해당 모듈을 통해 제공받는 값을 Stub을 통해 받을 수 있도록 구성하여 test를 수행할 수도 있습니다.

참고자료