Testing reactivity - how to write unit tests for RxSwift
- Tutorial
Well, we will start writing tests!
Add functionality
Before we start writing tests, let's torment Facebook a little more and write the function of creating a post on our wall. To do this, we first need to add publish_actions permission for the login button in LoginViewController.viewDidLoad ():
loginButton.publishPermissions = ["publish_actions"]
After that, we write a request to create a post in the APIManager file:
func addFeed(feedMessage: String) -> Observable {
return Observable.create { observer in
let parameters = ["message": feedMessage]
let addFeedRequest = FBSDKGraphRequest.init(graphPath: "me/feed", parameters: parameters, HTTPMethod: "POST")
addFeedRequest.startWithCompletionHandler { (connection, result, error) -> Void in
if error != nil {
observer.on(.Error(error!))
} else {
observer.on(.Next(result))
observer.on(.Completed)
}
}
return AnonymousDisposable {
}
}
}
Next, create a new screen with two elements - UITextView for entering a message and UIButton for sending a message. I will not describe this part, everything is fairly standard, who will have difficulties - at the end of this article you can find a link to Github and see my implementation.
Now we need to make a ViewModel for the new screen:
class AddPostViewModel {
let validatedText: Observable
let sendEnabled: Observable
// If some process in progress
let indicator: Observable
// Has feed send in
let sendedIn: Observable
init(input: (
feedText: Observable,
sendButton: Observable
),
dependency: (
API: APIManager,
wireframe: Wireframe
)
) {
let API = dependency.API
let wireframe = dependency.wireframe
let indicator = ViewIndicator()
self.indicator = indicator.asObservable()
validatedText = input.feedText
.map { text in
return text.characters.count > 0
}
.shareReplay(1)
sendedIn = input.sendButton.withLatestFrom(input.feedText)
.flatMap { feedText -> Observable in
return API.addFeed(feedText).trackView(indicator)
}
.catchError { error in
return wireframe.promptFor((error as NSError).localizedDescription, cancelAction: "OK", actions: [])
.map { _ in
return error
}
.flatMap { error -> Observable in
return Observable.error(error)
}
}
.retry()
.shareReplay(1)
sendEnabled = Observable.combineLatest(
validatedText,
indicator.asObservable()
) { text, sendingIn in
text &&
!sendingIn
}
.distinctUntilChanged()
.shareReplay(1)
}
}
Let's look at the input block, we feedText (the text of our news) and sendButton (the event of pressing a button) to the input. In the class variables, we have validatedText (to check that the text field is not empty), sendEnabled (to check that the post button is available) and sendedIn (to fulfill the request to send the post). Consider the validatedText variable in more detail:
validatedText = input.feedText
.map { text in
return text.characters.count > 0
}
.shareReplay(1)
Everything is quite simple here - we take the text that we submitted to the input and check the number of characters in it. If there are characters, true is returned; otherwise, false. Now consider the sendEnabled variable:
sendEnabled = Observable.combineLatest(
validatedText,
indicator.asObservable()
) { text, sendingIn in
text &&
!sendingIn
}
.distinctUntilChanged()
.shareReplay(1)
Here, too, everything is simple. We get the latest status of the text and the download indicator. If the text is not empty and there is no loading, returns true, otherwise false. It remains to deal with the sendedIn field:
sendedIn = input.sendButton.withLatestFrom(input.feedText)
.flatMap { feedText -> Observable in
return API.addFeed(feedText).trackView(indicator)
}
.catchError { error in
return wireframe.promptFor((error as NSError).localizedDescription, cancelAction: "OK", actions: [])
.map { _ in
return error
}
.flatMap { error -> Observable in
return Observable.error(error)
}
}
.retry()
.shareReplay(1)
And there is nothing complicated. We take the latest value from input.feedText and try to fulfill the request to send the post, if we catch an error - we process it, display it to the user and do retry () so that there is no decoupling from the click event.
Super, we’ve finished with ViewModel, go to the post adding controller and write the following code there:
let viewModel = AddPostViewModel(
input: (
feedText: feedTextView.rx_text.asObservable(),
sendButton: sendFeed.rx_tap.asObservable()
),
dependency: (
API: APIManager.sharedAPI,
wireframe: DefaultWireframe.sharedInstance
)
)
let progress = MBProgressHUD()
progress.mode = MBProgressHUDMode.Indeterminate
progress.labelText = "Загрузка данных..."
progress.dimBackground = true
viewModel.indicator.asObservable()
.bindTo(progress.rx_mbprogresshud_animating)
.addDisposableTo(disposeBag)
viewModel.sendEnabled
.subscribeNext { [weak self] valid in
self!.sendFeed.enabled = valid
self!.sendFeed.alpha = valid ? 1.0 : 0.5
}
.addDisposableTo(self.disposeBag)
viewModel.sendedIn
.flatMap { _ -> Observable in
return DefaultWireframe.sharedInstance.promptFor("Ваша запись успешно опубликована!", cancelAction: "OK", actions: [])
.flatMap { action -> Observable in
return Observable.just(action)
}
}
.subscribeNext { action in
self.navigationController?.popToRootViewControllerAnimated(true)
}
.addDisposableTo(self.disposeBag)
We create an object of the AddPostViewModel class, use the button sendEnabled to set the state, and use the sendedIn variable to track the status of adding a post, if successful, display the user about it and return to the main screen. We check that everything works and finally move on to the tests.
The concept of unit tests when using RxSwift
Let's start with the concept of recording events. Let's define an array of events, for example like this:
let booleans = ["f": false, "t": true]
Now imagine this in a timeline format:
--f-----t---
First, we raised the false event in the timeline, and then the true event.
Next in line is the Sheduler object. It allows you to convert the timeline into an array of events, for example, it converts the above timeline like this:
[shedule onNext(false) @ 0.4s, shedule onNext(true) @ 1.6s]
In addition, Sheduler allows you to record sequence events in the same format. It has a number of functions, but for the time being, these two will be enough for us.
Now let's move on to the concept of testing. It consists in the following: there are expected events (expected) that we set initially, and there are actual events (recorded) that actually occur in the ViewModel. First, we record the expected events in the timeline and use the Sheduler object to convert them into an array, and then we take the tested ViewModel and write all the events to the array using the Sheduler object.
After which we can compare the array of expected events with the recorded ones and conclude whether our ViewModel works as we expect from it or not. Strictly speaking, we can compare not only events, but also their number: in the source code of the project you can find the unit test for FeedsViewModel, there the number of clicks on the table cell is compared.
As my practice shows, for testing business logic, it is enough to cover ViewModel with tests, however, this is a debatable issue, and I will be happy to discuss it.
Start testing
First, we will test the AddPostViewModel. First you need to configure the Podfile:
target 'ReactiveAppTests' do
pod 'RxTests', '~> 2.0'
pod 'FBSDKLoginKit'
pod 'RxCocoa', '~> 2.0'
end
Next, run the pod install command , wait for it to complete, and open the workspace. Let's make some mockups for testing. From the RxSwift repository, take the mockup for testing Wireframe , as well as NotImplementedStubs . The mocap for our API will look like this:
class MockAPI : API {
let _getFeeds: () -> Observable
let _getFeedInfo: (String) -> Observable
let _addFeed: (String) -> Observable
init(
getFeeds: () -> Observable = notImplemented(),
getFeedInfo: (String) -> Observable = notImplemented(),
addFeed: (String) -> Observable = notImplemented()
) {
_getFeeds = getFeeds
_getFeedInfo = getFeedInfo
_addFeed = addFeed
}
func getFeeds() -> Observable {
return _getFeeds()
}
func getFeedInfo(feedId: String) -> Observable {
return _getFeedInfo(feedId)
}
func addFeed(feedMessage: String) -> Observable {
return _addFeed(feedMessage)
}
}
We’ll write a small helper extension for our test class to make it easier to create a MockAPI object:
extension ReactiveAppTests {
func mockAPI(scheduler: TestScheduler) -> API {
return MockAPI(
getFeeds: scheduler.mock(feeds, errors: errors) { _ -> String in
return "--fs"
},
getFeedInfo: scheduler.mock(feedInfo, errors: errors) { _ -> String in
return "--fi"
},
addFeed: scheduler.mock(textValues, errors: errors) { _ -> String in
return "--ft"
}
)
}
}
Now we need to create a chain of expected events (expected), i.e. we must indicate how our program will work. To do this, we need to create a series of arrays of the form [String: YOUR_TYPE], where String is the name of the variable, YOUR_TYPE is the type of data that will be returned when the variable is called. For example, we make an array for boolean variables:
let booleans = ["t" : true, "f" : false]
Perhaps it’s not yet clear why all this is needed, so let's create the rest of the arrays for testing and see how it works - everything will immediately become clear:
// Для событий кнопки
let events = ["x" : ()]
// Для событий ошибок
let errors = [
"#1" : NSError(domain: "Some unknown error maybe", code: -1, userInfo: nil),
]
// Для событий ввода в текстовое поле
let textValues = [
"ft" : "feed",
"e" : ""
]
// Для новостей
// Да, я знаю что можно сделать элегантней, но мне лень возиться с конвертацией типов :-)
let feeds = [
"fs" : GetFeedsResponse()
]
let feedInfo = [
"fi" : GetFeedInfoResponse()
]
let feedArray = [
"fa" : [Feed]()
]
let feed = [
"f" : Feed(createdTime: "1", feedId: "1")
]
Now create the chain of expected events:
let (
feedTextEvents,
buttonTapEvents,
expectedValidatedTextEvents,
expectedSendFeedEnabledEvents
) = (
scheduler.parseEventsAndTimes("e----------ft------", values: textValues).first!,
scheduler.parseEventsAndTimes("-----------------x-", values: events).first!,
scheduler.parseEventsAndTimes("f----------t-------", values: booleans).first!,
scheduler.parseEventsAndTimes("f----------t-------", values: booleans).first!
)
So, let's deal with this issue. As we can see, we record events for 4 variables - feedTextEvents, buttonTapEvents, expectedValidatedTextEvents and expectedSendFeedEnabledEvents. The very first variable is feedTextEvents, its event chain is scheduler.parseEventsAndTimes ("e ---------- ft ------", values: textValues) .first! .. We take events from textValues, there are only 2 variables: "e": "" - an empty string, "ft": "feed - a string with the value" feed ". Now let’s take a look at the chain of events e ---------- ft ------ , we first call the event e in the chain of events, thereby saying that at the moment there is an empty string, and then at some point we call the fl event, that is, we say that we wrote the word “feed” into the variable.
Now let's look at the rest of the variables, for example, expectedValidatedTextEvents. When we have feedTextEvents an empty string, then expectedValidatedTextEvents should be false. We look at our boolean array and see that f is false, so when we call the e event for feedTextEvents, we need to call the f event for expectedValidatedTextEvents. As soon as the ft event occurred for the feedTextEvents variable, that is, the text in the text field is not empty, the t event should occur - true for expectedValidatedTextEvents.
The same is with expectedSendFeedEnabledEvents - as soon as the text field is not empty, the button becomes enabled and we need to raise the event t - true for it. Well, for the buttonTapEvents variable, we raise the button click event after the button has become available.
This is the key point of unit testing for RxSwift - to understand how to create chains of events and learn how to arrange them so that they are correctly called at the right time. for example, if you try for the expectedValidatedTextEvents variable to raise the t - true event before the ft event for the feedTextEvents variable occurs, then the tests fail because the expectedValidatedTextEvents event cannot happen with the true line empty. In general, I advise you to play around with the chains of events to understand for yourself what’s what, and now let's add the code:
let wireframe = MockWireframe()
let viewModel = AddPostViewModel(
input: (
feedText: scheduler.createHotObservable(feedTextEvents).asObservable(),
sendButton: scheduler.createHotObservable(buttonTapEvents).asObservable()
),
dependency: (
API: mock,
wireframe: wireframe
)
)
// run experiment
let recordedSendFeedEnabled = scheduler.record(viewModel.sendEnabled)
let recordedValidatedTextEvents = scheduler.record(viewModel.validatedText)
scheduler.start()
// validate
XCTAssertEqual(recordedValidatedTextEvents.events, expectedValidatedTextEvents)
XCTAssertEqual(recordedSendFeedEnabled.events, expectedSendFeedEnabledEvents)
We run the tests and feel this pleasant feeling that they are green :-) Using the same principle, I wrote a unit test for FeedsViewModel, which you can find in the project repo . That's all for me, I will be glad to comments / suggestions / wishes, thank you for your attention!