RxSwift & MVVM - How to use RxTests to test your ViewModel

Not that long ago, I wrote how to pair RxSwift with MVVM architecture in an iOS project. Even if I refactored my code to be reactive, I omitted to mention the unit tests. Today I’ll show step by step how to use RxTest to unit test your code.

Prerequisites

Before diving into the code, I would recommend you to read about how to integrate RxSwift with MVVM if you have missed it. This code is still based on the same sample app I used a MVVM template: a currency converter app.

RxSwift comes with RxBlocking as well as RxTests for testing purpose. I will cover only RxTests in this post.

Now let’s dive in.

Testability

Previously, my ViewModel I code was hardly testable. It has one output for everything. It was also triggered as soon as the ViewModel was initialized.

struct CurrencyViewModel {
    weak var service: CurrencyServiceObservable?
    let rates : Observable<[CurrencyRate]>

    init(service: CurrencyServiceObservable = FileDataService.shared) {
        self.service = service
        self.rates = service.fetchConverter()
    }
}

Technically there is nothing wrong about it: it works. However, what if I want to test only the error state of that stream? What if I want to test the success only? Finally, what if we want to reload the content on demand?

I prefer decouple each element and introduce a way to reload the content when needed. I also keep the code clean by using an Input / Output structure.

struct CurrencyViewModel {

    weak var service: CurrencyServiceObservable?

    // input and output
    // ...

    init(service: CurrencyServiceObservable = FileDataService.shared) {
        self.service = service

        let errorRelay = PublishRelay<String>()
        let reloadRelay = PublishRelay<Void>()

        let rates = reloadRelay
            .asObservable()
            .flatMapLatest({ service.fetchConverter() })
            .map({ $0.rates })
            .asDriver { (error) -> Driver<[CurrencyRate]> in
                errorRelay.accept((error as? ErrorResult)?.localizedDescription ?? error.localizedDescription)
                return Driver.just([])
            }


        self.input = Input(reload: reloadRelay)
        self.output = Output(rates: rates,
                             errorMessage: errorRelay.asDriver(onErrorJustReturn: "An error happened"))
    }
}

So far, I only decoupled the logic and migrated it from the ViewController to the ViewModel which increase testability of our code. The ViewController looks cleaner and more explicit.

class CurrencyViewController: UIViewController {

    private func bindViews() {

        // bind data to tableview
        self.viewModel.output.rates
            .drive(self.tableView.rx.items(cellIdentifier: "CurrencyCell", cellType: CurrencyCell.self)) { (row, currencyRate, cell) in
                cell.currencyRate = currencyRate
            }
            .disposed(by: disposeBag)

        // handling error
        self.viewModel.output.errorMessage
            .drive(onNext: { [weak self] errorMessage in
                guard let strongSelf = self else { return }
                strongSelf.showError(errorMessage)
            })
            .disposed(by: disposeBag)

        // reload at launch
        self.viewModel.input.reload.accept(())
    }
}

Wait, I thought we were talking about RxTests?

That’s correct. Like any other code, we need to make sure first our code is easily maintainable and testable. Now this ViewModel looks ready so let’s move on to the unit test cases.

RxTests

The main element of RxTests is its TestScheduler. It allows us to create testable observables and observers that we can bind to intercept content. Therefore, we’ll be able to see what’s coming in and out of our ViewModel.

override func setUp() {
    super.setUp()
    self.scheduler = TestScheduler(initialClock: 0)
    self.disposeBag = DisposeBag()
    self.service = MockCurrencyService()
    self.viewModel = CurrencyViewModel(service: service)
}

Let’s start small and test the error handling. We need to create testable observers to see comes out. We’ll need to bind our ViewModel to those observers to get outcomes. Then finally, we’ll trigger a reload faking the the view got launched.

func testFetchWithError() {

    // create testable observers
    let rates = scheduler.createObserver([CurrencyRate].self)
    let errorMessage = scheduler.createObserver(String.self)

    // giving a service with no currencies
    service.converter = nil

    viewModel.output.errorMessage
        .drive(errorMessage)
        .disposed(by: disposeBag)

    viewModel.output.rates
        .drive(rates)
        .disposed(by: disposeBag)

    // when fetching the service
    scheduler.createColdObservable([.next(10, ())])
        .bind(to: viewModel.input.reload)
        .disposed(by: disposeBag)
    scheduler.start()

    // expected error message
    XCTAssertEqual(errorMessage.events, [.next(10, "No converter")])
}

Why using cold observable here? What’s actually the difference with hot observable?

From ReactiveX.io

A “cold” Observable, on the other hand, waits until an observer subscribes to it before it begins to emit items, and so such an observer is guaranteed to see the whole sequence from the beginning.

That’s what we want. Where a hot observable wouldn’t wait. Read more about hot and cold observable here.

We still need a rates observer because we fire only an error when we fetch currency rates. If missing, no error message will be triggered. That’s by design of our ViewModel.

Let’s go further with currencies.

func testFetchCurrencies() {

    // create scheduler
    let rates = scheduler.createObserver([CurrencyRate].self)

    // giving a service with mocked currencies
    let expectedRates: [CurrencyRate] = [CurrencyRate(currencyIso: "USD", rate: 1.4)]
    service.converter = Converter(base: "GBP", date: "01-01-2018", rates: expectedRates)

    // bind the result
    viewModel.output.rates
        .drive(rates)
        .disposed(by: disposeBag)

    // mock a reload
    scheduler.createColdObservable([.next(10, ()), .next(30, ())])
        .bind(to: viewModel.input.reload)
        .disposed(by: disposeBag)

    scheduler.start()

    XCTAssertEqual(rates.events, [.next(10, expectedRates), .next(30, expectedRates)])
}

The code is very similar, I only inject mocked content that I’m expected to receive every time we trigger a reload. That’s the expected behavior from a ViewController point of view.


So we successfully get our unit tests working back again with our RxSwift and MVVM using RxTests. If you want to see the whole project together, it’s available on Github under rxswift branch.

To some people, RxTests looks overkilled, which I can understand. Personally, I really like it because it’s very close to the ViewController logic. The complexity doesn’t occur much since it’s what was done anyway.

That makes RxTests a great tool in a TDD approach if you are already using RxSwift. We can definitely cover our ViewModel before even thinking of our ViewController which is a great alternative.

Happy coding

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer 🇫🇷, writing about career development, mobile engineering and self-improvement

ShopBack 💰

Singapore 🇸🇬