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:
PassthroughSubject
that is directly connected toUIButton
- Flat map of this subject that switches to a
Deferred
publisher - Inside of the flat map there can be yet another publisher returned -
Combine.Just
- in case of an error - 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 onReactiveSwift.SignalProducer
that returnsSwift.Result<Success, Failure>
so it will look liketry? 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