Engineering

How to test iOS apps with Combine, ReactiveSwift and RxSwift

Some time ago, when I started working with Reactive Programming concept for developing iOS applications I struggled a lot with testing such products. First, I read some articles about the possibilities of testing for RxSwift. Then, when I switched to ReactiveSwift, I used similar mechanisms... Now, when Combine has been released by Apple, the same problems and patterns are occuring one more time, I've decided to write an article whose purpose is to present the same ideas in three different frameworks. I hope this will help you understand what the most common patterns that you can use when testing Reactive Code are!

Testing environment

I've prepared a sample app that contains 3 buttons and one label. Each button represents different reactive programming library for Swift, while the label should display the latest selected library name.

The library name is represented in the code by using following data model:

struct Library {
    let name: String
}

After tapping a button, Library is fetched from an external service - just to introduce asynchronous data flow - the interface of objects responsible for providing such a library looks as follows:

protocol WebService {
    func getLibrary_Combine() -> Combine.Deferred<AnyPublisher<Library, URLError>>
    func getLibrary_ReactiveSwift() -> ReactiveSwift.SignalProducer<Library, URLError>
    func getLibrary_RxSwift() -> RxSwift.Single<Library>
}

As you can see, I've used the most recommended traits for representing http request, if you’re not familiar with them, please take a look here: Combine, ReactiveSwift, RxSwift

To make this article clearer, I've decided not to mix it with SwiftUI, so I've used UIButton and UILabel components. To represent how I've modeled the view, let me present you the inputs and outputs of the Controller:

struct Input {
    let didTapCombineButton: Combine.PassthroughSubject<Void, Never>
    let didTapReactiveSwiftButton: ReactiveSwift.Signal<Void, Never>.Observer
    let didTapRxSwiftButton: RxSwift.AnyObserver<Void>
}

struct Output {
    let textChangesCombine: Combine.AnyPublisher<String, Never>
    let textChangesReactiveSwift: ReactiveSwift.Signal<String, Never>
    let textChangesRxSwift: RxSwift.Observable<String>
}

Basically, the idea is that I'm using observers to notify about changes (taps), an observer is a part of Subject that is only responsible for sending events. Unfortunately, the Combine.Subject protocol is not a composition of Observer and Publisher but just the extension of Publisher so we're unable to split this responsibility when using Combine. This is why I've decided to use PassthroughSubject in case of Combine library. Both other frameworks - RxSwift and ReactiveSwift define interface for Observer that can be used only for sending new events.

When it comes to observation of text changes, I've defined 3 different signals for each library. Here's the example of the relation between input and output:

let subject = Combine.PassthroughSubject<Void, Never>()
let textChanges = subject
    .flatMap { [webService] in
        webService.getLibrary_Combine()
            .map { "Library name: \($0.name)" }
            .catch { Combine.Just("Error occurred: \($0.localizedDescription)") }
    }
    .eraseToAnyPublisher()

Looks simple, right? Connections between this code and UIKit components are also pretty straightforward, so let me not bother you with it anymore.

Testing

Sink pattern

Before actually testing this, we need to prepare one more important part of this system - mocking. I usually handle mocks generation by using Sourcery tool, but in this code sample, we can simplify this with the following technique:

class WebServiceMock: WebService {
    typealias GetLibrary_Combine_ReturnType = Combine.Deferred<Combine.AnyPublisher<Library, URLError>>
    var getLibrary_Combine_Closure: (() -> GetLibrary_Combine_ReturnType)!
    func getLibrary_Combine() -> GetLibrary_Combine_ReturnType {
        return getLibrary_Combine_Closure()
    }
    
    ... // same for all other methods of this service

Thanks to using the structure above, mocking is as simple as assigning a value to a variable:

func testCombinetTap_TappedOnce_ExpectTextToContainCombineLibrary() {
    // Given
    webServiceMock.getLibrary_Combine_Closure = {
        Combine.Deferred(createPublisher: {
            Result.success(Library(name: "CombineLib"))
                .publisher
                .eraseToAnyPublisher()
        })
    }

    ...

To test the output (label text) we need to introduce some mechanism allowing us to read the value produced by a publisher after tapping a button. So, at first step, we should subscribe to text changes, and then tap a button. I've introduced a property that collects the latest value from a label text publisher, which I named sink:

// Given
webServiceMock.getLibrary_Combine_Closure = ...
let viewModel = sut.viewModel()
let sink = Combine.CurrentValueSubject<String?, Never>(nil)
viewModel.output.textChangesCombine
    .map(Optional.init)
    .subscribe(sink)
    .store(in: &combineDisposables)

// When
viewModel.input.didTapCombineButton.send(Void())

// Then
XCTAssertTrue(sink.value?.contains("CombineLib") ?? false)

The example above looks pretty clear, but the more you think about it, the more you will see how many different structures are needed for this one particular test case. Data flow is represented here by:

  1. PassthroughSubject that is directly connected to UIButton
  2. Flat map of this subject that switches to a Deferred publisher
  3. Inside of the flat map there can be yet another publisher returned - Combine.Just - in case of an error
  4. Another subject, this time CurrentValueSubject, which the flattened publisher is connected to

Even when using so many reactive structures, the whole flow still remained synchronous! This is great, because we don't need to use any expectation or other XCTest asynchronous testing mechanism, which will make the test much less clean. But, if you think about a real implementation of WebService, which has an asynchronous callback, here comes the challenge. It will no longer be synchronous! In Reactive world, everything is a stream. Our textChangesCombine is a stream and we can simulate asynchronicity by switching the scheduler for all downstreams of this stream.

Schedulers

The example above is the simplest possible scenario, mostly because we didn't use any different queue than the main one. What we usually do is switching to background queues to prevent locking user interface for long-term operations. The same as a real implementation of WebService, some heavy computations or delays, such as throttling or debouncing, are commonly used in the reactive world. This is done by using schedulers. Surprisingly, the name is the same for all 3 libraries. If we take a look at one of the interfaces (all of them look similar), we will see that it's pretty straightforward:

public protocol Scheduler {
    func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void)
    ...

Yes - the important fact is that this is a protocol, so it's up to the implementation how the action will be executed. This basically means that we can modify this behavior to call it synchronously again, or even travel in time. For different libraries, you would like to mock different types of schedulers, but after all, you may end up with following structure:

protocol SchedulerProvider { 
    func scheduler(forQueue queue: DispatchQueue) -> Scheduler
}

Anyway, you will use different objects for different libraries. ReactiveSwift (ReactiveSwift.TestScheduler) and RxSwift (RxTest.TestScheduler) have their own objects called TestScheduler that allow you to perform time travelling out of the box. For Combine, you can use Combine.ImmediateScheduler, which executes everything in a synchronous way. For now, either time traveling is not possible, or you can implement your own testing scheduler that allows this, or use 3rd party library.

No matter what you are going to use, you want to replace any implementations of schedulers with a provider call that will allow you to inject a scheduler in tests, such as:

...        
webService.getLibrary_Combine()
    ...
    .receive(on: schedulerProvider.scheduler(forQueue: heavyComputationQueue))

Testing cold streams

When you're testing cold reactive streams, you have one more opportunity - (hot/cold concept explained here). What you can do, is to just subscribe to such stream and lock current thread as long as the value is not provided. This is similar to async / await pattern, which you can read more about here. Let's imagine you’re testing a following transformation, such as:

func ensureLibraryExists(
    library: Library
    ) -> AnyPublisher<Bool, Never> {
    let libraryGlobalIdentifier = webService.getLibraryDetails(
            library: library
        )
        .map { $0.globalIdentifier }
    let allReactiveLibraries = webService.getAllReactiveLibrariesIdentifiers()

    return Combine.Publishers.CombineLatest(
            libraryGlobalIdentifier,
            allReactiveLibraries
        )
        .map { $1.contains($0) }
        .eraseToAnyPublisher()
}

You may want to test such function’s result, which may look like this:

XCTAssertTrue(
    ensureLibraryExists(library: combine).firstValue()
)

This looks really great, but again, there are different ways to achieve that:

  • Combine - not supported by default - you'll need to add some extension - I hope I've inspired you enough to do that on your own!
  • ReactiveSwift - there is first() method on ReactiveSwift.SignalProducer that returns Swift.Result<Success, Failure> so it will look like try? producer.first()
  • RxSwift - there is a separate library called RxBlocking that enables this functionality

Summary

As you can see, there are some trade-offs, and you can't just use those three libraries in exactly the same way, however, some patterns are being repeated for all of them. In the end, let me give you some tips that I've noted during 2 years of programming reactively almost every day:

  • Use Sink pattern when dealing with hot streams
  • Use blocking api when testing cold streams
  • Always inject any async schedulers
  • Avoid using XCTestExpectation - the above tools are much cleaner and do not allow you to set up long expectation timeouts that can possibly hide other issues

If you would like to see all three libraries examples, please visit this sample project on my GitHub. You can find an absolutely great article about the insights of Combine framework here. Feel free to give some claps if you've found this article useful! Should you have any questions, feel free to ask me on Twitter