Taking dependency injection one step further with Swift

Isolate until it’s testable

Oleg Dreyman

Dependency injection exists for a reason. Without it our code would be almost impossible to test. However, there are some major drawbacks when using the most straightforward approach for dependency injection in Swift. Consider the following example where NetworkController, Cache and ErrorReporter are classes:

class UserCacher {

let networkController: NetworkController // class
let cache: Cache // class
let errorReporter: ErrorReporter // class

init(networkController: NetworkController, cache: Cache, errorReporter: ErrorReporter) {
self.networkController = networkController
self.cache = cache
self.errorReporter = errorReporter
}

func cacheUser(withID id: Int) {
networkController.downloadUser(withID: id) { (result) in
switch result {
case .success(let user):
do {
let serializedUser = try user.asData()
self.cache.cache(serializedUser, at: "user-\(id)")
} catch {
self.errorReporter.report(error)
}
case .failure(let error):
self.errorReporter.report(error)
}
}
}

}
let cacher = UserCacher(networkController: NetworkController(),
cache: Cache(),
errorReporter: ErrorReporter())

The code we want to test is cacheUser. But with current implementation it’s almost impossible to test without also testing NetworkController, Cache and ErrorReporter. So in order to properly test this code, we have to subclass each one of our dependencies, yielding a not very elegant code (don’t read it if you don’t want to, I won’t blame you):

class TestSuccessNetworkController : NetworkController {

override func downloadUser(withID id: Int, completion: @escaping (Result<User>) -> ()) {
let user = User()
completion(.success(user))
}

}
class TestSuccessCache : Cache {

let expectation: XCTestExpectation

init(expectation: XCTestExpectation) {
self.expectation = expectation
}

override func cache(_ data: Data, at key: String) {
if key == "user-123" {
expectation.fulfill()
} else {
XCTFail("Expected user with key 123")
}
}

}
class TestSuccessErrorReporter : ErrorReporter {

override func report(_ error: Error) {
XCTFail(error.localizedDescription)
}

}
// Later in XCTestCasefunc testSuccessUserCaching() {
let expectation = self.expectation(description: "Expecting caching")
let userCacher = UserCacher(networkController: TestSuccessNetworkController(), cache: TestSuccessCache(expectation: expectation), errorReporter: TestSuccessErrorReporter())
userCacher.cacheUser(withID: 123)
waitForExpectations(timeout: 5.0)
}

Wow, that’s a lot of ugly boilerplate (and that’s only for one test case!). And now we’re obliged not to mark any of NetworkController, Cache or ErrorReporter as final (which would make perfect sense here, actually). And, overall, this approach just doesn’t feel like Swift (at least for me).

There is also another concern — NetworkController is potentially a huge class, and UserCacher only needs the downloadUser portion of it.

So, let’s head to our next try — protocol-oriented programming, which is a shiny concept in the world of Swift. So let’s “wrap” all those classes in protocols.

First, we should change our declarations:

protocol UserDownloader {

func downloadUser(withID id: Int, completion: @escaping (Result<User>) -> ())

}
extension NetworkController : UserDownloader { }protocol Caching {

func cache(_ data: Data, at key: String)

}
extension Cache : Caching { }protocol ErrorReporterProtocol {

func report(_ error: Error)

}
extension ErrorReporter : ErrorReporterProtocol { }class UserCacher {

let userDownloader: UserDownloader
let cache: Caching
let errorReporter: ErrorReporterProtocol

init(userDownloader: UserDownloader, cache: Caching, errorReporter: ErrorReporterProtocol) {
self.userDownloader = userDownloader
self.cache = cache
self.errorReporter = errorReporter
}

func cacheUser(withID id: Int) {
userDownloader.downloadUser(withID: id) { (result) in
switch result {
case .success(let user):
do {
let serializedUser = try user.asData()
self.cache.cache(serializedUser, at: "user-\(id)")
} catch {
self.errorReporter.report(error)
}
case .failure(let error):
self.errorReporter.report(error)
}
}
}

}

Phew, looks like a lot of boilerplate already. And here comes the testing part:

class TestSuccessNetworkController : UserDownloader {

func downloadUser(withID id: Int, completion: @escaping (Result<User>) -> ()) {
let user = User()
completion(.success(user))
}

}
class TestSuccessCache : Caching {

let expectation: XCTestExpectation

init(expectation: XCTestExpectation) {
self.expectation = expectation
}

func cache(_ data: Data, at key: String) {
if key == "user-123" {
expectation.fulfill()
} else {
XCTFail("Expected user with key 123")
}
}

}
class TestSuccessErrorReporter : ErrorReporterProtocol {

func report(_ error: Error) {
XCTFail(error.localizedDescription)
}

}
// Later in XCTestCasefunc testSuccessUserCaching() {
let expectation = self.expectation(description: "Expecting caching")
let userCacher = UserCacher(userDownloader: TestSuccessNetworkController(), cache: TestSuccessCache(expectation: expectation), errorReporter: TestSuccessErrorReporter())
userCacher.cacheUser(withID: 123)
waitForExpectations(timeout: 5.0)
}

So yeah… despite of getting rid of override, not much has changed, and even more boilerplate is introduced. Protocol-oriented programming is awesome, but it’s not a silver bullet. And it’s definitely a bad choice here because flexibility is still terrible and you will find yourself stuck in the boilerplate very quickly.

The Solution

The solution still lies in the field of dependency injection. Protocols helped us to isolate UserCacher, but they didn’t solve the “lack of flexibility” problem. What could be more flexible than protocols 🤔 ?

class UserCacher {

let downloadUser: (Int, _ completion: @escaping (Result<User>) -> ()) -> ()
let cache: (Data, String) -> ()
let report: (Error) -> ()

init(downloadUser: @escaping (Int, _ completion: @escaping (Result<User>) -> ()) -> (),
cache: @escaping (Data, String) -> (),
report: @escaping (Error) -> ()) {
self.downloadUser = downloadUser
self.cache = cache
self.report = report
}

func cacheUser(withID id: Int) {
downloadUser(id) { (result) in
switch result {
case .success(let user):
do {
let serializedUser = try user.asData()
self.cache(serializedUser, "user-\(id)")
} catch {
self.report(error)
}
case .failure(let error):
self.report(error)
}
}
}

}

Yes, that’s it: just inject closures. Dynamically inject functions — isn’t that obvious and cool at the same time? Here how it looks at the regular call site:

let network = NetworkController()
let cache = Cache()
let errorReporter = ErrorReporter()
let cacher = UserCacher(downloadUser: network.downloadUser,
cache: cache.cache,
report: errorReporter.report)

And now our testing code which is incredibly short:

func testSuccessUserCaching() {
let expectation = self.expectation(description: "Expecting caching")
let onSaveCache: (Data, String) -> () = { _, key in
if key == "user-123" {
expectation.fulfill()
} else {
XCTFail("Expected user with key 123")
}
}
let userCacher = UserCacher(downloadUser: { $0.1(.success(User())) }, cache: onSaveCache, report: { XCTFail($0.localizedDescription) })
userCacher.cacheUser(withID: 123)
waitForExpectations(timeout: 5.0)
}

We can also cover the tracks of our closure implementation with simple convenience initializer:

convenience init(networkController: NetworkController,
cache: Cache,
errorReporter: ErrorReporter) {
self.init(downloadUser: networkController.downloadUser,
cache: cache.cache,
report: errorReporter.report)
}

And now we’re back to where we started:

let cacher = UserCacher(networkController: NetworkController(),
cache: Cache(),
errorReporter: ErrorReporter())

However, our solution now can be completely (and easily!) tested. 🎉

AnySuggestion

A Swift & Cocoa blog by Oleg Dreyman

Oleg Dreyman

Written by

Swift enthusiast. https://github.com/dreymonde

AnySuggestion

A Swift & Cocoa blog by Oleg Dreyman