/ IOS

Unit Testing Asynchronous Code in Swift

If you are practicing unit testing, sooner or later you’ll run across testing async code. Writing such tests can be a challenge, unless you are armed with special techniques. In this article let’s learn different patterns of testing asynchronous code in Swift.

Problem Statement

Almost each iOS app has code that runs asynchronously. It can be networking, delayed operations, background tasks, timers and even view animations. Why unit testing async code is a problem?

Before answering, let’s make sure that we understand what unit tests are:

Unit tests verify that a known, fixed input produces a known, fixed output. [1]

Fixed and known are very important. Asynchronous code violates these principles. We don’t know when exactly async code finishes, hence cannot verify it reliably. Let’s discuss several patterns that allow to remove flakiness when unit testing async Swift code.

Tests Isolation

No techniques will help if tests are directly or indirectly connected with each other. Why this is the case? Tests that have dependencies are flaky. They are deeply infected and contagious. When test suite has at least a couple of those, scary things begin to happen. It can be random test failures, infinite freezes, crashes, exceptions. Given that the suite typically has thousands of tests, such problems are extremely hard to track and fix.

Preliminary measures must be conducted promptly to prevent that from happening: each test must run fresh and in a fully controlled environment.

Starting tests fresh means that all changes to the global state must be cleaned up before and after each test. Usually this is done from setUp() and tearDown() methods of your XCTestCase class. Doing any of those in your unit tests is a warning flag and a strong indication that the global state must be resetted:

  • Mutate static variables
  • Write to a database (CoreData, Realm etc)
  • Write to UserDefaults
  • Post and subscribe to notifications
  • The use of singletons

Additionally, you should never call tests from each other.

To control the environment, dependencies must be injected into the system under test. Otherwise, the dependencies might interact with the outside app unpredictably, what brings us back to flaky tests.

I am touching on the subject in Dependency Injection in Swift.

The dependencies that we are using for testing purposes are called mocks.

Mocks mimic real objects for testing.

After ensuring that our tests are properly isolated, we can apply special techniques that allow to test async Swift code reliably.

Mock Asynchronous API

When testing a system that runs code asynchronously, we can usually split its responsibilities into two parts: the logical and the multithreaded. The multithreaded code is extracted into a separate object, which is injected as a dependency to the first one. This allows to mock the asynchronous dependency, effectively substituting async operation with sync.

Let’s put this into practice.

Example #1: Dispatch Queues

Image compression is a long-running task that is usually performed in the background thread. DispatchQueue provides a convenient way of doing it:

class ImageCompressor {
    
    let queue = DispatchQueue(label: "image-compressor")
    
    func compress(_ source: UIImage, completion: @escaping (UIImage) -> Void) {
        queue.async {
            sleep(1) // Simulate the delay
            completion(UIImage())
        }
    }
}

The below test won’t pass, since it finishes before the completion is called.

class ImageCompressorTests: XCTestCase {

    func testCompressCompletes() {
        var didCompress = false

        let sut = ImageCompressor()
        sut.compress(UIImage()) { _ in
            didCompress = true
        }

        XCTAssertTrue(didCompress)
    }
}

First, we need to substitute operation queue during testing. To do so, let’s inject it via initializer:

class ImageCompressor {
    let queue: DispatchQueue
    
    init(queue: DispatchQueue) {
        self.queue = queue
    }

    // The rest of the code remains unchanged
}

Now we can mimic synchronous behavior to fulfill the test:

class ImageCompressorTests: XCTestCase {

    func testCompressCompletes() {
        var didCompress = false

        let queue = DispatchQueue(label: #function)
        let sut = ImageCompressor(queue: queue)
        
        sut.compress(UIImage()) { _ in
            didCompress = true
        }
        
        queue.sync {}
        
        XCTAssertTrue(didCompress)
    }
}

The little trick with queue.sync allows us to wait until the operation is finished. I’ve touched on this subject more in Grand Central Dispatch in Swift.

Example #2: View Controller Transition

Same technique can be applied to test view controller transition. Imagine that we are testing AppRouter which lends itself to navigating between iOS app screens.

class AppRouter {
    let navController: UINavigationController
    
    init(navController: UINavigationController) {
        self.navController = navController
    }
    
    func routeTo(_ next: UIViewController) {
        navController.pushViewController(next, animated: true)
    }
}

We’ve implemented a test verifying that routeTo method correctly shows two screens.

class AppRouterTests: XCTestCase {

    func testShowsTwoScreens() {
        let navController = UINavigationController()
        let firstScreen = UIViewController()
        let secondScreen = UIViewController()
        
        let sut = AppRouter(navController: navController)
        
        sut.routeTo(firstScreen)
        sut.routeTo(secondScreen)
        
        XCTAssertEqual(navController.viewControllers.count, 2)
        XCTAssertEqual(navController.viewControllers.first, firstScreen)
        XCTAssertEqual(navController.viewControllers.last, secondScreen)
    }
}

However, this test fails with following log:

XCTAssertEqual failed: ("1") is not equal to ("2")
XCTAssertEqual failed: ("Optional(<UIViewController: 0x7fe0e4c0b830>)") is not equal to ("Optional(<UIViewController: 0x7fe0e4c0bfb0>)")

View controller transition is performed asynchronously, hence assertions are executed before the navigation has finished. The solution is to mock navigation controller and inject it into AppRouter:

class NonAnimatableNavController: UINavigationController {

    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        super.pushViewController(viewController, animated: false) // Disable animation
    }
}

In our unit test, we inject NonAnimatableNavController to forcibly disable the animation. The only that changes is navController initialization:

let navController = NonAnimatableNavController()

The test now passes, since animation is disabled.

Current approach goes far beyond view controller transitions and dispatch queues: we can mock network requests, delayed operations, timers. Basically, any async API which is represented with an abstraction that can be injected and mocked.

Signaling

Unfortunately, not everything can be mocked. Signaling allows to test async code reliably without mocking. Signaling is implemented by means of XCTestExpectation API, which makes it a trivial task. The technique is summarized into three steps:

  1. Create expectation.
  2. Signal expectation when async operation has finished.
  3. Wait for a signal.
  4. Assert.

To see it in practice, let’s implement and test a network service that queries songs with iTunes Search API.

Services and their roles are described in Layered Architecture.

Here is the implementation of MusicService:

class MusicService {
    
    func search(_ term: String, completion: @escaping (Result<[Track], Error>) -> Void) {
        var components = URLComponents(string: "https://itunes.apple.com/search")
        components?.queryItems = [
            .init(name: "media", value: "music"),
            .init(name: "entity", value: "song"),
            .init(name: "term", value: "\(term)")
        ]
        
        URLSession.shared.dataTask(with: components!.url!) { data, response, error in
            if let data = data, (response as? HTTPURLResponse)?.statusCode == 200 {
                let tracks = try? JSONDecoder().decode(SearchMediaResponse.self, from: data).results
                DispatchQueue.main.async {
                    completion(.success(tracks ?? []))
                }
            } else {
                completion(.failure(APIError.unexpected(error)))
            }
        }.resume()
    }
}

The test implementation reveals the aforementioned signaling steps:

class MusicServiceTests: XCTestCase {

    func testSearchUsingSignaling() {
        // 1. Create expectation
        let didFinish = self.expectation(description: #function)
        
        let sut = MusicService()

        var result: Result<[Track], Error>?
        sut.search("Rock") {
            result = $0
            // 2. Signal
            didFinish.fulfill()
        }
        
        // 3. Wait until the expectation is fulfilled
        wait(for: [didFinish], timeout: 5)
        
        // 4. Assert
        XCTAssertNoThrow(try result?.get())
    }
}

Avoid Concurrency

This technique approaches unit testing of asynchronous code from different perspective. Instead of directly testing the async part, we test the code right before and right after it. This allows to split the test into two synchronous parts. The first one verifies preconditions before the async operation has started. The second checks postconditions as if the operation has finished.

Say we are testing a class that writes data to a file system. The class notifies delegate as soon as the write finishes:

class FileSystemWriter {
    weak var delegate: FileSystemWriterDelegate?
    private let queue: OperationQueue
    
    init(queue: OperationQueue) {
        self.queue = queue
    }
    
    func write(_ data: Data, to path: String) {
        let doWrite = BlockOperation { sleep(1) }
        let notifyDelegate = BlockOperation { [weak self] in self?.delegate?.didFinishWriting() }
        notifyDelegate.addDependency(doWrite)
        
        queue.addOperations([doWrite, notifyDelegate], waitUntilFinished: false)
    }
}

The delegation protocol is implement by FileSystemWriterClient that counts the number of written files:

protocol FileSystemWriterDelegate: AnyObject {
    func didFinishWriting()
}

class FileSystemWriterClient: FileSystemWriterDelegate {
    var filesWritten = 0

    func didFinishWriting() {
        filesWritten += 1
    }
}

‘Test before’ verifies that the file system writer has added the correct number operations to the queue:

class FileSystemWriterTests: XCTestCase {

    func testWriteAddsOperationsToQueue() {
        let queue = OperationQueue()
        let sut = FileSystemWriter(queue: queue)

        sut.write(Data(), to: "")

        XCTAssertEqual(queue.operationCount, 2)
    }
}

‘Test after’ targets the client and verifies that the counter is incremented:

class FileSystemWriterClientTests: XCTestCase {
    func testClientIncrementsFilesCounter() {
        let client = FileSystemWriterClient()
        
        client.didFinishWriting()
        
        XCTAssertEqual(client.filesWritten, 1)
    }
}

This way we have split the asynchronous test into two, which allowed to completely avoid concurrency.

Source Code

Source code with all examples from this article can be found here.

Summary

Let’s briefly recap the techniques for testing async code in Swift:

  • Mock dependencies to substitute async API with sync.
  • Use XCTestExpectation to signal and wait for async code to finish.
  • Test before and test after the concurrent operation.

Most importantly, ensure that the tests are isolated from each other and the side effects are fully controlled.