Search code examples
iosswiftrx-swiftrx-blocking

RxSwift, tests with RxBlocking do not end


I'm trying to test a very simple view model:

struct SearchViewModelImpl: SearchViewModel {
    let query = PublishSubject<String>()
    let results: Observable<BookResult<[Book]>>

    init(searchService: SearchService) {
        results = query
            .distinctUntilChanged()
            .throttle(0.5, scheduler: MainScheduler.instance)
            .filter({ !$0.isEmpty })
            .flatMapLatest({ searchService.search(query: $0) })
    }
}

I'm trying to test receiving an error from service so I doubled it this way:

class SearchServiceStub: SearchService {
    let erroring: Bool

    init(erroring: Bool) {
        self.erroring = erroring
    }

    func search(query: String) -> Observable<BookResult<[Book]>> {
        if erroring {
            return .just(BookResult.error(SearchError.downloadError, cached: nil))
        } else {
            return books.map(BookResult.success) // Returns dummy books
        }
    }
}

I'm testing a query that errors this way:

func test_when_searchBooksErrored_then_nextEventWithError() {
    let sut = SearchViewModelImpl(searchService: SearchServiceStub(erroring: true))
    let observer = scheduler.createObserver(BookResult<[Book]>.self)

    scheduler
        .createHotObservable([
            Recorded.next(200, ("Rx")),
            Recorded.next(800, ("RxSwift"))
        ])
        .bind(to: sut.query)
        .disposed(by: disposeBag)

    sut.results
        .subscribe(observer)
        .disposed(by: disposeBag)

    scheduler.start()

    XCTAssertEqual(observer.events.count, 2)
}

To begin I'm just asserting if the count of events is correct but I'am only receiving one not two. I thought it was a matter of asynchronicity so I changed the test to use RxBlocking:

func test_when_searchBooksErrored_then_nextEventWithError() {
    let sut = SearchViewModelImpl(searchService: SearchServiceStub(erroring: true))
    let observer = scheduler.createObserver(BookResult<[Book]>.self)

    scheduler
        .createHotObservable([
            Recorded.next(200, ("Rx")),
            Recorded.next(800, ("RxSwift"))
        ])
        .bind(to: sut.query)
        .disposed(by: disposeBag)

    sut.results.debug()
        .subscribe(observer)
        .disposed(by: disposeBag)

    let events = try! sut.results.take(2).toBlocking().toArray()

    scheduler.start()

    XCTAssertEqual(events.count, 2)
}

But this never ends.

I don't know if there is something wrong with my stub, or maybe with the viewmodel, but the production app works correctly, emitting the events as the query fires.

Documentation of RxTest and RxBlocking is very very short, with the classic examples with a string or an integer, but nothing related with this kind of flow... it is very frustrating.


Solution

  • Your throttling your query with the MainScheduler.instance scheduler. Try removing that and see what happens. That is probably why your only getting one. You need to inject the test scheduler into that throttle when testing.

    There are a few different ways to go about getting the right scheduler into your model. Based on your current code, dependency injection would work fine.

    struct SearchViewModelImpl: SearchViewModel {
        let query = PublishSubject<String>()
        let results: Observable<BookResult<[Book]>>
    
        init(searchService: SearchService, scheduler: SchedulerType = MainScheduler.instance) {
            results = query
                .distinctUntilChanged()
                .throttle(0.5, scheduler: scheduler)
                .filter({ !$0.isEmpty })
                .flatMapLatest({ searchService.search(query: $0) })
        }
    }
    

    then in your test:

    let sut = SearchViewModelImpl(searchService: SearchServiceStub(erroring: true), scheduler: testScheduler)
    

    Also, rather than using toBocking(), you can bind the results events to the testable Observer.

    func test_when_searchBooksErrored_then_nextEventWithError() {
        let sut = SearchViewModelImpl(searchService: SearchServiceStub(erroring: true), scheduler: testScheduler)
        let observer = scheduler.createObserver(BookResult<[Book]>.self)
    
        scheduler
            .createHotObservable([
                Recorded.next(200, ("Rx")),
                Recorded.next(800, ("RxSwift"))
            ])
            .bind(to: sut.query)
            .disposed(by: disposeBag)
    
        sut.results.bind(to: observer)
         .disposed(by: disposeBag)
    
        scheduler.start()
    
        XCTAssertEqual(observer.events.count, 2)
    }
    

    Although toBlocking() can be useful in certain situation, you get a lot more information when you bind the events to a testableObserver.