1

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.

emenegro
  • 6,901
  • 10
  • 45
  • 68
  • 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. Also, you can bind the results to the observer and then check those events, without needing to do `let events = try! sut.results.take(2).toBlocking().toArray()` – JustinM May 07 '18 at 15:57
  • You're goddamn right, @JustinM! Now the question is how to inject the test scheduler :\ Please, convert this comment to an answer and I will accept it :) But, besides that, this only works using the first type of test, no with RxBlocking. – emenegro May 07 '18 at 16:03
  • give me a few mins and I'll write up a working example and post it – JustinM May 07 '18 at 16:07

1 Answers1

4

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.

JustinM
  • 2,202
  • 2
  • 14
  • 24