To create a robust test suite, we need to be in control of the environment the test suite runs in. That includes being in control of the requests the application sends to the Cocoacasts API. We don't want the application to hit the Cocoacasts API when the test suite is run. In this episode, I show you how to stub the Cocoacasts API using the OHHTTPStubs library.

Installing the OHHTTPStubs Library

We start by installing the OHHTTPStubs library. The Swift Package Manager makes this straightforward. Select the project in the Project Navigator on the right. Select the project in the Project section and click the Package Dependencies tab at the top.

Installing the OHHTTPStubs Library

The Package Dependencies tab shows you an overview of the Swift packages the project depends on. Click the + button at the bottom of the table to add a Swift package using the Swift Package Manager.

Installing the OHHTTPStubs Library

Enter the package URL of the OHHTTPStubs library in the search field in the top right. We leave the configuration as is. Click Add Package in the bottom right.

Installing the OHHTTPStubs Library

Select both package products and add them to the CocoacastsTests target. Click Add Package to add the package products to the CocoacastsTests target.

Installing the OHHTTPStubs Library

Stubbing the Cocoacasts API

Revisit APIClientTests.swift and add an import statement for OHHTTPStubs and OHHTTPStubsSwift. OHHTTPStubs implements the library's core functionality. OHHTTPStubsSwift defines a range of APIs optimized for Swift.

import XCTest
import Combine
import OHHTTPStubs
import OHHTTPStubsSwift
@testable import Cocoacasts

final class APIClientTests: XCTestCase {

	...

}

We first define a private, variable property, stubsDescriptors, of type [HTTPStubsDescriptor] and set its initial value to an empty array. This property serves a similar function as the subscriptions property we defined earlier. The stubsDescriptors property stores the stubs we install. A stubs descriptor references a stub and we use it to remove the stub when we no longer need it.

import XCTest
import Combine
import OHHTTPStubs
import OHHTTPStubsSwift
@testable import Cocoacasts

final class APIClientTests: XCTestCase {

    // MARK: - Properties

    private var subscriptions: Set<AnyCancellable> = []

    // MARK: -

    private var stubsDescriptors: [HTTPStubsDescriptor] = []
	
	...

}

In the tearDownWithError() method, we loop over the array of stubs descriptors using the forEach(_) method. In the closure we pass to the forEach(_:) method, we invoke removeStub(_:) on HTTPStubs, passing in the stubs descriptor. As the name suggests, the removeStub(_:) method removes the stub the stubs descriptor references. This is an important step. You need to remove the stubs you installed when you no longer need them.

import XCTest
import Combine
import OHHTTPStubs
import OHHTTPStubsSwift
@testable import Cocoacasts

final class APIClientTests: XCTestCase {

    // MARK: - Properties

    private var subscriptions: Set<AnyCancellable> = []

    // MARK: -

    private var stubsDescriptors: [HTTPStubsDescriptor] = []

    // MARK: - Set Up & Tear Down

    override func setUpWithError() throws {

    }

    override func tearDownWithError() throws {
        stubsDescriptors.forEach {
            HTTPStubs.removeStub($0)
        }
    }
	
	...

}

We stub the API in the testEpisodes() method by invoking the stub(condition:response:) function. The first argument is a closure that defines which requests to stub. The second argument is also a closure and defines the response for the stubbed requests. We keep it as simple as possible for now.

The first argument is a closure of type HTTPStubsTestBlock. The closure accepts the request as an argument and returns a boolean. The request is stubbed if the closure returns true. Because we want to stub requests made to the /api/v1/episodes endpoint, we return true if the path of the URL of the request is equal to /api/v1/episodes. That suffices for now.

// MARK: - Tests for Episodes

func testEpisodes() throws {
    stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in

    }

    let expectation = self.expectation(description: "Fetch Episodes")
	
	...
}

The second argument is a closure of type HTTPStubsResponseBlock. The closure also accepts the request as an argument, but it returns a HTTPStubsResponse object. The OHHTTPStubs library defines a number of APIs to create a HTTPStubsResponse object. The plan I have in mind is simple. We load a response from the unit testing bundle and use that response to create a HTTPStubsResponse object.

We first add the response to the unit testing bundle. Add a group with name Stubs to the CocoacastsTests group. We add a file with name episodes.json to the Stubs group. The file contains a response that contains ten episodes. Make sure you add episodes.json to the CocoacastsTests target, not the Cocoacasts target.

We obtain the path for the stub by invoking a convenience function of the OHHTTPStubs library, OHPathForFile(_:_:). It accepts the file name of the stub as the first argument and the class of the object invoking the function as the second argument. The second argument is used to obtain a reference to the bundle that contains the stub. This really is a convenience function. It uses the Bundle API under the hood.

The return type of the OHPathForFile(_:_:) function is String?, the path for the stub. We use an if statement to safely unwrap the path for the stub and throw a fatal error in the else clause. We want to be notified if a stub is missing from the bundle.

// MARK: - Tests for Episodes

func testEpisodes() throws {
    stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in
        if let filePath = OHPathForFile("episodes.json", type(of: self)) {

        } else {
            fatalError("Unable to Find Stub in Bundle")
        }
    }

    let expectation = self.expectation(description: "Fetch Episodes")
	
	...
}

Remember that the closure needs to return a HTTPStubsResponse object. We invoke another helper function to create a HTTPStubsResponse object from the path for the stub. The fixture(filePath:status:headers:) function accepts the path for the stub as its first argument, the status code of the response as its second argument, and an optional dictionary of headers as its third argument. We pass 200 as the second argument and an empty dictionary as the third argument.

// MARK: - Tests for Episodes

func testEpisodes() throws {
    stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in
        if let filePath = OHPathForFile("episodes.json", type(of: self)) {
            return fixture(filePath: filePath, status: 200, headers: [:])
        } else {
            fatalError("Unable to Find Stub in Bundle")
        }
    }

    let expectation = self.expectation(description: "Fetch Episodes")
	
	...
}

The stub(condition:response:) function returns a HTTPStubsDescriptor object. We store the stubs descriptor in a constant with name stubsDescriptor and append it to the stubsDescriptors property.

// MARK: - Tests for Episodes

func testEpisodes() throws {
    let stubsDescriptor = stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in
        if let filePath = OHPathForFile("episodes.json", type(of: self)) {
            return fixture(filePath: filePath, status: 200, headers: [:])
        } else {
            fatalError("Unable to Find Stub in Bundle")
        }
    }

    stubsDescriptors.append(stubsDescriptor)

    let expectation = self.expectation(description: "Fetch Episodes")

    ...
}

Click the diamond in the gutter on the left to execute the unit test. The unit test should fail. Can you guess why that is?

Stubbing the Cocoacasts API

The unit test failed because the assertion in the value handler failed. We expect the unit test to fail because the stub for the /api/v1/episodes endpoint contains 10 episodes, not 25. The failure of the unit test proves that we successfully stubbed the /api/v1/episodes` endpoint of the Cocoacasts API. Update the assertion and run the unit test one more time.

// MARK: - Tests for Episodes

func testEpisodes() throws {
    ...
    
    apiClient.episodes()
        .sink { completion in
            switch completion {
            case .finished:
                expectation.fulfill()
            case .failure:
                XCTFail("Request Should Succeed")
            }
        } receiveValue: { episodes in
            XCTAssertEqual(episodes.count, 10)
        }.store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

Stubbing the Cocoacasts API

Unit Testing the Unhappy Path

We successfully unit tested the happy path. It is time to face reality and write a unit test for the unhappy path. Rename testEpisodes() to testEpisodes_Success(). Copy the testEpisodes_Success() method and rename the copy to testEpisodes_Failure().

func testEpisodes_Failure() throws {
	...
}

func testEpisodes_Failure() throws {
    ...
}

The testEpisodes_Failure() method tests the unhappy path, that is, the request fails. In the response closure of the stub(condition:response:) function, we create a HTTPStubsResponse object by invoking the init(data:statusCode:headers:) initializer. We pass an empty Data object as the first argument, 401 as the second argument, and an empty dictionary as the third argument.

func testEpisodes_Failure() throws {
    let stubsDescriptor = stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in
        HTTPStubsResponse(data: Data(), statusCode: 401, headers: [:])
    }

    stubsDescriptors.append(stubsDescriptor)

    let expectation = self.expectation(description: "Fetch Episodes")

	...
}

We expect the request to fail and that means we need to update the completion and value handlers we pass to the sink(receiveCompletion:receiveValue:) method. We invoke fulfill() on the expectation in the failure case and invoke the XCTFail(_:file:line:) function in the finished case and in the value handler.

func testEpisodes_Failure() throws {
    let stubsDescriptor = stub { request in
        request.url?.path == "/api/v1/episodes"
    } response: { request in
        HTTPStubsResponse(data: Data(), statusCode: 401, headers: [:])
    }

    stubsDescriptors.append(stubsDescriptor)

    let expectation = self.expectation(description: "Fetch Episodes")

    let apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())

    apiClient.episodes()
        .sink { completion in
            switch completion {
            case .finished:
                XCTFail("Request Should Fail")
            case .failure:
                expectation.fulfill()
            }
        } receiveValue: { episodes in
            XCTFail("Request Should Fail")
        }.store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

Run the unit tests for the APIClient class by clicking the diamond in the gutter on the left of the class declaration. Both unit tests should pass without issues.

Stubbing the Cocoacasts API

What's Next?

Stubbing the APIs an application interacts with is essential if you aim to build a fast and robust test suite. The OHHTTPStubs library has been around for many years and is easy to use. Even though the unit tests we wrote so far pass without issues, we need to optimize their implementation. A test suite should be clean, consistent, and easy to understand. That is something we address in the next episode.