В прошлой статье я рассказал о том, как создать простое реактивное приложение для iOS с помощью фреймворка RxSwift. В одном из комментариев прозвучала просьба рассказать про написание unit-тестов для реактивного кода. Я обещал написать об этом после пары других статей про RxSwift. Но меня опередили — то, о чём я собирался писать, прекрасно раскрыто в этой и этой статьях. Я лично хочу его поблагодарить автора за его титанический труд — эти две статьи прочно обосновались в моём избранном и помогают мне в работе.
Ну а мы приступим к написанию тестов!
Прежде чем мы начнём писать тесты, давайте ещё немного помучаем Facebook и напишем функцию создания поста у себя на стене. Для этого нам сначала необходимо добавить разрешение publish_actions для кнопки логина в LoginViewController.viewDidLoad():
После этого напишем запрос на создание поста в файле APIManager:
Далее создадим новый экран с двумя элементами — UITextView для ввода сообщения и UIButton для отправки сообщения. Описывать эту часть я не буду, всё достаточно стандартно, у кого возникнут затруднения — в конце этой статьи вы можете найти ссылку на Github и посмотреть мою реализацию.
Теперь нам нужно сделать ViewModel для нового экрана:
Посмотрим блок input, на вход мы подаём feedText (текст нашей новости) и sendButton (событие нажатия на кнопку). В переменных класса у нас validatedText (для проверки того, что текстовое поле не пустое), sendEnabled (для проверки того, что кнопка отправки поста доступна) и sendedIn (для выполнения запроса на отправку поста). Рассмотрим подробней переменную validatedText:
Тут всё достаточно просто — берём текст, который мы подали на вход, и проверяем количество символов в нём. Если символы есть — возвращается true, иначе — false. Теперь рассмотрим переменную sendEnabled:
Тут тоже всё просто. Получаем последние состояния текста и индикатора загрузки. Если текст не пустой и нет загрузки — возвращается true, иначе false. Осталось разобраться с полем sendedIn:
И тут ничего сложного нет. Берём самое последнее значение из input.feedText и пытаемся выполнить запрос на отправку поста, если словили ошибку — обрабатываем её, выводим пользователю и делаем retry() чтобы не произошло отвязки от события нажатия на кнопку.
Супер, с ViewModel закончили, переходим к контроллеру добавления поста и напишем там следующий код:
Создаем объект класса AddPostViewModel, переменную sendEnabled используем для установки состояния кнопку, а переменную sendedIn используем для отслеживания статуса добавления поста, в случае успеха — выводим пользователю окно об этом и возвращаемся на главный экран. Проверяем что всё работает и наконец-то переходим к тестам.
Начнём с концепта записи событий. Давайте зададим массив событий, например вот так:
А теперь представим это в формате временной шкалы:
Сначала мы вызвали событие false во временной шкале, а потом событие true.
Далее на очереди — объект Sheduler. Он позволяет преобразовывать временную шкалу в массив событий, например, вышеописанную временную шкалу он преобразует примерно так:
Помимо этого, Sheduler позволяет записывать события последовательности в таком же формате. У него есть ещё ряд функций, но для тестирования нам пока будет достаточно этих двух.
Теперь перейдём к концепции тестирования. Она заключается в следующем: есть ожидаемые нами события (expected), которые мы задаём изначально, а есть фактические события(recorded), которые на самом деле происходят во ViewModel. Сначала мы записываем ожидаемые события во временную шкалу и с помощью объекта Sheduler преобразовываем их в массив, а потом мы берём тестируемую ViewModel и так же с помощью объекта Sheduler записываем все события в массив.
После чего мы можем сравнить массив ожидаемых события с записанными и сделать вывод, работает ли наша ViewModel так, как мы от неё ожидаем, или нет. Строго говоря, мы можем сравнивать не только события, но и их количество: в исходном коде проекта вы можете найти unit-тест для FeedsViewModel, там сравнивается количество нажатий на ячейку таблицы.
Как показывает моя практика, для тестирования бизнес-логики достаточно покрыть тестами ViewModel, впрочем, это вопрос дискуссионный, и я буду рад его обсудить.
Первым делом мы будем тестировать AddPostViewModel. Для начала нужно настроить Podfile:
Далее запускаем команду pod install, ждём когда всё выполнится и открываем workspace. Давайте сделаем несколько мокапов для тестирования. Из RxSwift репозитория возьмём мокап для тестирования Wireframe, а также NotImplementedStubs. Мокап для нашего API будет выглядеть так:
Напишем небольшое вспомогательное расширение для нашего тестового класса, чтобы было легче создать объект MockAPI:
Теперь нам необходимо создать цепочку ожидаемых событий (expected), т.е. мы должны обозначить каким образом будет работать наша программа. Для этого нам нужно создать ряд массивов вида [String: YOUR_TYPE], где String — имя переменной, YOUR_TYPE — тип данных, которые будут возвращаться при вызове переменной. Например, сделаем такой массив для булевых переменных:
Возможно, пока не очень понятно, зачем всё это нужно, поэтому давайте создадим остальные массивы для тестирования и посмотрим как это работает — всё сразу станет понятно:
Теперь создадим цепочки ожидаемых событий:
Итак, давайте разбираться с этим вопросом. Как мы видим, у нас записываются события для 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 при пустой строке. В общем, я советую вам поиграться с цепочками событий, чтобы самим понять что к чему, а теперь давайте допишем код:
Запускаем тесты и испытываем это приятное ощущение от того, что они горят зелёным :-) По такому же принципу я написал unit-тест для FeedsViewModel, его вы можете найти в репо проекта. На этом у меня всё, буду рад замечаниям/предложениям/пожеланиям, спасибо за внимание!
Ну а мы приступим к написанию тестов!
Добавляем функционал
Прежде чем мы начнём писать тесты, давайте ещё немного помучаем 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, его вы можете найти в репо проекта. На этом у меня всё, буду рад замечаниям/предложениям/пожеланиям, спасибо за внимание!
