Testing Your RxSwift Code
In this tutorial, you’ll learn the key to testing RxSwift code. Specifically, you’ll learn how to create unit tests for your Observable streams. By Shai Mishali.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Testing Your RxSwift Code
35 mins
Advantages and Disadvantages of RxBlocking
As you might have noticed, RxBlocking is great and is easy to get started with as it sort of “wraps” the reactive concepts under very well-known constructs. Unfortunately, it comes with a few limitations that you should be aware of:
- It’s aimed at testing finite sequences, meaning that, if you want to test the first element or a list of elements of a completed sequence, RxBlocking will prove to be very useful. However, in the more common case of dealing with non-terminating sequences, using RxBlocking won’t provide the flexibility you need.
-
RxBlocking works by blocking the current thread and actually locking the run-loop. If your
Observable
schedules events with relatively long intervals or delays, yourBlockingObservable
will wait for those in a synchronous matter. - When you’re interested in asserting time-based events and confirming they contain the correct time stamp, RxBlocking is no help as it only captures elements and not their times.
- When testing outputs that depend on asynchronous input, RxBlocking won’t be useful as it blocks the current thread, for example, when testing an output that needs some other observable trigger to emit.
The next tests you need to implement run into most of these limitations. For example: Tapping the Play/Pause button should cause a new emission of the isPlaying
output, and this requires an asynchronous trigger (the tappedPlayPause
input). It would also be beneficial to test the times of the emissions.
Using RxTest
As mentioned in the last section, RxBlocking provides great benefits, but it might be a bit lacking when it comes to thoroughly testing your stream’s events, times and relations with other asynchronous triggers.
To resolve all of these issues, and more, RxTest comes to the rescue!
RxTest is an entirely different beast to RxBlocking, with the main difference being that it is vastly more flexible in its abilities and in the information that it provides about your streams. It’s able to do this because it provides its very own special scheduler called TestScheduler
.
Before diving into code, it’s worthwhile to go over what a scheduler actually is.
Understanding Schedulers
Schedulers are a bit of a lower-level concept of RxSwift, but it’s important to understand what they are and how they work, to better understand their role in your tests.
RxSwift uses schedulers to abstract and describe how to perform work, as well as to schedule the emitted events resulting from that work.
Why is this interesting, you might ask?
RxTest provides its own custom scheduler called TestScheduler
solely for testing. It simplifies testing time-based events by letting you create mock Observable
s and Observer
s so that you can “record” these events and test them.
If you’re interested in diving deeper into schedulers, the official documentation offers some great insights and guidelines.
Writing Your Time-Based Tests
Before writing your tests, you’ll need to create an instance of TestScheduler
. You’ll also add a DisposeBag
to your class to manage the Disposables
that your tests create. Below your viewModel
property, add the following properties:
var scheduler: TestScheduler!
var disposeBag: DisposeBag!
Then, at the end of setUp()
, add the following lines to create a new TestScheduler
and DisposeBag
before every test:
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()
The TestScheduler
‘s initializer takes in an initialClock
argument that defines the “starting time” for your stream. A new DisposeBag
will take care of getting rid of any subscriptions left by your previous test.
Onward to some actual test writing!
Your first test will trigger the Play/Pause button several times and assert the isPlaying
output emits changes accordingly.
To do that, you need to:
- Create a mock
Observable
stream emitting fake “taps” into thetappedPlayPause
input. - Create a mock
Observer
-like type to record events emitted by theisPlaying
output. - Assert the recorded events are the ones that you expect.
This might seem like a lot, but you’ll be surprised at how it comes together!
Some things are better explained with an example. Start by adding your first RxTest-based test:
func testTappedPlayPauseChangesIsPlaying() {
// 1
let isPlaying = scheduler.createObserver(Bool.self)
// 2
viewModel.isPlaying
.drive(isPlaying)
.disposed(by: disposeBag)
// 3
scheduler.createColdObservable([.next(10, ()),
.next(20, ()),
.next(30, ())])
.bind(to: viewModel.tappedPlayPause)
.disposed(by: disposeBag)
// 4
scheduler.start()
// 5
XCTAssertEqual(isPlaying.events, [
.next(0, false),
.next(10, true),
.next(20, false),
.next(30, true)
])
}
Don’t worry if this looks a bit intimidating. Breaking it down:
- Use your
TestScheduler
to create aTestableObserver
of the type of elements that you want to mock — in this case, aBool
. One of the main advantages of this special observer is that it exposes anevents
property that you can use to assert any events added to it. -
drive()
yourisPlaying
output into the newTestableObserver
. This is where you “record” your events. - Create a mock
Observable
that mimics the emission of three “taps” into thetappedPlayPause
input. Again, this is a special type ofObservable
called aTestableObservable
, which uses yourTestScheduler
to emit events on the provided virtual times. - Call
start()
on your test scheduler. This method triggers the pending subscriptions created in the previous points. - Use a special overload of
XCTAssertEqual
bundled with RxTest, which lets you assert the events inisPlaying
are equal, in both elements and times, to the ones you expect.10
,20
and30
correspond to the times your inputs fired, and0
is the initial emission ofisPlaying
.
Confused? Think about it this way: You “mock” a stream of events and feed it into your view model’s input at specific times. Then, you assert your output to make sure that it emits the expected events at the right times.
Run your tests again by pressing Command-U. You should see five passing tests.
Understanding Time Values
You’ve probably noticed the 0
, 10
, 20
and 30
values used for time and wondered what these values actually mean. How do they relate to actual time?
RxTest uses an internal mechanism for converting regular time (e.g., a Date
) into what it calls a VirtualTimeUnit
(represented by an Int
).
When scheduling events with RxTest, the times that you use can be anything that you’d like — they are entirely arbitrary and TestScheduler
uses them to schedule the events, like any other scheduler.
One important thing to keep in mind is that this virtual time doesn’t actually correspond with actual seconds, meaning, 10
doesn’t actually mean 10 seconds, but only represents a virtual time. You’ll learn a bit more about the internals of this mechanism later in this tutorial.
Now that you have a deeper understanding of times in TestScheduler
, why don’t you go back to adding more test coverage for your view model?
Add the following three tests immediately after the previous one:
func testModifyingNumeratorUpdatesNumeratorText() {
let numerator = scheduler.createObserver(String.self)
viewModel.numeratorText
.drive(numerator)
.disposed(by: disposeBag)
scheduler.createColdObservable([.next(10, 3),
.next(15, 1)])
.bind(to: viewModel.steppedNumerator)
.disposed(by: disposeBag)
scheduler.start()
XCTAssertEqual(numerator.events, [
.next(0, "4"),
.next(10, "3"),
.next(15, "1")
])
}
func testModifyingDenominatorUpdatesNumeratorText() {
let denominator = scheduler.createObserver(String.self)
viewModel.denominatorText
.drive(denominator)
.disposed(by: disposeBag)
// Denominator is 2 to the power of `steppedDenominator + 1`.
// f(1, 2, 3, 4) = 4, 8, 16, 32
scheduler.createColdObservable([.next(10, 2),
.next(15, 4),
.next(20, 3),
.next(25, 1)])
.bind(to: viewModel.steppedDenominator)
.disposed(by: disposeBag)
scheduler.start()
XCTAssertEqual(denominator.events, [
.next(0, "4"),
.next(10, "8"),
.next(15, "32"),
.next(20, "16"),
.next(25, "4")
])
}
func testModifyingTempoUpdatesTempoText() {
let tempo = scheduler.createObserver(String.self)
viewModel.tempoText
.drive(tempo)
.disposed(by: disposeBag)
scheduler.createColdObservable([.next(10, 75),
.next(15, 90),
.next(20, 180),
.next(25, 60)])
.bind(to: viewModel.tempo)
.disposed(by: disposeBag)
scheduler.start()
XCTAssertEqual(tempo.events, [
.next(0, "120 BPM"),
.next(10, "75 BPM"),
.next(15, "90 BPM"),
.next(20, "180 BPM"),
.next(25, "60 BPM")
])
}
These tests do the following:
-
testModifyingNumeratorUpdatesNumeratorText
: Tests that when you modify the numerator, the text updates correctly. -
testModifyingDenominatorUpdatesNumeratorText
: Tests that when you modify the denominator, the text updates correctly. -
testModifyingTempoUpdatesTempoText
: Tests that when you modify the tempo, the text updates correctly.
Hopefully, you feel right at home with this code by now as it is quite similar to the previous test. You mock changing the numerator to 3
, and then 1
. And you assert the numeratorText
emits "4"
(initial value of 4/4 signature), "3"
, and eventually "1"
.
Similarly, you test that changing the denominator’s value updates denominatorText
, accordingly. Notice that the numerator values are actually 1 through 4, while the actual presentation is 4
, 8
, 16
, and 32
.
Finally, you assert that updating the tempo properly emits a string representation with the BPM
suffix.
Run your tests by pressing Command-U, leaving you with a total of eight passing tests. Nice!
OK — seems you like you got the hang of it!
Time to step it up a notch. Add the following test:
func testModifyingSignatureUpdatesSignatureText() {
// 1
let signature = scheduler.createObserver(String.self)
viewModel.signatureText
.drive(signature)
.disposed(by: disposeBag)
// 2
scheduler.createColdObservable([.next(5, 3),
.next(10, 1),
.next(20, 5),
.next(25, 7),
.next(35, 12),
.next(45, 24),
.next(50, 32)
])
.bind(to: viewModel.steppedNumerator)
.disposed(by: disposeBag)
// Denominator is 2 to the power of `steppedDenominator + 1`.
// f(1, 2, 3, 4) = 4, 8, 16, 32
scheduler.createColdObservable([.next(15, 2), // switch to 8ths
.next(30, 3), // switch to 16ths
.next(40, 4) // switch to 32nds
])
.bind(to: viewModel.steppedDenominator)
.disposed(by: disposeBag)
// 3
scheduler.start()
// 4
XCTAssertEqual(signature.events, [
.next(0, "4/4"),
.next(5, "3/4"),
.next(10, "1/4"),
.next(15, "1/8"),
.next(20, "5/8"),
.next(25, "7/8"),
.next(30, "7/16"),
.next(35, "12/16"),
.next(40, "12/32"),
.next(45, "24/32"),
.next(50, "32/32")
])
}
Take a deep breath! This really isn’t anything new or terrifying but merely a longer variation of the same tests that you wrote so far. You’re adding elements onto both the steppedNumerator
and steppedDenominator
inputs consecutively to create all sorts of different time signatures, and then you are asserting that the signatureText
output emits properly formatted signatures.
This becomes clearer if you look at the test in a more visual way:
Feel free to run your test suite again. You now have 9 passing tests!
Next, you’ll take a crack at a more complex use case.
Think of the following scenario:
- The app starts with a
4/4
signature. - You switch to a
24/32
signature. - You then press the – button on the denominator; this should cause the signature to drop to
16/16
, then8/8
and, eventually,4/4
, because24/16
,24/8
and24/4
aren’t valid meters for your metronome.
Add a test for this scenario:
func testModifyingDenominatorUpdatesNumeratorValueIfExceedsMaximum() {
// 1
let numerator = scheduler.createObserver(Double.self)
viewModel.numeratorValue
.drive(numerator)
.disposed(by: disposeBag)
// 2
// Denominator is 2 to the power of `steppedDenominator + 1`.
// f(1, 2, 3, 4) = 4, 8, 16, 32
scheduler.createColdObservable([
.next(5, 4), // switch to 32nds
.next(15, 3), // switch to 16ths
.next(20, 2), // switch to 8ths
.next(25, 1) // switch to 4ths
])
.bind(to: viewModel.steppedDenominator)
.disposed(by: disposeBag)
scheduler.createColdObservable([.next(10, 24)])
.bind(to: viewModel.steppedNumerator)
.disposed(by: disposeBag)
// 3
scheduler.start()
// 4
XCTAssertEqual(numerator.events, [
.next(0, 4), // Expected to be 4/4
.next(10, 24), // Expected to be 24/32
.next(15, 16), // Expected to be 16/16
.next(20, 8), // Expected to be 8/8
.next(25, 4) // Expected to be 4/4
])
}
A bit complex, but nothing you can’t handle! Breaking it down, piece by piece:
- As usual, you start off by creating a
TestableObserver
and driving thenumeratorValue
output to it. - Here, things get a tad confusing, but looking at the visual representation below will make it clearer. You start by switching to a
32
denominator, and then switch to a24
numerator (on the second stream), putting you at a24/32
meter. You then drop the denominator step-by-step to cause the model to emit changes on thenumeratorValue
output. - Start the
scheduler
. - You assert that the proper
numeratorValue
is emitted for each of the steps.
Quite the complex test that you’ve made! Run your tests by pressing Command-U:
XCTAssertEqual failed: ("[next(4.0) @ 0, next(24.0) @ 10]") is not equal to ("[next(4.0) @ 0, next(24.0) @ 10, next(16.0) @ 15, next(8.0) @ 20, next(4.0) @ 25]") -
Oh, no! The test failed.
Looking at the expected result, it seems like the numeratorValue
output stays on 24
, even when the denominator drops down, leaving you with illegal signatures such as 24/16
or 24/4
. Build and run the app and try it yourself:
- Increase your denominator, leaving you at a 4/8 signature.
- Do the same for your numerator, getting to a 7/8 signature.
- Drop your denominator by one. You’re supposed to be at 4/4, but you’re actually at 7/4 — an illegal signature for your metronome!
Seems like you’ve found a bug. :]
Of course, you’ll make the responsible choice of fixing it.
Open MetronomeViewModel.swift and find the following piece of code responsible for setting up numeratorValue
:
numeratorValue = steppedNumerator
.distinctUntilChanged()
.asDriver(onErrorJustReturn: 0)
Replace it with:
numeratorValue = Observable
.combineLatest(steppedNumerator,
maxNumerator.asObservable())
.map(min)
.distinctUntilChanged()
.asDriver(onErrorJustReturn: 0)
Instead of simply taking the steppedNumerator
value and emitting it back, you combine the latest value from the steppedNumerator
with the maxNumerator
and map to the smaller of the two values.
Run your test suite again by pressing Command-U, and you should behold 10 beautifully executed tests. Amazing work!