Posted 9/30/2018.
We write asynchronous code when we know a task will take time to complete, whether because it's computationally expensive or we're making a network request. Testing this code can be difficult, especially when the asynchronous logic is internal. For example, let's say we're making a fire and forget request to load an image into an image view? We would likely make the request on a background queue and then dispatch to the main queue to set the image on the image view.
In this case, the caller isn't concerned about knowing when the request has completed and the image is loaded, but we still want to be able to test this logic. In order to do that, we need to make the internal asynchronous code synchronous. In this tutorial we'll see how to accomplish that using dependency injection.
Our goal is to test a fire and forget request that loads an image into an image view. Let's review how we might do this using dispatch queues and URLSession
. First, let's look at an ImageRequester
object that uses URLSession
to load an image:
import UIKit
final class ImageRequester {
let defaultSession = URLSession(configuration: .default)
func requestImage(withURL url: URL, completion: @escaping (UIImage?) -> Void) {
let dataTask = defaultSession.dataTask(with: url) { data, _, error in
let image = self.serializeImage(with: data, error: error)
completion(image)
}
dataTask.resume()
}
private func serializeImage(with data: Data?, error: Error?) -> UIImage? {
if error != nil { return nil }
guard let data = data, let image = UIImage(data: data) else { return nil }
return image
}
}
Next, let's look at a basic view controller class that just has an image view and a method for using the above ImageRequester
to load in an image:
class ViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
let imageRequester = ImageRequester()
let backgroundQueue = DispatchQueue(label: "com.app.ImageQueue", qos: .userInitiated, attributes: .concurrent)
func loadImage(withURL url: URL) {
backgroundQueue.async { [weak self] in
self?.imageRequester.requestImage(withURL: url) { image in
DispatchQueue.main.async {
self?.imageView.image = image
}
}
}
}
}
Finally, we can call this method from the AppDelegate
as follows:
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
configureRootViewController()
return true
}
func configureRootViewController() {
let path = "https://upload.wikimedia.org/wikipedia/commons/7/7a/Apple-swift-logo.png"
guard let rootViewController = window?.rootViewController as? ViewController,
let url = URL(string: path) else { return }
rootViewController.loadImage(withURL: url)
}
}
This works, and will load the Swift logo into the view controller's image view. But it's not testable at all. Let's fix that using dependency injection.
The first thing we'll want to inject is ImageRequester
. This will allow us to replace the asynchronous image request in tests using a mock that returns an image from our test target's bundle immediately.
To do this, let's add an ImageRequesting
protocol:
protocol ImageRequesting {
func requestImage(withURL url: URL, completion: @escaping (UIImage?) -> Void)
}
extension ImageRequester: ImageRequesting {}
Next let's prepare to inject the two queues our view controller uses, a background concurrent queue and the main queue. This will allow us to replace long-running or expensive tasks with quick and easy tasks that complete immediately in tests. We'll add a Dispatching
protocol to do that:
protocol Dispatching: class {
func async(_ block: @escaping () -> Void)
}
extension DispatchQueue: Dispatching {
func async(_ block: @escaping () -> Void) {
async(group: nil, execute: block)
}
}
We're now ready to rewrite our ViewController
class using dependency injection:
class ViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
static let defaultBackgroundQueue = DispatchQueue(label: "com.app.ImageQueue", qos: .userInitiated, attributes: .concurrent)
var imageRequester: ImageRequesting?
var mainQueue: Dispatching?
var backgroundQueue: Dispatching?
func configure(withImageRequester imageRequester: ImageRequesting? = ImageRequester(),
mainQueue: Dispatching = DispatchQueue.main,
backgroundQueue: Dispatching = defaultBackgroundQueue) {
self.imageRequester = imageRequester
self.mainQueue = mainQueue
self.backgroundQueue = backgroundQueue
}
func loadImage(withURL url: URL) {
backgroundQueue?.async { [weak self] in
self?.imageRequester?.requestImage(withURL: url) { image in
self?.mainQueue?.async {
self?.imageView.image = image
}
}
}
}
}
We have a new configure
method that takes an imageRequester
, a mainQueue
, and a backgroundQueue
. Note that we moved our default background queue to a static constant and added default arguments for all three injected objects. Now in AppDelegate
, we just need to add a call to configure
. Replace configureRootViewController
with the following:
func configureRootViewController() {
let path = "https://upload.wikimedia.org/wikipedia/commons/7/7a/Apple-swift-logo.png"
guard let rootViewController = window?.rootViewController as? ViewController,
let url = URL(string: path) else { return }
rootViewController.configure()
rootViewController.loadImage(withURL: url)
}
Run the app again to make sure everything still works as expected.
Now that we've injected all of the dependencies the view controller uses, we're ready to add tests. First let's add a mock for ImageRequester
:
class MockImageRequester: ImageRequesting {
func requestImage(withURL url: URL, completion: @escaping (UIImage?) -> Void) {
let bundle = Bundle(for: ViewControllerTests.self)
let url = bundle.url(forResource: "swift_logo", withExtension: "png")!
let data = try! Data(contentsOf: url)
let image = UIImage(data: data)!
completion(image)
}
}
All we're doing here is loading a .png file of the Swift logo that's already been added to the test target's bundle and passing it into the completion block.
Mocking the dispatch queues is even easier:
class MockQueue: Dispatching {
func async(_ block: @escaping () -> Void) {
block()
}
}
We simply call the block that's passed in immediately.
We can now write a test for the view controller's loadImage:withURL
method:
class ViewControllerTests: XCTestCase {
let imageRequester: ImageRequesting = MockImageRequester()
let mainQueue: Dispatching = MockQueue()
let backgroundQueue: Dispatching = MockQueue()
let url = URL(string: "https://commons.wikimedia.org/wiki/File:Apple-swift-logo.png")!
lazy var viewController: ViewController = {
let storyboard = UIStoryboard(name: "Main", bundle: .main)
let viewController = storyboard.instantiateInitialViewController() as! ViewController
viewController.configure(withImageRequester: imageRequester,
mainQueue: mainQueue,
backgroundQueue: backgroundQueue)
UIApplication.shared.keyWindow?.rootViewController = viewController
return viewController
}()
func testLoadImage() {
XCTAssertNil(viewController.imageView.image)
viewController.loadImage(withURL: url)
XCTAssertNotNil(viewController.imageView.image)
}
}
We start by declaring three mock objects for imageRequester
, mainQueue
, and backgroundQueue
. We then declare a lazy var for the viewController
instance we'll be testing, loading it from the app's main storyboard, configuring it with mocks, and setting it as the app window's root view controller.
Then in our loadImage
test, we first assert that the image view's image is nil
, then call loadImage
with a sample URL, and finally assert that the image view's image is no longer nil
.
In this tutorial, we saw how we can make asynchronous code, even fire and forget requests, testable using dependency injection. With this approach, we can make our test suites run faster and more reliably. Dependency injection with protocols also makes our code easier to understand by cleary defining what each dependency does in the context we are testing. It this example, DispatchQueue
and ImageRequester
could have significantly more functionality than is needed by ViewController
. Using protocols we can define the API ViewController
needs from each and quickly see exactly what these dependencies are doing.
The source code for this project is on GitHub here.