Testing Swift code that uses system singletons in 3 easy steps
Discover page available: Unit TestingMost apps written for any of Apple’s platforms rely on APIs that are singleton based. From UIScreen
to UIApplication
to NSBundle
, static APIs are everywhere in Foundation
, UIKit
& AppKit
.
While singletons are very convenient and give easy access to a certain API from anywhere, they also pose a challenge when it comes to code decoupling and testing. Singletons are also a quite common source of bugs, where state ends up being shared and mutations not propagated properly throughout the system.
However, while we can refactor our own code to only use singletons where really needed, we can’t do much about what the system APIs give us. But the good news is, there are some techniques you can use to make your code that uses system singletons still be easy to manage & easy to test.
Let’s have a look at some code that uses the URLSession.shared
singleton:
class DataLoader {
enum Result {
case data(Data)
case error(Error)
}
func load(from url: URL, completionHandler: @escaping (Result) -> Void) {
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let error = error {
return completionHandler(.error(error))
}
completionHandler(.data(data ?? Data()))
}
task.resume()
}
}
The above DataLoader
is currently very hard to test, as it will automatically call the shared URL session and perform a network call. This would require us to add waiting and timeouts to our testing code, and it quickly becomes very tricky and unstable.
Instead, let’s go through 3 easy steps to make this code still as simple to use as currently, but making it a lot easier to test.
1. Abstract into a protocol
Our first task is to move the parts from URLSession
that we need into a protocol that we can then easily mock in our tests. In my talk “Writing Swift code with great testability” I recommend avoiding mocks when possible, and while that’s a good strategy to follow for your own code, when interacting with system singletons — mocking becomes an essential tool to increase predictability.
Let’s create a NetworkEngine
protocol and make URLSession
conform to it:
protocol NetworkEngine {
typealias Handler = (Data?, URLResponse?, Error?) -> Void
func performRequest(for url: URL, completionHandler: @escaping Handler)
}
extension URLSession: NetworkEngine {
typealias Handler = NetworkEngine.Handler
func performRequest(for url: URL, completionHandler: @escaping Handler) {
let task = dataTask(with: url, completionHandler: completionHandler)
task.resume()
}
}
As you can see above, we let URLSessionDataTask
be an implementation detail of URLSession
. That way, we avoid having to create multiple mocks in our tests, and can focus on the NetworkEngine
API.
2. Use the protocol with the singleton as the default
Now, let’s update our DataLoader
from before to use the new NetworkEngine protocol, and get it injected as a dependency. We’ll use URLSession.shared
as the default argument, so that we can maintain backward compatibility and the same convenience as we had before.
class DataLoader {
enum Result {
case data(Data)
case error(Error)
}
private let engine: NetworkEngine
init(engine: NetworkEngine = URLSession.shared) {
self.engine = engine
}
func load(from url: URL, completionHandler: @escaping (Result) -> Void) {
engine.performRequest(for: url) { (data, response, error) in
if let error = error {
return completionHandler(.error(error))
}
completionHandler(.data(data ?? Data()))
}
}
}
By using a default argument, we can still easily create a DataLoader
without having to supply a NetworkEngine
— simply using DataLoader()
— just like before.
3. Mock the protocol in your tests
Finally, let’s write a test — where we’ll mock NetworkEngine
to make our test fast, predictable and easy to maintain.
func testLoadingData() {
class NetworkEngineMock: NetworkEngine {
typealias Handler = NetworkEngine.Handler
var requestedURL: URL?
func performRequest(for url: URL, completionHandler: @escaping Handler) {
requestedURL = url
let data = "Hello world".data(using: .utf8)
completionHandler(data, nil, nil)
}
}
let engine = NetworkEngineMock()
let loader = DataLoader(engine: engine)
var result: DataLoader.Result?
let url = URL(string: "my/API")!
loader.load(from: url) { result = $0 }
XCTAssertEqual(engine.requestedURL, url)
XCTAssertEqual(result, .data("Hello world".data(using: .utf8)!))
}
Above you can see that I try to keep my mock as simple as possible. Instead of creating complicated mocks with lots of logic, it’s usually a good idea to just have them return some hardcoded value, that you can then make asserts against in your test. Otherwise, the risk is that you end up testing your mock more than you’re actually testing your production code.
That’s it!
We now have testable code, that still uses a system singleton for convenience — all by following these 3 easy steps:
1. Abstract into a protocol
2. Use the protocol with the singleton as the default
3. Mock the protocol in your tests
Feel free to reach out to me on Twitter if you have any questions, suggestions or feedback. I’d also love to hear from you if you have any topic that you’d like me to cover in an upcoming post.
Thanks for reading 🚀