Тестируем реактивность — как писать unit-тесты для RxSwift

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

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

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


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

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

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

Теперь нам нужно сделать ViewModel для нового экрана:

Реализация AddPostViewModel
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)
    }
}


Посмотрим блок 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 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)

И тут ничего сложного нет. Берём самое последнее значение из 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 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)

Создаем объект класса 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
    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)
    }
}

Напишем небольшое вспомогательное расширение для нашего тестового класса, чтобы было легче создать объект 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, его вы можете найти в репо проекта. На этом у меня всё, буду рад замечаниям/предложениям/пожеланиям, спасибо за внимание!

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

© Habrahabr.ru