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