EXPEDIA GROUP TECHNOLOGY — SOFTWARE

Asynchronous Testing with Combine Schedulers

Testing asynchronous code can be hard but using the Combine framework with the right tools can make it easier

Travelers watching a hot air balloon event.
Photo by Mesut Kaya on Unsplash

First, let’s dive into a code example. This is in Swift, and it’s a simplified version of code in the Expedia app on Apple’s App Store.

It is a private function that does a network operation using a Combine publisher. The function subscribes to this publisher on the .userInteractive queue and uses the receive operator to get the output in the main thread for UI work.

When it comes to testing, we have the following mock.

We use the publisher Just to pass the output we want to consume in the test and avoid hitting the network. The current solution for testing is:

We wait for 0.3 seconds (an arbitrary time) to give the runloop a chance to do a full tick. Hopefully the mock will be ready to verify it. However, the moment we use the operators subscribe and receive, we make the publisher asynchronous and harder to test.

Here is where the test becomes unreliable. 0.3 seconds can be enough on our machines, most of the time, but not in CI. Moreover, we can chain multiple publishers swapping between background and main thread, making wait time even less predictable. Fundamentally, there is a mismatch between the synchronous test code and the asynchronous operation.

Solution

The Combine framework provides the Scheduler protocol, a powerful abstraction for describing how and when units of work will be executed. It unifies many disparate ways of executing work, such as DispatchQueue, RunLoop, and OperationQueue. This allows us to transform the asynchronous code into synchronous by changing the Scheduler.

Scheduler is a protocol, and Apple doesn’t offer an erased type for Schedulers as they do for AnyPublisher or AnyCancellable. So we would have to inject the Scheduler in every place using Generics.
However, there is a better way to use an erased type for Schedulers. Having AnyScheduler will allow us to work the same way as we do for Publishers.

To not reinvent the wheel, there is a good implementation of AnyScheduler by Point Free (https://github.com/pointfreeco/combine-schedulers). They also offer more Scheduler types that can be useful during testing.

How do we test this code?

We test it exactly like any other synchronous code, continuing with the same example:

We create the dependencies, stub the methods and verify the output, exactly like any other synchronous test. We have removed the exceptions that won’t fulfill, so no more waiting calls, the tests are now quicker to execute, and we can say goodbye to the flakiness.

But.. What changes do we need to introduce?

First, we import CombineSchedulers. Next, we declare the two schedulers we use for background and main threads. Finally, we provide default values to avoid breaking the factories in the init.

Our function remains the same, except for the new scheduler instances.

For testing, we use the immediate Scheduler for `work` and `main` thus converting the publisher into a synchronous one.

It is important to remember that ImmediateScheduler won’t be effective using other Combine operators such as debounce, throttle, delay, timeout, etc. We need to use the TestScheduler to control the execution in a deterministic manner.

Combine Scheduler library analysis

Overview

Sizes

  • Total lines of code: 2239
  • Source lines of code: 1985
  • Longest file: Timer.swift (311 source lines)
  • Longest type: AnyScheduler (339 source lines)

Structure

Imports:

  • Combine (14)
  • Foundation (7)
  • XCTest (6)
  • CombineSchedulers (5)
  • SwiftUI (2)
  • XCTestDynamicOverlay (2)
  • PackageDescription (2)
  • XCTest.XCTFail (1)
  • Darwin (1)
  • Dispatch (1)
  • Foundation (1)

UIKit View Controllers: 0

UIKit Views: 0

SwiftUI Views: 0

External Dependency: XCTestDynamicOverlay

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store