We can drastically improve the unit tests we wrote in the previous episodes. Even though the unit tests pass without issues, string literals and code duplication are subtle hints that we should take another look at the unit tests we wrote. At the end of this episode, we have a framework in place that we can use for the remaining unit tests of the APIClient class.

Stubbing the Cocoacasts API

We can stub the Cocoacasts API with a few lines of code, but we don't want to repeat that code every time we need to stub the Cocoacasts API. Let's create a convenience method to make that easy and readable. Readability is an attribute of every good unit test. As a developer, you should be able to focus on the code path the unit test targets, not boilerplate code to set up the environment.

Before we create that convenience method, we define a fileprivate enum that defines an endpoint of the Cocoacasts API. Define a fileprivate enum with name Endpoint. The enum defines a single case for now, episodes.

fileprivate enum Endpoint {

    // MARK: - Cases

    case episodes

}

With the Endpoint enum in place, we define the convenience method I mentioned earlier. Define a fileprivate extension for the APIClientTests class. Define a method with name stubAPI(endpoint:isSuccess:). It accepts an Endpoint object as its first argument and a boolean as its second argument. The second argument defines whether the request fails or succeeds. This comes in useful if we want to unit test the unhappy path. The return type of the stubAPI(endpoint:isSuccess:) method is HTTPStubsDescriptor. Remember that we need to keep a reference to the stubs we install in order to remove them when we no longer need them.

fileprivate extension APIClientTests {

    // MARK: - Stub API

    func stubAPI(endpoint: Endpoint, isSuccess: Bool) -> HTTPStubsDescriptor {
    	
	}

}

We can move the stub(condition:response:) function from the testEpisodes_Success() method to the stubAPI(endpoint:isSuccess:) method. We need to make a few small changes.

func stubAPI(endpoint: Endpoint, isSuccess: Bool) -> HTTPStubsDescriptor {
    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")
        }
    }
}

The path of the endpoint in the condition closure needs to be dynamic. Revisit the Endpoint enum. Define a computed property, path, of type String. In the body of the computed path property, we define a constant, path, of type String. We use a self-executing closure to assign a value to the path constant. This is a technique I use often to group declaration and assignment.

In the self-executing closure, we use a switch statement to switch on self, the Endpoint object. The switch statement is short and simple since Endpoint defines a single case for now. We return episodes for the episodes case. We prepend /api/v1/ to the value stored in path and return the result.

fileprivate enum Endpoint {

    // MARK: - Cases

    case episodes

    // MARK: - Properties

    var path: String {
        let path: String = {
            switch self {
            case .episodes:
                return "episodes"
            }
        }()

        return "/api/v1/\(path)"
    }

}

Revisit the implementation of the stubAPI(endpoint:isSuccess:) method. In the condition closure of the stub(condition:response:) function, we remove the string literal and compare the path of the URL of the request to the value returned by the endpoint's computed path property.

func stubAPI(endpoint: Endpoint, isSuccess: Bool) -> HTTPStubsDescriptor {
    stub { request in
        request.url?.path == endpoint.path
    } 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")
        }
    }
}

We also need to make a few small changes to the response closure. If isSuccess is equal to true, the response closure returns the stub. If isSuccess is equal to false, we create a HTTPStubsResponse object by invoking the init(error:) initializer, passing in an APIError object.

func stubAPI(endpoint: Endpoint, isSuccess: Bool) -> HTTPStubsDescriptor {
    stub { request in
        request.url?.path == endpoint.path
    } response: { request in
        if isSuccess {
            if let filePath = OHPathForFile("episodes.json", type(of: self)) {
                return fixture(filePath: filePath, status: 200, headers: [:])
            } else {
                fatalError("Unable to Find Stub in Bundle")
            }
        } else {
            return HTTPStubsResponse(error: APIError.failedRequest)
        }
    }
}

There is one other change we need to make. We shouldn't pass a string literal to the OHPathForFile(_,_) function. The Endpoint object should provide the name of the stub. Revisit the Endpoint enum and define a computed property, stub, of type String. The implementation is similar to that of the computed path property. In the body, we switch on self, the Endpoint object, returning episodes.json for the episodes case.

fileprivate enum Endpoint {

    // MARK: - Cases

    case episodes

    // MARK: - Properties

    var path: String {
        let path: String = {
            switch self {
            case .episodes:
                return "episodes"
            }
        }()

        return "/api/v1/\(path)"
    }

    var stub: String {
        switch self {
        case .episodes:
            return "episodes.json"
        }
    }

}

Revisit the stubAPI(endpoint:isSuccess:) method. Replace the first argument of the OHPathForFile(_, _) function with the value returned by the computed stub property of the Endpoint object.

func stubAPI(endpoint: Endpoint, isSuccess: Bool) -> HTTPStubsDescriptor {
    stub { request in
        request.url?.path == endpoint.path
    } response: { request in
        if isSuccess {
            if let filePath = OHPathForFile(endpoint.stub, type(of: self)) {
                return fixture(filePath: filePath, status: 200, headers: [:])
            } else {
                fatalError("Unable to Find Stub in Bundle")
            }
        } else {
            return HTTPStubsResponse(error: APIError.failedRequest)
        }
    }
}

Before we test the stubAPI(endpoint:isSuccess:) method, I would like to implement another convenience method. Add a group with name Extensions to the CocoacastsTests group. Add a Swift file with name HTTPStubsDescriptor+Helpers.swift. Add an import statement for OHHTTPStubs and OHHTTPStubsSwift. Define an extension for the HTTPStubsDescriptor protocol.

import OHHTTPStubs
import OHHTTPStubsSwift

extension HTTPStubsDescriptor {
	
}

We define a method to easily add a stubs descriptor to an array of stubs descriptors. I borrowed this idea from Combine's store(in:) method. Define a method with name store(in:). The method accepts an array of stubs descriptors as an argument. In the body of the method, we append self, the stubs descriptor, to the array of stubs descriptors.

import OHHTTPStubs
import OHHTTPStubsSwift

extension HTTPStubsDescriptor {

    func store(in stubsDescriptors: [HTTPStubsDescriptor]) {
        stubsDescriptors.append(self)
    }

}

There is one problem we need to fix. The compiler throws an error because we attempt to mutate an immutable array. We resolve this by prefixing the parameter's type with the inout keyword. A parameter is a constant by default. We work around this limitation using an in-out parameter.

Using the Inout Keyword

import OHHTTPStubs
import OHHTTPStubsSwift

extension HTTPStubsDescriptor {

    func store(in stubsDescriptors: inout [HTTPStubsDescriptor]) {
        stubsDescriptors.append(self)
    }

}

With this simple method, we can further simplify the unit tests of the APIClient class. Revisit APIClientTests.swift and navigate to the testEpisodes_Success() method. We no longer invoke the stub(condition:response:) function. We invoke the stubAPI(endpoint:isSuccess:) method instead, passing in episodes as the first argument and true as the second argument.

Because the stubAPI(endpoint:isSuccess:) method returns a stubs descriptor, we can invoke the store(in:) method on the stubs descriptor the method returns, passing in the array of stubs descriptors. That looks quite nice.

func testEpisodes_Success() throws {
    stubAPI(
        endpoint: .episodes,
        isSuccess: true
    ).store(in: &stubsDescriptors)

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

    ...

}

We repeat these steps for the testEpisodes_Failure() method. The difference is that we pass false as the second argument since the request to the Cocoacasts API should fail.

func testEpisodes_Failure() throws {
    stubAPI(
        endpoint: .episodes,
        isSuccess: false
    ).store(in: &stubsDescriptors)

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

}

Reducing Code Duplication

There is still quite a bit of code duplication we need to address. We start by declaring a private, variable property, apiClient, of type APIClient!. Notice that the property is an implicitly unwrapped optional. I find the use of implicitly unwrapped optionals justifiable in unit tests as they improve readability and avoid unnecessarily complex code.

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

final class APIClientTests: XCTestCase {

    // MARK: - Properties

    private var apiClient: APIClient!

    ...

}

In setUpWithError(), we create an APIClient instance and store a reference to the API client in the apiClient property. This means we no longer need to create an APIClient instance in the unit tests.

override func setUpWithError() throws {
    apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())
}

We can take one more step to reduce code duplication. Define a private helper method with name runEpisodesTest(isSuccess:). The method accepts a boolean as its only argument. The boolean defines whether the request to the Cocoacasts API fails or succeeds.

private func runEpisodesTest(isSuccess: Bool) {

}

Move the body of the testEpisodes_Success() method to the runEpisodesTest(isSuccess:) method. We need to make a few changes. First, we pass the value of the isSuccess parameter to the stubAPI(endpoint:isSuccess:) method. Second, we fulfill the expectation in the completion handler, below the switch statement. Third, we invoke the XCTFail(_:file:line:) function in the finished case if isSuccess is equal to false and we invoke the XCTFail(_:file:line:) function in the failure case if isSuccess is equal to true.

private func runEpisodesTest(isSuccess: Bool) {
    stubAPI(
        endpoint: .episodes,
        isSuccess: true
    ).store(in: &stubsDescriptors)

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

    apiClient.episodes()
        .sink { completion in
            switch completion {
            case .finished:
                if !isSuccess {
                    XCTFail("Request Should Fail")
                }
            case .failure:
                if isSuccess {
                    XCTFail("Request Should Succeed")
                }
            }

            expectation.fulfill()
        } receiveValue: { episodes in
            XCTAssertEqual(episodes.count, 10)
        }.store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

The last step is invoking the helper method we implemented in the testEpisodes_Success() and testEpisodes_Failure() methods. The only difference is the value we pass to the runEpisodesTest(isSuccess:) method. Run the test suite to make sure the unit tests for the APIClient class still pass.

func testEpisodes_Success() throws {
    runEpisodesTest(isSuccess: true)
}

func testEpisodes_Failure() throws {
    runEpisodesTest(isSuccess: false)
}

What's Next?

A robust test suite needs to meet a number of requirements. Unit tests should be readable and maintainable. Setting up the environment, such as stubbing an API, should be fast and straightforward. We addressed a few issues in this episode to meet some of these requirements. In the next episode, we check if the unit tests we wrote for the /api/v1/episodes endpoint are sufficient. Should we write more unit tests or can we move on to the next endpoint?