Otávio’s blog

Replacing Type Methods to Improve Testability

Whether we like them or not, type methods, also known as class methods or static methods, are heavily used in Swift and are part of our daily lives as engineers.

From analytics trackers to requesting system permissions, we’ve all encountered type methods from external libraries that we have no control over. Testing code that interacts with them might seem difficult without using method swizzling, but fortunately, this isn't always the case.

Let's suppose there's a class called APIWrapper, which implements a type method that requests search results from an endpoint.

public class APIWrapper {
    public static func search(
        query: String,
        completion: @escaping (Result<[String], Error>) -> Void
    )
}

The method takes a query string and a completion block, which is triggered once the request finishes. Its internal implementation doesn't really matter since it could be from an external framework or generated by a code generator from the API specification.

Consuming the method is straightforward:

APIWrapper.search(query: "fancy restaurant") { result in
    // do something with 'result'
}

However, testing the part of the application that interacts with it isn't as obvious. Especially when we want to:

  1. avoid making real network requests
  2. stress all the possibilities the interface allows. For instance:
    1. Is the search method called with the correct query string?
    2. What happens when the completion block returns an empty array?
    3. What happens when the completion block returns an array of results?
    4. What happens when the completion block is called with an error?

Basic Data Source Implementation

The snippet below shows DataSource, a UITableViewDataSource subclass which uses the API wrapper to populate the table view when the network request completes. Unit testing it - as is - triggers a real network request and doesn't provide what's needed to test the query passed to the wrapper and the different behaviors when the result returns.

final class DataSource: NSObject, UITableViewDataSource {
    private let tableView: UITableView
    private var results: [String] = []

    init(tableView: UITableView) {
        self.tableView = tableView
    }

    func fetchSearchResults(query: String) {
        APIWrapper.search(query: query) { [weak self] result in
            switch result {
            case .success(let results):
                self?.results = results
            case .failure:
                self?.results = []
            }
            self?.tableView.reloadData()
        }
    }

    // ... UITableViewDataSource methods ...
}

If APIWrapper had an instance method instead of a type method, two easy and obvious solutions would emerge: protocols and subclasses. Using either, it would be possible to replace the wrapper with a Test Double that captures the query parameter and completion block for verifying expectations.

Dependency Injection to the Rescue

In the example above, the API wrapper interface can't be changed and has to stay as a type method. Therefore, the easiest way to overcome this is via dependency injection. In this case, method injection via object initialization.

The code below stores a method which matches the qualified symbol name of the wrapper method, using it to perform the search. To simplify the DataSource interface, the wrapper's search method is assigned using a default parameter value.

final class DataSource: NSObject, UITableViewDataSource {
    typealias SearcherCompletion = (Result<[String], Error>) -> Void
    typealias Searcher = (String, @escaping SearcherCompletion) -> Void

    private let tableView: UITableView
    private var results: [String] = []
    private var searcher: Searcher

    init(
        tableView: UITableView,
        searcher: @escaping Searcher = APIWrapper.search
    ) {
        self.tableView = tableView
        self.searcher = searcher
    }

    func fetchSearchResults(query: String) {
        searcher(query) { [weak self] result in
            switch result {
            case .success(let results):
                self?.results = results
            case .failure:
                self?.results = []
            }
            self?.tableView.reloadData()
        }
    }

    // ... UITableViewDataSource methods ...
}

Testing with Dependency Injection

Finally, testing becomes trivial. The injected method is used to capture the query term and the completion block passed to the API wrapper. Once captured, these can be used to verify expectations.

final class DataSourceTests: XCTestCase {
    private var tableView: UITableView!
    private var dataSource: DataSource?
    private var lastSearchQuery: String?
    private var lastSearchCompletion: DataSource.SearcherCompletion?

    override func setUp() {
        super.setUp()

        tableView = UITableView()

        dataSource = DataSource(
            tableView: tableView
        ) { query, completion in
            self.lastSearchQuery = query
            self.lastSearchCompletion = completion
        }
    }

    // ... tearDown(), and test methods ...
}

With all the setup in place, it's possible to return to the original questions: Is the search method called with the correct query string? The code below checks if the data source calls the API wrapper with the correct query.

func testFetchSearchResultsQuery() {
    // When the method is called
    dataSource?.fetchSearchResults(query: "Mocked Search Query")

    // Then
    XCTAssertEqual(
        lastSearchQuery,
        "Mocked Search Query",
        "It should call the API wrapper with the correct search query"
    )
}

When the data source calls the API wrapper, it not only captures the query item passed but also the completion block. This way, it's possible to call the completion block with different behaviors. Below, it answers the question What happens when the completion block returns an empty array?

func testFetchSearchResultsWithoutResults() {
    // When the method is called
    dataSource?.fetchSearchResults(query: "Mocked Search Query")

    // And the API wrapper completion block is called with an empty array
    lastSearchCompletion?(.success([]))

    // Then
    XCTAssertEqual(
        dataSource?.tableView(tableView, numberOfRowsInSection: 0),
        0,
        "It should empty the table view"
    )
}

Similarly, it's possible to trigger the completion with a success containing actual results. This answers What happens when the completion block returns an array of results?

func testFetchSearchResultsWithResults() {
    // When the method is called
    dataSource?.fetchSearchResults(query: "Mocked Search Query")

    // And the API wrapper completion block is called with results
    lastSearchCompletion?(.success(["result 1", "result 2"]))

    // Then
    XCTAssertEqual(
        dataSource?.tableView(tableView, numberOfRowsInSection: 0),
        2,
        "It should add the correct number of results to the table view"
    )

    XCTAssertEqual(
        dataSource?.tableView(
            tableView,
            cellForRowAt: IndexPath(row: 0, section: 0)
        ).textLabel?.text,
        "result 1",
        "It should configure the first cell"
    )

    XCTAssertEqual(
        dataSource?.tableView(
            tableView,
            cellForRowAt: IndexPath(row: 1, section: 0)
        ).textLabel?.text,
        "result 2",
        "It should configure the second cell"
    )
}

And finally, we can answer What happens when the completion block is called with an error? by calling the completion with an error.

func testFetchSearchResultsWithError() {
    // When the method is called
    dataSource?.fetchSearchResults(query: "Mocked Search Query")

    // And the API wrapper completion block is called with an error
    enum MockError: Error { case someGenericError }
    lastSearchCompletion?(.failure(MockError.someGenericError))

    // Then
    XCTAssertEqual(
        dataSource?.tableView(tableView, numberOfRowsInSection: 0),
        0,
        "It should empty the table view"
    )
}

More importantly, this method works not only with type methods but also with free functions, such as SecItemAdd(_:_:) used to add items to the Keychain.

Conclusion

Testing code that interacts with type methods can be challenging, but it doesn't have to be. By leveraging dependency injection, it's possible to isolate and test code effectively without relying on real calls (network requests, permission requests, etc.). This approach not only simplifies the testing process but also makes our tests reliable and easier to maintain. Adopting these techniques will lead to more robust, maintainable, and testable code, improving the quality and reliability of applications.

Note: This is a repost of a blog post I originally published on GetYourGuide’s Inside Blog years ago with some improvements.

#handpick #swift #testing