Practical Testing with RxTest - part I
Marcin Hawro
September 21, 2021
Practical Testing with RxTest - part I
One of the most challenging aspects of day-to-day work with reactive code is the clarity of the reactive chains. Even the seasoned rx-experts could have troubles reasoning about multiple operators put together in often most unpredictable ways. A remedy for that could be a set of well-written tests. This article aims to present the most common testing scenarios for the reactive code. It will also summarise the best practices when using the RxTest library.
Test Scheduler
A central component of the RxTest is Test Scheduler. For someone who has worked with RxSwift before, the “scheduler” part might be misleading. The more famous Main Scheduler and its friends from RxSwift control the thread on which the given operation or multiple operations execute. Test Scheduler, on the other hand, has the following capabilities:
- it can create the Testable Observers, which record all reactive events sent by the specific Observable
- it can spawn the hot or cold Testable Observables, which could simulate the reactive events
- it can control the timing of the reactive events.
We will dive deep into all of these functionalities. For now, it’s handy to point out that using the Test Scheduler object as an input for observe(on:)
and subscribe(on:)
operators is a rare case. Most commonly, we want to pass the MainScheduler()
object (but not MainScheduler.sharedInstance
of the Singleton’s fame) to these operators.
Do I get the expected events?
Let’s inspect the simplest and most common test scenario for the reactive code - synchronous events validation. Or in simpler words - does the Observable send the events I would expect. To make things more expressive, we will use an example - a safe. We can send the integer numbers to the safe in an attempt to unlock it. Implementation could look like below:
struct Safe {
private let key = PublishSubject<Int>()
let isUnlocked: Observable<Bool>
init() {
let unlockingNumber = 4
isUnlocked = key.asObservable()
.map { number in
number == unlockingNumber
}
}
func tryUnlocking(withNumber number: Int) {
key.onNext(number)
}
}
As we can see, 4
is our lucky number. The most important thing to notice here is that we have access to the reactive output (Observable), but we don’t have access to the reactive input (Publish Subject is private). We can still simulate the reactive events using tryUnlocking(withNumber:)
method. However, this is just a normal method, and as such it doesn’t have any point where we could potentially connect the Test Scheduler or its products. But the isUnlocked
property is such a point.
Let’s show that unlucky number 7
will not unlock our safe.
We can use a Test Scheduler to create an observer which we can bind to the Observable. For example like this:
private var safe: Safe!
private var unlockFlagObserver: TestableObserver<Bool>!
private var disposeBag: DisposeBag!
override func setUpWithError() throws {
try super.setUpWithError()
disposeBag = DisposeBag()
let testScheduler = TestScheduler(initialClock: 0)
unlockFlagObserver = testScheduler.createObserver(Bool.self)
safe = Safe()
safe.isUnlocked.bind(to: unlockFlagObserver).disposed(by: disposeBag)
testScheduler.start()
}
unlockFlagObserver
is a Testable Observer object. Its power is to record all events with times that its connected Observable emits. The interesting facts about Test Scheduler for this particular scenario are that we don’t have to retain it and we don’t have to start it. We just use it to spawn the Testable Observer and that’s it. The complete test case could look like this:
func testThatTheSafeStaysLockedForInvalidNumber() throws {
// setup
let invalidNumber = 7
// execution
safe.tryUnlocking(withNumber: invalidNumber)
// validation
XCTAssertEqual(unlockFlagObserver.events, [.next(0, false)])
}
Will I get the expected events?
The previous example was an optimistic scenario when everything was synchronous. But often in the iOS world, we have to wait for the results. And sometimes we don’t know how long we are going to wait. Let’s consider how Test Scheduler could help us when something like this happens. The manufacturers of the safe wanted to make it a little bit harder to crack. That’s why they added a delay in getting the result:
struct DelayedSafe {
private let key = PublishSubject<Int>()
let isUnlocked: Observable<Bool>
init(scheduler: SchedulerType = MainScheduler.instance) {
let unlockingNumber = 4
let delay = Int.random(in: 1...5)
isUnlocked = key.asObservable()
.delay(DispatchTimeInterval.seconds(delay), scheduler: scheduler)
.map { number in
number == unlockingNumber
}
}
func tryUnlocking(withNumber number: Int) {
key.onNext(number)
}
}
Unfortunately, they also made it harder for us to test. How long before we get the feedback? We cannot tell - it’s random. Even Main Scheduler object won’t be of any help because the test will finish before we get the unlock flag. It doesn’t matter whether the event arrives on a main or a background thread it will arrive too late. The similiar situation could happen if our event-sending operation is asynchronous or requires scheduling at a particular time. We will inspect such case in the next parts. For now, let’s see how the Test Scheduler can help us with the delay.
final class DelayedSafe_RxUnlockingTests: XCTestCase {
private var safe: DelayedSafe!
private var testScheduler: TestScheduler!
private var unlockFlagObserver: TestableObserver<Bool>!
private var disposeBag: DisposeBag!
override func setUpWithError() throws {
try super.setUpWithError()
disposeBag = DisposeBag()
testScheduler = TestScheduler(initialClock: 0)
unlockFlagObserver = testScheduler.createObserver(Bool.self)
safe = DelayedSafe(scheduler: testScheduler)
safe.isUnlocked.bind(to: unlockFlagObserver).disposed(by: disposeBag)
}
override func tearDownWithError() throws {
safe = nil
unlockFlagObserver = nil
testScheduler = nil
disposeBag = nil
try super.tearDownWithError()
}
func testThatTheSafeStaysLockedForInvalidNumber() throws {
// setup
let invalidNumber = 7
// execution
safe.tryUnlocking(withNumber: invalidNumber)
testScheduler.start()
// validation
XCTAssertEqual(unlockFlagObserver.events.count, 1)
XCTAssertEqual(unlockFlagObserver.events[0].value, .next(false))
}
}
In the example above, we need to pass the Test Scheduler to the delay
operator. We do this via Delayed Safe’s initializer. The scheduler would now be able to capture the events into the schedulerQueue
property. It could then translate the real time of the delay into virtual time. Virtual time is an abstract, which allows the Test Scheduler to decouple the timing of the events from real time.
The second crucial thing here is starting the Test Scheduler. Start operation will pass the events accumulated in the schedulerQueue
to the Observables. The events will carry the virtual time markers, for example, 3 time units from now. But it will all happen instantaneously without slowing down the test or risking that the test case terminates before validating our system.
Conclusions
The above yields one interesting observation. We should allow Test Scheduler to consume all relevant events first. And only then we should perform a start operation on it. So it’s a good practice to call testScheduler.start()
as late as possible, ideally just before the validation of our expectations. Exactly as it was done in our last example.
Binding to Observables is such a common test case that I created the following snippet to avoid typing the same code all over again:
var disposeBag: DisposeBag!
disposeBag = DisposeBag()
disposeBag = nil
var testScheduler: TestScheduler!
testScheduler = TestScheduler(initialClock: 0)
testScheduler = nil
var observer: TestableObserver<<#Type#>>!
observer = testScheduler.createObserver(<#Type#>.self)
observer = nil
<#sutObservable#>.bind(to: observer).disposed(by: disposeBag)
It gives me the declaration, setup, and cleanup code, which I can distribute depending on which testing framework I’m using. You can push it even further by preparing dedicated snippets or even file templates for XCTests, Quick/Nimble, and others.
It’s a good point to conclude part one. We now understand how to validate the Observable’s output. In the next part, we will shed some light on the input. We will inspect how we can simulate the events in the lucky scenario when the tested API exposes the Subject.