В прошлой статье я рассказал о том, как создать простое реактивное приложение для iOS с помощью фреймворка RxSwift. В одном из комментариев прозвучала просьба рассказать про написание unit-тестов для реактивного кода. Я обещал написать об этом после пары других статей про RxSwift. Но меня опередили — то, о чём я собирался писать, прекрасно раскрыто в этой и этой статьях. Я лично хочу его поблагодарить автора за его титанический труд — эти две статьи прочно обосновались в моём избранном и помогают мне в работе.

Ну а мы приступим к написанию тестов!

Добавляем функционал


Прежде чем мы начнём писать тесты, давайте ещё немного помучаем Facebook и напишем функцию создания поста у себя на стене. Для этого нам сначала необходимо добавить разрешение publish_actions для кнопки логина в LoginViewController.viewDidLoad():
loginButton.publishPermissions = ["publish_actions"]

После этого напишем запрос на создание поста в файле APIManager:
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 {
                
            }
        }
    }


Далее создадим новый экран с двумя элементами — UITextView для ввода сообщения и UIButton для отправки сообщения. Описывать эту часть я не буду, всё достаточно стандартно, у кого возникнут затруднения — в конце этой статьи вы можете найти ссылку на Github и посмотреть мою реализацию.

Теперь нам нужно сделать ViewModel для нового экрана:
Реализация 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)
    }
}


Посмотрим блок input, на вход мы подаём feedText (текст нашей новости) и sendButton (событие нажатия на кнопку). В переменных класса у нас validatedText (для проверки того, что текстовое поле не пустое), sendEnabled (для проверки того, что кнопка отправки поста доступна) и sendedIn (для выполнения запроса на отправку поста). Рассмотрим подробней переменную validatedText:
validatedText = input.feedText
            .map { text in
                return text.characters.count > 0
            }
            .shareReplay(1)

Тут всё достаточно просто — берём текст, который мы подали на вход, и проверяем количество символов в нём. Если символы есть — возвращается true, иначе — false. Теперь рассмотрим переменную sendEnabled:
sendEnabled = Observable.combineLatest(
            validatedText,
            indicator.asObservable()
        )   { text, sendingIn in
                text &&
                !sendingIn
            }
            .distinctUntilChanged()
            .shareReplay(1)

Тут тоже всё просто. Получаем последние состояния текста и индикатора загрузки. Если текст не пустой и нет загрузки — возвращается true, иначе false. Осталось разобраться с полем sendedIn:
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)

И тут ничего сложного нет. Берём самое последнее значение из input.feedText и пытаемся выполнить запрос на отправку поста, если словили ошибку — обрабатываем её, выводим пользователю и делаем retry() чтобы не произошло отвязки от события нажатия на кнопку.

Супер, с ViewModel закончили, переходим к контроллеру добавления поста и напишем там следующий код:
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)

Создаем объект класса AddPostViewModel, переменную sendEnabled используем для установки состояния кнопку, а переменную sendedIn используем для отслеживания статуса добавления поста, в случае успеха — выводим пользователю окно об этом и возвращаемся на главный экран. Проверяем что всё работает и наконец-то переходим к тестам.

Концепция unit-тестов при использовании RxSwift


Начнём с концепта записи событий. Давайте зададим массив событий, например вот так:
let booleans = ["f": false, "t": true]

А теперь представим это в формате временной шкалы:
--f-----t---

Сначала мы вызвали событие false во временной шкале, а потом событие true.
Далее на очереди — объект Sheduler. Он позволяет преобразовывать временную шкалу в массив событий, например, вышеописанную временную шкалу он преобразует примерно так:
[shedule onNext(false) @ 0.4s, shedule onNext(true) @ 1.6s]

Помимо этого, Sheduler позволяет записывать события последовательности в таком же формате. У него есть ещё ряд функций, но для тестирования нам пока будет достаточно этих двух.

Теперь перейдём к концепции тестирования. Она заключается в следующем: есть ожидаемые нами события (expected), которые мы задаём изначально, а есть фактические события(recorded), которые на самом деле происходят во ViewModel. Сначала мы записываем ожидаемые события во временную шкалу и с помощью объекта Sheduler преобразовываем их в массив, а потом мы берём тестируемую ViewModel и так же с помощью объекта Sheduler записываем все события в массив.

После чего мы можем сравнить массив ожидаемых события с записанными и сделать вывод, работает ли наша ViewModel так, как мы от неё ожидаем, или нет. Строго говоря, мы можем сравнивать не только события, но и их количество: в исходном коде проекта вы можете найти unit-тест для FeedsViewModel, там сравнивается количество нажатий на ячейку таблицы.

Как показывает моя практика, для тестирования бизнес-логики достаточно покрыть тестами ViewModel, впрочем, это вопрос дискуссионный, и я буду рад его обсудить.

Начинаем тестирование


Первым делом мы будем тестировать AddPostViewModel. Для начала нужно настроить Podfile:
target 'ReactiveAppTests' do
	pod 'RxTests',    '~> 2.0'
	
	pod 'FBSDKLoginKit'
	pod 'RxCocoa',    '~> 2.0'
end

Далее запускаем команду pod install, ждём когда всё выполнится и открываем workspace. Давайте сделаем несколько мокапов для тестирования. Из RxSwift репозитория возьмём мокап для тестирования Wireframe, а также NotImplementedStubs. Мокап для нашего API будет выглядеть так:
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)
    }
}

Напишем небольшое вспомогательное расширение для нашего тестового класса, чтобы было легче создать объект MockAPI:
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"
            }
        )
    }
}


Теперь нам необходимо создать цепочку ожидаемых событий (expected), т.е. мы должны обозначить каким образом будет работать наша программа. Для этого нам нужно создать ряд массивов вида [String: YOUR_TYPE], где String — имя переменной, YOUR_TYPE — тип данных, которые будут возвращаться при вызове переменной. Например, сделаем такой массив для булевых переменных:
let booleans = ["t" : true, "f" : false]

Возможно, пока не очень понятно, зачем всё это нужно, поэтому давайте создадим остальные массивы для тестирования и посмотрим как это работает — всё сразу станет понятно:
// Для событий кнопки
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")
    ]

Теперь создадим цепочки ожидаемых событий:
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!
        )

Итак, давайте разбираться с этим вопросом. Как мы видим, у нас записываются события для 4 переменных — feedTextEvents, buttonTapEvents, expectedValidatedTextEvents и expectedSendFeedEnabledEvents. Самая первая переменная — feedTextEvents, её цепочка событий — scheduler.parseEventsAndTimes(«e----------ft------», values: textValues).first!.. События мы берём из textValues, там всего 2 переменные: «e»: "" — пустая строка, «ft»: «feed — строка со значением „feed“. Теперь взглянем на цепочку событий e----------ft------, сначала мы в цепочке событий вызываем событие e, тем самым говорим что в данный момент пустая строка, а потом в какой-то момент вызываем событие fl, то есть говорим что мы записали в переменную слово „feed“.

Теперь давайте посмотрим на остальные переменные, например на expectedValidatedTextEvents. Когда у нас feedTextEvents пустая строка, то expectedValidatedTextEvents должен быть равен false. Смотрим наш массив boolean и видим, что f — false, поэтому при вызове события e для feedTextEvents нам нужно вызвать событие f для expectedValidatedTextEvents. Как только для переменной feedTextEvents произошло событие ft, то есть текст в текстовом поле стал не пустой, то должно произойти событие t — true для expectedValidatedTextEvents.

То же самое и с expectedSendFeedEnabledEvents — как только поле текста становится не пустым, то кнопка становится enabled и нам нужно вызвать событие t — true для неё. Ну и для переменной buttonTapEvents вызываем событие нажатия на кнопку после того, как кнопка стала доступна.

Это ключевой момент unit-тестирования для RxSwift — понять как создавать цепочки событий и научиться располагать их таким образом, чтобы они правильно вызывались в нужный момент. например, если вы попробуете для переменной expectedValidatedTextEvents вызвать событие t — true раньше, чем произойдёт событие ft для переменной feedTextEvents, то тесты провалятся, потому что в expectedValidatedTextEvents не может произойти событие true при пустой строке. В общем, я советую вам поиграться с цепочками событий, чтобы самим понять что к чему, а теперь давайте допишем код:
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)


Запускаем тесты и испытываем это приятное ощущение от того, что они горят зелёным :-) По такому же принципу я написал unit-тест для FeedsViewModel, его вы можете найти в репо проекта. На этом у меня всё, буду рад замечаниям/предложениям/пожеланиям, спасибо за внимание!
Поделиться с друзьями
-->

Комментарии (5)


  1. SparkLone
    27.07.2016 19:56

    Рад, что мои статьи действительно помогают, а не просто осели в избранном )
    До юнит тестов у меня руки не дошли (планировал 4й в цикле статей), так что Ваша статья — очень нужна.
    К сожалению 3я статья в состоянии «заморозки», она была посвящена архитектурным решениям RxSwift в сравнении с другими (MVC, MVVM без RxSwift). Но в процессе написания пришло понимание, что у меня не хватает времени и опыта практической работы с RxSwift, чтобы написать действительно достойный материал, а писать «чтобы было» не хочется.
    В комментариях к другой статье по RxSwift я попробовал попытать автора на интересовавшие меня вопросы, но обьем текста в вопросах стал приближаться к обьему написанной им статьи и общение как то сошло на нет )

    Так что сделаю еще одну попытку затронуть интересующие темы, если позволите.
    1) Вы, я так понимаю, активно применяете RxSwift в production коде. Это приложения для клиентов или для себя? Если для клиентов — как они смотрят на то, что код будет «нестандартным»?
    2) Не сталкивались ли с проседанием производительности в RxSwift?
    3) Сколько добавляет в весе использование RxSwift в релизе?
    4) RxSwift используется повсеместно во всем приложении или в основном для GUI привязок?

    Просто RxSwift подразумевает под собой реализацию парадигмы функционально-реактивного программирования, которая включает в себя неизменяемость, что довольно тяжело выполнить везде. Достаточно посмотреть на их попытку продемонстрировать работу с TableView в RxSwift (TableViewWithEditingCommands).
    Так что часто приходится в VM использовать изменяемые модели.
    А вообще беда в том, что технология все же еще молода, и нет нормально выкристализованных best practice. Хотя мне очень понравилось работать с RxSwift, так что через время наверное сделаю еще одну попытку.


    1. svyat_reshetnikov
      28.07.2016 10:08
      +1

      Здравствуйте, Александр! Ещё раз спасибо большое за статьи, это действительно огромный труд. Я рад что наши с Вами статьи дополняют друг друга и знакомят русскоязычное сообщество с более совершенными технологиями программирования, коей безусловно является RxSwift. IMHO, конечно :-)

      Теперь отвечаю на Ваши вопросы:
      1) Да, я активно применяю RxSwift в production code. На своей основной работе я главный iOS-разработчик и то, какие технологии и библиотеки будут в iOS-проекте, решаю я. У меня есть личные проекты, которые я делаю в свободное от работы время и в выходные, там также активно используется RxSwift, т.к. в этом случае тоже только я решаю, что будет использоваться в проекте. Что касается клиентов — я давно перестал брать фриланс, если честно, потому что большинство заказчиков не знакомы со спецификой программирования и иногда достаточно тяжело закрывать заказы. Но даже если и взять фриланс, то заказчику, в принципе, всё равно на каком языке я буду писать и какие технологии использовать — обычно они оставляют выбор способа для реализации задачи, им важен сам результат — наличие работающего приложения.
      2) С проседанием производительности не сталкивался, но если верить отладчику, то уровень потребления памяти и загрузка процессора в моих проектах с RxSwift и без него не сильно отличаются — всё достаточно стабильно, хотя параметр Energy Impact для RxSwift совсем немного выше, чем без него. Это я провёл некий «экспресс-анализ» на своёи рабочем проекте с RxSwift и на своём старом личном проекте без RxSwift, хотя, строго говоря, чтобы это замерить, нужно написать 2 приложения с одинаковыми функциями и замерять их параметры производительности. Если брать в целом, то приложения что с RxSwift, что без него, работают хорошо с точки зрения скорости работы и плавности. Может быть, вы предложите другие параметры для замеров?
      3) Честно сказать — не задавался этим вопросом. Тут опять же, нужно написать 2 одинаковых приложения и замерить их вес в релизе. RxSwift сокращает количество кода, но сама библиотека имеет определенный вес, поэтому я думаю, что вряд ли будет какое-то сильное отличие этих параметров. Хотя, свечку не держал, как говорится, ничего не могу утверждать, только предполагаю :-)
      4) Я использую RxSwift для UI-привязок, сетевой части, иногда я пишу Rx делегаты для сторонних библиотек, чтобы можно было реактивно обработать события делегатов. Но тут тоже нужно знать меру — если где-то можно сделать проще, не используя Rx (принцип KISS), то я так и сделаю.

      Да, по поводу молодости технологии — согласен с Вами, но согласитесь — у неё огромные возможности, одно только избегание callback-hell и удобных UI-привязок чего стоит. Я думаю что за этим будущее и с нетерпением жду Вашу третью статью! :-)


      1. SparkLone
        28.07.2016 12:21

        Здравствуйте, Святослав )
        Времени статьи отняли конечно порядочно, но просто захотелось хоть немного вернуть долг сообществу. Ведь сколько было за эти годы прочитано статей. Есть время разбрасывать камни, и время собирать камни )
        Спасибо Вам за ответы.
        По поводу вопросов 2 и 3. Для 3ей статьи я как раз создал приложение имеющее идентичный функционал но с разными архитектурными подходами. Производительность правда там особо не померяешь, не те нагрузки. А вот по поводу веса вполне можно было бы сказать, но нужно делать полноценный билд для App Store, там же собирается «по уму», то что не нужно отрезается и все в таком духе.
        4) Золотые слова. Я как раз слишком увлекся Rx-фикацией, когда пытался сделать clear Rx везде где только можно. Урок был усвоен )

        В последнее время я заинтересовался immutable моделями, судя по WWDC 2016, Apple тоже стала её продвигать. Но тут своих нюансов тоже хватает, как и в Rx. Если в итоге паззл в голове сложится — будет и 3я статья )


        1. svyat_reshetnikov
          28.07.2016 13:05

          Коллега, hard work beats talent, так что сложится паззл в Вашей голове или нет — зависит только от вас. Я уверен в Ваших способностях, и вместе со всем сообществом жду Вашу третью статью! :-)


          1. SparkLone
            28.07.2016 17:10

            Надеюсь оправдать доверие партии )