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.
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.
Related
I have this code:
...
let minMaxReq = networkProvider.getMinMaxAmortization(id: id)
let emitExtractReq = networkProvider.getEmitExtract(id: id)
self.isLoading.accept(true)
Observable.zip(minMaxReq.asObservable(), emitExtractReq.asObservable()) { (minMaxResp, emitExtractResp) in
return (minMaxResp, emitExtractResp)
}.subscribe(onNext: { [weak self] responses in
let minMaxResp = responses.0
let emitExtractResp = responses.1
guard let self = self else { return }
self.isLoading.accept(false)
self.getMinMaxAmortizationResponse.accept(minMaxResp)
self.receiptsCNH.accept(emitExtractResp)
}, onError: { [weak self] error in
self?.isLoading.accept(false)
self?.receivedError.accept(error)
}).disposed(by: disposeBag)
In this case all errors from both requests will end up in the onError closure, how can I handle the error from minMaxReq in a different onError closure?
My goal is to make the 2 requests at the same time but handle their error with different closures. How can I achieve this?
thanks
Have you tried the materialize operator? Maybe it can be useful for the implementation you need. Annex an example of how it is usually used:
let valid = network.getToken(apikey)
.flatMap{ token in self.verifyToken(token).materialize()}
.share()
valid
.compactMap { $0.element }
.subscribe(onNext: { data in print("token is valid:", data) })
.disposed(by: disposeBag)
valid
.compactMap { $0.error?.localizedDescription }
.subscribe(onNext: { data in print("token is not valid:", data) })
.disposed(by: disposeBag)
In that way, you could divide the stream into two and give it the appropriate treatment.
Another option might be to manipulate the error event in the minMaxReq operation. Something similar to:
let minMaxReq = networkProvider.getMinMaxAmortization(id: id)
.catchError({ error in Observable.empty()
.do(onCompleted: { /* Do anything with no side effect*/ }) })
let emitExtractReq = networkProvider.getEmitExtract(id: id)
Observable.zip(...)
Here is an article that explains more detail Handling Errors in RxSwift
I found this solution helpful for me RxSwift zip operator when one observable can fail
Need to catch error before add to zip
let request1 = usecase.request1().asObservable()
let request2 = usecase.request2().catchErrorJustReturn([]).asObservable() // fetching some not so important data which are just good to have.Return empty array on error
Observable.zip(request1,request2)..observeOn(MainScheduler.instance)
.subscribe(
onNext: { //success code },
onError: { _ in //error code}).disposeBy(bag:myDisposeBag)
But I changed a bit the final:
let request2 = usecase.request2().catchError { _ in
return .just([])
}
I have a BehaviorSubject where my tableview is bound to through RxDataSources.
Besides that, I have a pull to refresh which creates an observable that updates the data and updates the data in the BehaviorSubject so that my UITableView updates correctly.
Now the question is, how do I handle the error handling for whenever my API call fails?
Few options that I have thought of was:
Subscribe to the observer's onError and call the onError of my BehaviorSubject\
Somehow try to concat? or bind(to: ..)
Let another subscriber in my ViewController subscribe besides that my tableview subscribes to the BehaviorSubject.
Any suggestions?
Ideally, you wouldn't use the BehaviorSubject at all. From the Intro to Rx book:
The usage of subjects should largely remain in the realms of samples and testing. Subjects are a great way to get started with Rx. They reduce the learning curve for new developers, however they pose several concerns...
Better would be to do something like this in your viewDidLoad (or a function that is called from your viewDidLoad):
let earthquakeData = Observable.merge(
tableView.refreshControl!.rx.controlEvent(.valueChanged).asObservable(),
rx.methodInvoked(#selector(UIViewController.viewDidAppear(_:))).map { _ in }
)
.map { earthquakeSummary /* generate URLRequest */ }
.flatMapLatest { request in
URLSession.shared.rx.data(request: request)
.materialize()
}
.share(replay: 1)
earthquakeData
.compactMap { $0.element }
.map { Earthquake.earthquakes(from: $0) }
.map { $0.map { EarthquakeCellDisplay(earthquake: $0) } }
.bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: EarthquakeTableViewCell.self)) { _, element, cell in
cell.placeLabel.text = element.place
cell.dateLabel.text = element.date
cell.magnitudeLabel.text = element.magnitude
cell.magnitudeImageView.image = element.imageName.isEmpty ? UIImage() : UIImage(named: element.imageName)
}
.disposed(by: disposeBag)
earthquakeData
.compactMap { $0.error }
.map { (title: "Error", message: $0.localizedDescription) }
.bind { [weak self] title, message in
self?.presentAlert(title: title, message: message, animated: true)
}
.disposed(by: disposeBag)
The materialize() operator turns a Event.error(Error) result into an Event.next(.error(Error)) so that the chain won't be broken down. The .compactMap { $0.element } emits only the successful results while the .compactMap { $0.error } emits only the errors.
The above code is adapted from my RxEarthquake sample.
I'm working on iOS App that uses the IP Stack API for geolocation. I'd like to optimise the IP Stack Api usage by asking for external (public) IP address first and then re-use lat response for that IP if it hasn't changed.
So what I'm after is that I ask every time the https://www.ipify.org about external IP, then ask https://ipstack.com with given IP address. If I ask the second time but IP doesn't changed then re-use last response (or actually cached dictionary with IP's as keys and responses as values).
I have a solution but I'm not happy with this cache property in my code. It is some state and some other part of code can mutate this. I was thinking about using some scan() operator in RxSwfit but I just can't figure out any new ideas.
class ViewController: UIViewController {
#IBOutlet var geoButton: UIButton!
let disposeBag = DisposeBag()
let API_KEY = "my_private_API_KEY"
let provider = PublicIPProvider()
var cachedResponse: [String: Any] = [:] // <-- THIS
override func viewDidLoad() {
super.viewDidLoad()
}
#IBAction func geoButtonTapped(_ sender: UIButton) {
// my IP provider for ipify.org
// .flatMap to ignore all nil values,
// $0 - my structure to contains IP address as string
let fetchedIP = provider.currentPublicIP()
.timeout(3.0, scheduler: MainScheduler.instance)
.flatMapLatest { Observable.from(optional: $0.ip) }
.distinctUntilChanged()
// excuse me my barbaric URL creation, it's just for demonstration
let geoLocalization = fetchedIP
.flatMapLatest { ip -> Observable<Any> in
// check if cache contains response for given IP address
guard let lastResponse = self.cachedResponse[ip] else {
return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=\(API_KEY)")! ))
.do(onNext: { result in
// store cache as a "side effect"
print("My result 1: \(result)")
self.cachedResponse[ip] = result
})
}
return Observable.just(lastResponse)
}
geoLocalization
.subscribe(onNext: { result in
print("My result 2: \(result)")
})
.disposed(by: disposeBag)
}
}
Is it possible to achieve the same functionality but without var cachedResponse: [String: Any] = [:] property in my class?
OMG! I spent a bunch of time with the answer for this question (see below) and then realized that there is a much simpler solution. Just pass the correct caching parameter in your URLRequest and you can do away with the internal cache completely! I left the original answer because I also do a general review of your code.
class ViewController: UIViewController {
let disposeBag = DisposeBag()
let API_KEY = "my_private_API_KEY"
let provider = PublicIPProvider()
#IBAction func geoButtonTapped(_ sender: UIButton) {
// my IP provider for ipify.org
let fetchedIP: Maybe<String> = provider.currentPublicIP() // `currentPublicIP()` returns a Single
.timeout(3.0, scheduler: MainScheduler.instance)
.map { $0.ip ?? "" }
.filter { !$0.isEmpty }
// excuse me my barbaric URL creation, it's just for demonstration
let geoLocalization = fetchedIP
.flatMap { (ip) -> Maybe<Any> in
let url = URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")!
return URLSession.shared.rx.json(request: URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad))
.asMaybe()
}
geoLocalization
.observeOn(MainScheduler.instance)
.subscribe(onSuccess: { result in
print("My result 2: \(result)")
})
.disposed(by: disposeBag)
}
}
Original Answer
The short answer here is no. The best you can do is wrap the state in a class to limit its access. Something like this generic approach:
final class Cache<Key: Hashable, State> {
init(qos: DispatchQoS, source: #escaping (Key) -> Single<State>) {
scheduler = SerialDispatchQueueScheduler(qos: qos)
getState = source
}
func data(for key: Key) -> Single<State> {
lock.lock(); defer { lock.unlock() }
guard let state = cache[key] else {
let state = ReplaySubject<State>.create(bufferSize: 1)
getState(key)
.observeOn(scheduler)
.subscribe(onSuccess: { state.onNext($0) })
.disposed(by: bag)
cache[key] = state
return state.asSingle()
}
return state.asSingle()
}
private var cache: [Key: ReplaySubject<State>] = [:]
private let scheduler: SerialDispatchQueueScheduler
private let lock = NSRecursiveLock()
private let getState: (Key) -> Single<State>
private let bag = DisposeBag()
}
Using the above isolates your state and creates a nice reusable component for other situations where a cache is necessary.
I know it looks more complex than your current code, but gracefully handles the situation where there are multiple requests for the same key before any response is returned. It does this by pushing the same response object to all observers. (The scheduler and lock exist to protect data(for:) which could be called on any thread.)
I have some other suggested improvements for your code as well.
Instead of using flatMapLatest to unwrap an optional, just filter optionals out. But in this case, what's the difference between an empty String and a nil String? Better would be to use the nil coalescing operator and filter out empties.
Since you have the code in an IBAction, I assume that currentPublicIP() only emits one value and completes or errors. Make that clear by having it return a Single. If it does emit multiple values, then you are creating a new chain with every function call and all of them will be emitting values. It's unlikely that this is what you want.
URLSession's json(request:) function emits on a background thread. If you are going to be doing anything with UIKit, you will need to observe on the main thread.
Here is the resulting code with the adjustments mentioned above:
class ViewController: UIViewController {
private let disposeBag = DisposeBag()
private let provider = PublicIPProvider()
private let responses: Cache<String, Any> = Cache(qos: .userInitiated) { ip in
return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")!))
.asSingle()
}
#IBAction func geoButtonTapped(_ sender: UIButton) {
// my IP provider for ipify.org
let fetchedIP: Maybe<String> = provider.currentPublicIP() // `currentPublicIP()` returns a Single
.timeout(3.0, scheduler: MainScheduler.instance)
.map { $0.ip ?? "" }
.filter { !$0.isEmpty }
let geoLocalization: Maybe<Any> = fetchedIP
.flatMap { [weak responses] ip in
return responses?.data(for: ip).asMaybe() ?? Maybe.empty()
}
geoLocalization
.observeOn(MainScheduler.instance) // this is necessary if your subscribe messes with UIKit
.subscribe(onSuccess: { result in
print("My result 2: \(result)")
}, onError: { error in
// don't forget to handle errors.
})
.disposed(by: disposeBag)
}
}
I'm afraid that unless you have some way to cache your network responses (ideally with URLRequest's native caching mechanism), there will always be side-effects.
Here's a suggestion to try to keep them contained though:
Use Rx for your button tap as well, and get rid of the #IBAction. It's not great to have all that code in an #IBAction anyway (unless you did that for demonstration purposes).
That way, you can use a local-scope variable inside the setup function, which will only be captured by your flatMapLatest closure. It makes for some nice, clean code and helps you make sure that your cachedResponse dictionary is not tampered by other functions in your class.
class ViewController: UIViewController {
#IBOutlet var geoButton: UIButton!
let disposeBag = DisposeBag()
let API_KEY = "my_private_API_KEY"
let provider = PublicIPProvider()
override func viewDidLoad() {
super.viewDidLoad()
prepareGeoButton()
}
func prepareGeoButton() {
// ----> Use RxCocoa UIButton.rx.tap instead of #IBAction
let fetchedIP = geoButton.rx.tap
.flatMap { _ in self.provider.currentPublicIP() }
.timeout(3.0, scheduler: MainScheduler.instance)
.flatMapLatest { Observable.from(optional: $0.ip) }
.distinctUntilChanged()
// ----> Use local variable.
// Still has side-effects, but is much cleaner and safer.
var cachedResponse: [String: Any] = [:]
let geoLocalization = fetchedIP
.flatMapLatest { ip -> Observable<Any> in
// check if cache contains response for given IP address
guard let lastResponse = cachedResponse[ip] else {
return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")! ))
.do(onNext: { result in
print("My result 1: \(result)")
cachedResponse[ip] = result
})
}
return Observable.just(lastResponse)
}
geoLocalization
.subscribe(onNext: { result in
print("My result 2: \(result)")
})
.disposed(by: disposeBag)
}
}
I did not want to change the code too much from what you had and add more implementation details into the mix, but if you choose to go this way, please:
a) Use a Driver instead of an observable for your button tap. More on Drivers here.
b) Use [weak self] inside your closures. Don't retain self as this might lead to your ViewController being retained in memory multiple times when you move away from the current screen in the middle of a network request, or some other long-running action.
I have the following MVVM-C + RxSwift code.
The problem is that the TableView is not receiving any signals. When I
debug the results I can see that the API call is returning what it should, the objects array is populated with objects but the tableview does not show any results. Here is the console output:
2018-11-13 16:12:08.107: searchText -> Event next(qwerty)
Search something: qwerty
2018-11-13 16:12:08.324: viewModel.data -> Event next([])
Could it be the tableview itself? Maybe wrong custom cell setup?
ViewController.swift:
tableView = UITableView(frame: self.view.frame)
tableView.delegate = nil
tableView.dataSource = nil
tableView.register(SearchResultCell.self, forCellReuseIdentifier: "SearchResultCell")
viewModel.data
.debug("viewModel.data", trimOutput: false)
.drive(tableView.rx.items(cellIdentifier: "SearchResultCell")) { row, object, cell in
cell.name.text = object.name
cell.something.text = object.something
}
.disposed(by: disposeBag)
ViewModel.swift:
let disposeBag = DisposeBag()
var searchText = BehaviorRelay(value: "something to search for")
lazy var data: Driver<[Object]> = {
return self.searchText.asObservable()
.debug("searchText", trimOutput: false)
.throttle(0.3, scheduler: MainScheduler.instance)
.distinctUntilChanged()
.flatMapLatest(searchSomething)
.asDriver(onErrorJustReturn: [])
}()
func searchSomething(query: String) -> Observable<[Object]> {
print("Search something: \(query)")
let provider = MoyaProvider<APIService>()
var objects = [Object]()
provider.rx.request(.search(query: query)).subscribe { event in
switch event {
case let .success(response):
do {
let responseJSON: NSDictionary = try (response.mapJSON() as? NSDictionary)!
objects = self.parse(json: responseJSON["results"] as Any)
} catch(let error) {
print(error)
}
break
case let .error(error):
print(error)
break
}
}
.disposed(by: disposeBag)
let result: Observable<[Object]> = Observable.from(optional: objects)
return result
}
When using flatMap, you do not want to create nested subscriptions. You will create an Observable that returns the expected result, and flatMap will take care of subscribing to it. In the current state of things, searchSomething will always return an empty array, as Observable.from(optional: objects) will be called before the request has a chance to complete.
Since version 10.0 of Moya, provider will cancel the requests it created when deallocated. Here, it will be deallocated when execution exits searchSomething, hence the network request won't have time to finish. Moving provider's declaration to the view model's level solves this issue.
Here's searchSomething(query: String) -> Observable<[Object]> rewritten.
let provider = MoyaProvider<APIService>()
func searchSomething(query: String) -> Observable<[Object]> {
print("Search something: \(query)")
return provider.rx.request(.search(query: query)).map { (response) -> [Object] in
let responseJSON: NSDictionary = try (response.mapJSON() as? NSDictionary)!
return self.parse(json: responseJSON["results"] as Any)
}
}
Instead of doing the transformation in subscribe, it's done in map, which will be called for every next event, being passed the value associated with the event.
I'm using RxCoCoa and RxSwift for UITableView Biding.
the problem is when Connection lost or other connection errors except for Server Errors(I handled them) my app crash because of binding error that mentioned below. my question is how to handle Connection Errors?
fileprivate func getNextState() {
showFullPageState(State.LOADING)
viewModel.getProductListByID(orderGroup: OrderGroup.SERVICES.rawValue)
.do(onError: {
showStatusError(error: $0)
self.showFullPageState(State.CONTENT)
})
.filter {
$0.products != nil
}
.map {
$0.products!
}
.bind(to: (self.tableView?.rx.items(cellIdentifier: cellIdentifier, cellType: ProductCell.self))!) {
(row, element, cell) in
self.showFullPageState(State.CONTENT)
cell.product = element
}
.disposed(by: bag)
self.tableView?.rx.setDelegate(self).disposed(by: bag)
}
and this is my ViewModel :
func getProductListByID(orderGroup: String, page: String = "1", limit: String = "1000") -> Observable<ProductRes> {
return orderRegApiClient.getProductsById(query: getProductQueryDic(stateKey: getNextStateID(product: nextProduct)
, type: orderGroup, page: page, limit: limit)).map {
try JSONDecoder().decode(ProductRes.self, from: $0.data)
}.asObservable()
}
and I use Moya for my Network layer like This:
func getProductsById(query: [String: String]) -> Single<Response> {
return provider.rx.request(.getProductsById(query))
.filterSuccessfulStatusCodes()
}
You aren't handling errors anywhere. I mean you are acknowledging the error in the do operator but that doesn't actually handle it, that just allows it to pass through to the table view, which can't handle an error.
Look up the catchError series of operators for a solution. Probably .catchErrorJustReturn([]) will be all you need.
In a comment, you said:
... I don't want to return empty Array to my table. I want to show the error to customer and customer can retry service
In that case, you should use .catchError only for the success chain and setup a separate chain for the error as done below.
fileprivate func getNextState() {
showFullPageState(State.LOADING)
let products = viewModel.getProductListByID(orderGroup: OrderGroup.SERVICES.rawValue)
.share()
products
.catchError { _ in Observable.never() }
.filter { $0.products != nil }
.map { $0.products! }
.bind(to: tableView!.rx.items(cellIdentifier: cellIdentifier, cellType: ProductCell.self)) {
(row, element, cell) in
self.showFullPageState(State.CONTENT)
cell.product = element
}
.disposed(by: bag)
products
.subscribe(onError: { error in
showStatusError(error: error)
self.showFullPageState(State.CONTENT)
})
.disposed(by: bag)
self.tableView?.rx.setDelegate(self).disposed(by: bag)
}
The way you have the code setup, the only way for the user to retry the service is to call the function again. If you want to let the user retry in a more declarative manor, you would need to tie the chain to an observable that the user can trigger.