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.

5 (17) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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:

  1. 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.
  2. RxBlocking works by blocking the current thread and actually locking the run-loop. If your Observable schedules events with relatively long intervals or delays, your BlockingObservable will wait for those in a synchronous matter.
  3. 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.
  4. 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.

RxTest - Measuring timed events

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 Observables and Observers 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:

  1. Create a mock Observable stream emitting fake “taps” into the tappedPlayPause input.
  2. Create a mock Observer-like type to record events emitted by the isPlaying output.
  3. 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:

  1. Use your TestScheduler to create a TestableObserver of the type of elements that you want to mock — in this case, a Bool. One of the main advantages of this special observer is that it exposes an events property that you can use to assert any events added to it.
  2. drive() your isPlaying output into the new TestableObserver. This is where you “record” your events.
  3. Create a mock Observable that mimics the emission of three “taps” into the tappedPlayPause input. Again, this is a special type of Observable called a TestableObservable, which uses your TestScheduler to emit events on the provided virtual times.
  4. Call start() on your test scheduler. This method triggers the pending subscriptions created in the previous points.
  5. Use a special overload of XCTAssertEqual bundled with RxTest, which lets you assert the events in isPlaying are equal, in both elements and times, to the ones you expect. 10, 20 and 30 correspond to the times your inputs fired, and 0 is the initial emission of isPlaying.

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.

TestableObserver, TestableObservable, Asserting timed events

Run your tests again by pressing Command-U. You should see five passing tests.

5 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!

8 Passing Tests

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:

Multiple inputs / single output RxTest

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:

  1. The app starts with a 4/4 signature.
  2. You switch to a 24/32 signature.
  3. You then press the button on the denominator; this should cause the signature to drop to 16/16, then 8/8 and, eventually, 4/4, because 24/16, 24/8 and 24/4 aren’t valid meters for your metronome.
Note: Even though some of these meters are valid musically, you’ll consider them illegal for the sake of 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:

  1. As usual, you start off by creating a TestableObserver and driving the numeratorValue output to it.
  2. 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 a 24 numerator (on the second stream), putting you at a 24/32 meter. You then drop the denominator step-by-step to cause the model to emit changes on the numeratorValue output.
  3. Start the scheduler.
  4. You assert that the proper numeratorValue is emitted for each of the steps.

Two inputs, One output skipping RxTest

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!

Invalid meters not handled properly

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!

Success! Awesome work.