iosrx-swiftrxtest

Mocking and Validating Results in RxSwift Unit Testing


I have just started learning RxSwift and trying to build a sample application to practice these concepts.

I have written a QuestionViewModel that loads list of questions from QuestionOps class. QuestionOps has a function getQuestions that returns Single<[Question]>.

Problem that I am facing is, how to mock the behavior of QuestionOps class while testing QuestionViewModel.

public class QuestionsListViewModel {

    public var questionOps: QuestionOps!

    private let disposeBag = DisposeBag()
    private let items = BehaviorRelay<[QuestionItemViewModel]>(value: [])
    public let loadNextPage = PublishSubject<Void>()
    public var listItems: Driver<[QuestionItemViewModel]>
    public init() {
        listItems = items.asDriver(onErrorJustReturn: [])

        loadNextPage
            .flatMapFirst { self.questionOps.getQuestions() }
            .map { $0.map { QuestionItemViewModel($0) } }
            .bind(to: items)
            .disposed(by: disposeBag)
    }
}
public class QuestionOps {

    public func getQuestions() -> Single<[Question]> {

        return Single.create { event -> Disposable in

            event(.success([]))
            return Disposables.create()
        }
    }
}

I have created this MockQuestionOps for test purpose:

public class MockQuestionOps : QuestionOps {

    //MARK: -
    //MARK: Responses
    public var getQuestionsResponse: Single<[Question]>?

    public func getQuestions() -> Single<[Question]> {
        self.getQuestionsResponse = Single.create { event -> Disposable in

            return Disposables.create()
        }
        return self.getQuestionsResponse!
    }
}

In my test case I am doing the following:

/// My idea here is to test in following maner:
/// - at some point user initates loading
/// - after some time got network response with status true
func testLoadedDataIsDisplayedCorrectly() {

    scheduler = TestScheduler(initialClock: 0)
    let questionsLoadedObserver = scheduler.createObserver([QuestionItemViewModel].self)

    let qOps = MockQuestionOps()
    vm = QuestionsListViewModel()
    vm.questionOps = qOps
    vm.listItems
        .drive(questionsLoadedObserver)
        .disposed(by: disposebag)

    // User initiates load questions
    scheduler.createColdObservable([.next(2, ())])
        .bind(to: vm.loadNextPage)
        .disposed(by: disposebag)

    // Simulating question ops behaviour of responding
    // to get question request

    /// HERE: -----------    
    /// This is where I am stuck
    /// How should I tell qOps to send particular response with delay

    scheduler.start()

    /// HERE: -----------
    /// How can I test list is initialy empty
    /// and after loading, data is correctly loaded
}

Solution

  • Here is a complete, compilable answer (not including imports.)

    There is no notion of "the list is initially empty". The list is always empty. It emits values over time and what you are testing is whether it emitted the correct values.

    class rx_sandboxTests: XCTestCase {
    
        func testLoadedDataIsDisplayedCorrectly() {
    
            let scheduler = TestScheduler(initialClock: 0)
            let disposebag = DisposeBag()
            let questionsLoadedObserver = scheduler.createObserver([QuestionItemViewModel].self)
    
            let qOps = MockQuestionOps(scheduler: scheduler)
            let vm = QuestionsListViewModel(questionOps: qOps)
            vm.listItems
                .drive(questionsLoadedObserver)
                .disposed(by: disposebag)
    
            scheduler.createColdObservable([.next(2, ())])
                .bind(to: vm.loadNextPage)
                .disposed(by: disposebag)
    
            scheduler.start()
    
            XCTAssertEqual(questionsLoadedObserver.events, [.next(12, [QuestionItemViewModel(), QuestionItemViewModel()])])
        }
    }
    
    protocol QuestionOpsType {
        func getQuestions() -> Single<[Question]>
    }
    
    struct MockQuestionOps: QuestionOpsType {
        func getQuestions() -> Single<[Question]> {
            return scheduler.createColdObservable([.next(10, [Question(), Question()]), .completed(10)]).asSingle()
        }
        let scheduler: TestScheduler
    }
    
    class QuestionsListViewModel {
    
        let listItems: Driver<[QuestionItemViewModel]>
        private let _loadNextPage = PublishSubject<Void>()
    
        var loadNextPage: AnyObserver<Void> {
            return _loadNextPage.asObserver()
        }
    
        init(questionOps: QuestionOpsType) {
            listItems = _loadNextPage
                .flatMapFirst { [questionOps] in
                    questionOps.getQuestions().asObservable()
                }
    
                .map { $0.map { QuestionItemViewModel($0) } }
                .asDriver(onErrorJustReturn: [])
        }
    }
    
    struct Question { }
    struct QuestionItemViewModel: Equatable {
        init() { }
        init(_ question: Question) { }
    }