HCN DEV

한국어로 보기

Test Stub

Test Stub feature image

You can check out the PlayGround used in the explanation.

1. What is Stub?

In this article, we will discuss Stub, one of the representative Test Doubles.

A Stub is an object used to provide ‘indirect input’ to the subject of testing (SUT, System Under Test). In most cases, objects or functions (Units) are designed to return output based on the input they receive. When we write tests, we naturally verify whether the output matches the input we provide. In this context, a tester constructs various inputs to check if the test appropriately validates the Unit. These provided inputs are referred to as ‘indirect input.’ Furthermore, providing ‘indirect input’ to the SUT can be structured in two ways: directly injecting it into the Unit or providing it from another object. This other object, referred to as a ‘DOC’ (Depended on Component) because it is an object that the SUT depends on, and a Stub is an object that provides the input from this ‘DOC.’

  • When the behavior of the SUT depends on input values, these inputs are called ‘indirect input.’
  • A Stub is an object used for providing ‘indirect input’ to the SUT.

2. Using Stubs for Unit Testing

Typical testing is divided into four phases: (setup, exercise, verify, teardown).

  1. During the Setup phase, the test author decides whether to use Stubs when providing input to the SUT. In other words, they implement the interfaces of the DOCs on which the SUT depends.
  2. During the Exercise phase, the Stub provides the appropriate indirect input to the SUT when the SUT requests it.
  3. During the Verify phase, the test author checks whether the state of the SUT is appropriate.

To illustrate this in more detail, let’s consider an example. Let’s say we have a SubscriptionWorker that handles an HTTP API for toggling a user’s program subscription on/off. This API provides the following JSON response:

{
    "subscribed": true,
    "subscriptionCount": 27038
}

To process this JSON, we can structure the SubscriptionWorker as follows:

View Detailed Code
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()
        }
    }
}

We make calls to the Subscription.Worker.update(request:completion:) function as follows:

let urlRequest = URLRequest(url: URL(string: "https://hcn1519.github.io")!)
Subscription.Worker.update(request: .init(urlRequest: urlRequest), completion: { result in
    // do something
    print(result)
})

In this scenario, we want to test the Subscription.Worker.update(request:completion:) function. More specifically, we want to test whether it correctly converts the subscription information obtained through an HTTP request into a usable model in the app. However, there’s an issue: the update() function makes a call to dataTask(with:completionHandler), making it dependent on an external server. This means that to create various responses, you would need the server to provide them.

In such cases, using a Stub allows you to perform the desired tests without relying on the server.

View Detailed Code
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
    }
}

The main difference between the previous code and the modified code is the ability to inject a Stub. If you use Stub injection, the update() function will return a StubResponse that you directly provide, without making actual requests to the URL. In other words, you can provide the response you want directly through the Stub, even if the server does not provide the desired response.

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: Here, we have written the code directly to demonstrate how Stubs work. In actual code development, you can make use of libraries like Moya, which incorporate this functionality quite effectively.

3. When to Use Stubs

Stub can be used when it’s difficult to control the injection of ‘indirect input’ into the SUT. For example, you can use Stub to create various responses, such as successful and failed cases, to control the behavior of the SUT. Additionally, in test environments where accessing certain modules (e.g., a payment module) is challenging, you can configure those modules to provide values through Stub for testing purposes.

References