Unit Testing Dispatch Queues With Dependency Injection

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.

Setup

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.

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.

Testing ViewController

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.