📜 ⬆️ ⬇️

Testing reactivity - how to write unit tests for RxSwift

In the last article, I talked about how to create a simple reactive application for iOS using the RxSwift framework. In one of the comments there was a request to tell about writing unit tests for a reactive code. I promised to write about it after a couple of other articles about RxSwift. But they were ahead of me - what I was going to write about was perfectly disclosed in this and this article. I personally want to thank the author for his titanic work - these two articles are firmly established in my favorites and help me in my work.

Well, we will start writing tests!

Adding functionality


Before we start writing tests, let's torture Facebook a bit more and write the post creation function on our wall. To do this, we first need to add the publish_actions permission for the login button in LoginViewController.viewDidLoad ():
loginButton.publishPermissions = ["publish_actions"] 

After that, we will write a request to create a post in the APIManager file:
 func addFeed(feedMessage: String) -> Observable<Any> { 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 - a UITextView for entering a message and a UIButton for sending a message. I will not describe this part, everything is fairly standard, who has difficulties - at the end of this article you can find a link to Github and see my implementation.
')
Now we need to make the ViewModel for the new screen:
Implementing AddPostViewModel
 class AddPostViewModel { let validatedText: Observable<Bool> let sendEnabled: Observable<Bool> // If some process in progress let indicator: Observable<Bool> // Has feed send in let sendedIn: Observable<Any> init(input: ( feedText: Observable<String>, sendButton: Observable<Void> ), 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<Any> 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<Any> 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, at the input we submit feedText (the text of our news) and sendButton (event of pressing the button). In the class variables, we have validatedText (to check that the text field is not empty), sendEnabled (to check that the post sending button is available) and sendedIn (to fulfill the request to send the post). Let's take a closer look at the validatedText variable:
 validatedText = input.feedText .map { text in return text.characters.count > 0 } .shareReplay(1) 

It's all quite simple - we take the text that we submitted to the input, and check the number of characters in it. If there are characters, then 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 text status and loading indicator. If the text is not empty and there is no loading, then true is returned, otherwise false. It remains to deal with the sendedIn field:
 sendedIn = input.sendButton.withLatestFrom(input.feedText) .flatMap { feedText -> Observable<Any> 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<Any> in return Observable.error(error) } } .retry() .shareReplay(1) 

And there is nothing complicated. We take the most recent value from input.feedText and try to fulfill the request to send the post, if we catch an error - we process it, output it to the user and retry () so that there is no disconnection from the button press event.

Super, with ViewModel finished, go to the controller to add the post 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<String> in return DefaultWireframe.sharedInstance.promptFor("   !", cancelAction: "OK", actions: []) .flatMap { action -> Observable<Any> in return Observable.just(action) } } .subscribeNext { action in self.navigationController?.popToRootViewControllerAnimated(true) } .addDisposableTo(self.disposeBag) 

Create an object of the AddPostViewModel class, use the sendEnabled variable to set the state button, and use the sendedIn variable to track the status of adding a post, if successful, display a window to the user about it and return to the main screen. We check that everything works and finally go to the tests.

The concept of unit tests when using RxSwift


Let's start with the concept of recording events. Let's set an array of events, like this:
 let booleans = ["f": false, "t": true] 

Now imagine this in a timeline format:
 --f-----t--- 

We first called the false event in the timeline, and then the true event.
Next in line is the Sheduler object. It allows you to convert a timeline into an array of events, for example, it converts the above described 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. He has a number of other functions, but for testing, these two will suffice for now.

We now turn to the concept of testing. It consists in the following: there are events expected by us (expected), which we set initially, and there are actual events (recorded) that actually occur in the ViewModel. First, we write the expected events to the timeline and use the Sheduler object to convert them into an array, and then we take the ViewModel under test and also use the Sheduler object to write all the events into the array.

After that, we can compare the array of expected events with those recorded and conclude whether our ViewModel works as we expect from it or not. Strictly speaking, we can compare not only the events, but also their number: in the source code of the project you can find the unit test for FeedsViewModel, it compares the number of clicks on the table cell.

As my practice shows, to test business logic is enough to cover the ViewModel tests, however, this is a debatable question, and I will be happy to discuss it.

We start testing


First of all we will test 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 everything to complete and open the workspace. Let's do some mockups for testing. From the RxSwift repository, we take a mocap for testing Wireframe , as well as NotImplementedStubs . Mocap for our API will look like this:
 class MockAPI : API { let _getFeeds: () -> Observable<GetFeedsResponse> let _getFeedInfo: (String) -> Observable<GetFeedInfoResponse> let _addFeed: (String) -> Observable<AnyObject> init( getFeeds: () -> Observable<GetFeedsResponse> = notImplemented(), getFeedInfo: (String) -> Observable<GetFeedInfoResponse> = notImplemented(), addFeed: (String) -> Observable<Any> = notImplemented() ) { _getFeeds = getFeeds _getFeedInfo = getFeedInfo _addFeed = addFeed } func getFeeds() -> Observable<GetFeedsResponse> { return _getFeeds() } func getFeedInfo(feedId: String) -> Observable<GetFeedInfoResponse> { return _getFeedInfo(feedId) } func addFeed(feedMessage: String) -> Observable<AnyObject> { return _addFeed(feedMessage) } } 

Let's 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 designate 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 variable name, YOUR_TYPE is the data type that will be returned when the variable is called. For example, let's make such an array for Boolean variables:
 let booleans = ["t" : true, "f" : false] 

Perhaps, it’s not very clear why all this is needed, so let's create the remaining 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 a 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 have events for 4 variables - feedTextEvents, buttonTapEvents, expectedValidatedTextEvents and expectedSendFeedEnabledEvents. The very first variable is feedTextEvents, its chain of events is scheduler.parseEventsAndTimes ("e ---------- ft ------", values: textValues) .first! .. We take events from textValues, there are only 2 variables: “e”: "" is an empty string, "ft": “feed is a string with the value“ feed ”. Now let's take a look at the chain of events e ---------- ft ------ , at first we in the chain of events trigger the event e, thereby saying that at the moment there is an empty string, and then at some point cause the event fl, that is, we say that we have written the word “feed” into a variable.

Now let's look at the other variables, for example, on the expectedValidatedTextEvents. When we have a feedTextEvents empty string, then the expectedValidatedTextEvents should be false. We look at our boolean array and see that f is false, so when we call event e for feedTextEvents, we need to call event f for the expectedValidatedTextEvents. As soon as the ft event occurred for the feedTextEvents variable, that is, the text in the text field was not empty, then the t event should occur - true for expectedValidatedTextEvents.

It is the same with expectedSendFeedEnabledEvents - as soon as the text field becomes non-empty, the button becomes enabled and we need to trigger the t event - true for it. Well, for the buttonTapEvents variable, we trigger a 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 position them so that they are called at the right moment. for example, if you try for the variable expectedValidatedTextEvents to raise the t event - true before the ft event for the variable feedTextEvents occurs, the tests will fail because the expectedValidatedTextEvents event cannot occur with an empty string. In general, I advise you to play around with chains of events in order to understand 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 tests and experience this pleasant sensation from the fact that they are green :-) By the same principle, I wrote a unit test for FeedsViewModel, you can find it in the repo project . I have everything on it, I will be happy with comments / suggestions / wishes, thank you for your attention!

Source: https://habr.com/ru/post/304866/


All Articles