Introduction
Like them or not, type methods, aka class methods or static methods, are heavily used in Swift and part of our daily lives as developers.
From analytics trackers to requesting system permissions, we’ve all had to face type methods from external libraries in which we had no control over. Testing code that interacts with them might seem hard without using method swizzling, but fortunately, this doesn’t always have to be 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, (Result<[String], Error>) -> Void
, 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'
}
But testing the part of the application that interacts with it isn’t that obvious. Especially when we want to:
- avoid making real network requests
- 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 SearchResultsTableViewDataSource
, a UITableViewDataSource
subclass which uses the API wrapper to populate the table view when the network request completes. Testing it 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 SearchResultsTableViewDataSource: NSObject,
UITableViewDataSource {
private let tableView: UITableView
private var results: [String] = []
init(
tableView: UITableView
) {
self.tableView = tableView
}
func fetchSearchResuls(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/or, it would be possible to replace the wrapper with a [[22252.083445 - Test Doubles]] 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 SearchResultsTableViewDataSource
interface, the wrapper’s search method is assigned using a default parameter value.
final class SearchResultsTableViewDataSource: 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 fetchSearchResuls(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 SearchResultsTableViewDataSourceTests: XCTestCase {
var tableView: UITableView!
var dataSource: SearchResultsTableViewDataSource?
var lastSearchQuery: String?
var lastSearchCompletion:
SearchResultsTableViewDataSource.SearcherCompletion?
override func setUp() {
super.setUp()
tableView = UITableView()
dataSource = SearchResultsTableViewDataSource(
tableView: tableView
) { query, completion in
self.lastSearchQuery = query
self.lastSearchCompletion = completion
}
}
func testFetchSearchResultsQuery() {
// When the method is called
dataSource?.fetchSearchResuls(query: "Mocked Search Query")
XCTAssertEqual(
lastSearchQuery,
"Mocked Search Query",
"It calls the API wrapper with the correct search query"
)
}
func testFetchSearchResultsWithoutResults() {
// When the method is called
dataSource?.fetchSearchResuls(query: "Mocked Search Query")
// And the API wrapper completion block is called with
// an empty array
lastSearchCompletion?(.success([]))
XCTAssertEqual(
dataSource?.tableView(tableView, numberOfRowsInSection: 0),
0,
"It empties the table view"
)
}
func testFetchSearchResultsWithResults() {
// When the method is called
dataSource?.fetchSearchResuls(query: "Mocked Search Query")
// And the API wrapper completion block is called with results
lastSearchCompletion?(.success(["result 1", "result 2"]))
XCTAssertEqual(
dataSource?.tableView(tableView, numberOfRowsInSection: 0),
2,
"It adds the correct number of results to the table view"
)
XCTAssertEqual(
dataSource?.tableView(
tableView,
cellForRowAt: IndexPath(row: 0, section: 0)
).textLabel?.text,
"result 1",
"It configures the first cell"
)
XCTAssertEqual(
dataSource?.tableView(
tableView,
cellForRowAt: IndexPath(row: 1, section: 0)
).textLabel?.text,
"result 2",
"It configures the second cell"
)
}
func testFetchSearchResultsWithError() {
// When the method is called
dataSource?.fetchSearchResuls(query: "Mocked Search Query")
// And the API wrapper completion block is called with an error
enum MockError: Error { case someGenericError }
lastSearchCompletion?(.failure(MockError.someGenericError))
XCTAssertEqual(
dataSource?.tableView(tableView, numberOfRowsInSection: 0),
0,
"It empties the table view"
)
}
}
More importantly, not only does this method work with type methods but also with free functions, such as SecItemAdd(_:_:)
used to add items to the Keychain.