1

I'm trying to test the main functionality of my ViewModel. The important step is to test te loaded state completed. But for sure, for a better test it could be interesting to test al states.

I was reading a lot of post and information about RxTest and RxBlocking but I'm not able to test this module. If someone can help me, it would be great!

struct Product: Equatable { }
struct Promotion { }

protocol ProductsRepository {
    func fetchProducts() -> Observable<Products>
    func fetchPromotions()  -> Observable<[Promotion]>
}

struct ProductCellViewModel: Equatable {
    let product: Product
}

struct Products {
    let products: [Product]
}

enum ProductsViewState: Equatable {
    case loading
    case empty
    case error
    case loaded ([ProductCellViewModel])
}

class ProductsViewModel {

    var repository: ProductsRepository

    let disposeBag = DisposeBag()
    private var productCellViewModel: [ProductCellViewModel]
    private var promotions: [Promotion]

    // MARK: Input

    init(repository: ProductsRepository) {
        self.repository = repository
        productCellViewModel = [ProductCellViewModel]()
        promotions = [Promotion]()
    }

    func requestData(scheduler: SchedulerType) {
        state.onNext(.loading)
        resetCalculate()
        repository.fetchProducts()
            .observeOn(scheduler)
            .flatMap({ (products) -> Observable<[ProductCellViewModel]> in
                return self.buildCellViewModels(data: products)
            }).subscribe(onNext: { (cellViewModels) in
                self.productCellViewModel = cellViewModels
            }, onError: { (error) in
                self.state.onNext(.error)
            }, onCompleted: {
                self.repository.fetchPromotions()
                    .flatMap({ (promotions) -> Observable<[Promotion]> in
                        self.promotions = promotions
                        return Observable.just(promotions)
                    }).subscribe(onNext: { (_) in
                        self.state.onNext(.loaded(self.productCellViewModel))
                    }, onError: { (error) in
                        self.state.onNext(.error)
                    }).disposed(by: self.disposeBag)
            }).disposed(by: disposeBag)
    }

    // MARK: Output

    var state = PublishSubject<ProductsViewState>()

    // MARK: ViewModel Map Methods

    private func buildCellViewModels(data: Products) -> Observable <[ProductCellViewModel]> {
        var viewModels = [ProductCellViewModel]()
        for product in data.products {
            viewModels.append(ProductCellViewModel.init(product: product))
        }
        return Observable.just(viewModels)
    }

    func resetCalculate() {
        productCellViewModel = [ProductCellViewModel]()
    }
}

The goal is to be able to test all of ProductsViewState after viewmodel.requestData() is being called

Daniel T.
  • 32,821
  • 6
  • 50
  • 72
Michel Marqués
  • 161
  • 1
  • 2
  • 10
  • It would help if you posted compilable code. – Daniel T. Jun 03 '19 at 11:30
  • Thank you for your answer. I think all you need is published. You are requesting products and the view will show (loadin, error, empty or and array of viewmodelcell converted inside of my viewmodel), so I have to test all of that ViewStates, but I don't know how can I do. Btw tell me what you need and I will try to add it. Thanks again – Michel Marqués Jun 03 '19 at 14:52
  • You are asking us to make the above code compilable so we can write your tests for you? How about you do the first half of that and post compilable code. – Daniel T. Jun 03 '19 at 14:57
  • Here it is the complete project: https://github.com/iMark21/MVVM-RxSwift-Coordinator-State – Michel Marqués Jun 08 '19 at 12:03
  • To help you out, I edited your question to include a minimal compilable example from your project. – Daniel T. Jun 08 '19 at 13:05
  • I've updated your edition in order to update last changes of the code – Michel Marqués Jun 08 '19 at 13:09
  • And now the sample doesn't compile any more. A minimal _compilable_ sample must compile. I fixed it again. If you make any changes to it, _please_ make sure it compiles and is the least amount of code necessary for the question. – Daniel T. Jun 08 '19 at 13:30
  • So sorry Daniel and thank you for your edition. I will check and test your solution. BTW is it a good practice to pass Scheduler to the viewModel? In the other hand, make equatable that enums is correct only because i need to test that code? – Michel Marqués Jun 09 '19 at 13:48
  • If the view model needs a scheduler, then you should pass it in. This view model has a mix of imperative and declarative code (which is odd IMHO.) I assume that's why you felt the need to add the `observeOn(_:)`. View models shouldn't need this except in very rare situations. – Daniel T. Jun 09 '19 at 13:53
  • I made the enums Equatable because it made testing easier and it was obvious to me that they _could_ be made equatable. Now that Swift generates the `==(_:_:)` for us, it was easier to do than to make the tests more complex to work around it. – Daniel T. Jun 09 '19 at 13:55
  • Thank you for your quick and clear answer. BTW if you want to improve or correct my code, feel free to request me a pull request from my repo. In the other hand I will check your answer asap. Thank you @DanielT. great! – Michel Marqués Jun 09 '19 at 13:59

1 Answers1

2

The key here is that you have to inject your scheduler into the function so you can inject a test scheduler. Then you will be able to test your state. BTW that state property should be a let not a var.

class ProductsViewModelTests: XCTestCase {

    var scheduler: TestScheduler!
    var result: TestableObserver<ProductsViewState>!
    var disposeBag: DisposeBag!

    override func setUp() {
        super.setUp()
        scheduler = TestScheduler(initialClock: 0)
        result = scheduler.createObserver(ProductsViewState.self)
        disposeBag = DisposeBag()
    }

    func testStateLoaded() {
        let mockRepo = MockProductsRepository(products: { .empty() }, promotions: { .empty() })
        let viewModel = ProductsViewModel(repository: mockRepo)

        viewModel.state.bind(to: result).disposed(by: disposeBag)
        viewModel.requestData(scheduler: scheduler)

        scheduler.start()

        XCTAssertEqual(result.events, [.next(0, ProductsViewState.loading), .next(1, .loaded([]))])
    }

    func testState_ProductsError() {
        let mockRepo = MockProductsRepository(products: { .error(StubError()) }, promotions: { .empty() })
        let viewModel = ProductsViewModel(repository: mockRepo)

        viewModel.state.bind(to: result).disposed(by: disposeBag)
        viewModel.requestData(scheduler: scheduler)

        scheduler.start()

        XCTAssertEqual(result.events, [.next(0, ProductsViewState.loading), .next(1, .error)])
    }

    func testState_PromotionsError() {
        let mockRepo = MockProductsRepository(products: { .empty() }, promotions: { .error(StubError()) })
        let viewModel = ProductsViewModel(repository: mockRepo)

        viewModel.state.bind(to: result).disposed(by: disposeBag)
        viewModel.requestData(scheduler: scheduler)

        scheduler.start()

        XCTAssertEqual(result.events, [.next(0, ProductsViewState.loading), .next(1, .error)])
    }
}

struct StubError: Error { }

struct MockProductsRepository: ProductsRepository {
    let products: () -> Observable<Products>
    let promotions: () -> Observable<[Promotion]>

    func fetchProducts() -> Observable<Products> {
        return products()
    }

    func fetchPromotions() -> Observable<[Promotion]> {
        return promotions()
    }
}
Daniel T.
  • 32,821
  • 6
  • 50
  • 72