— Слава TDD!
— Юнит-тестам слава!
В этой статье мы разберемся с принципами применения MVP+TDD в разработке iOS приложений. Разбираться будем на примере создания небольшой обучалки для пользователя, которая показывается при первом запуске.
Требования от бизнеса
Итак, ваш заказчик хочет, чтоб в его приложение добавили обучалку, которая покажется пользователю один раз при первом запуске. Обучалка состоит из нескольких изображений, которые должны быть показаны в определенной последовательности. Переключаться изображения должны по нажатию на кнопку "Продолжить". Также при показе последнего изображения — на кнопке нужно написать "Старт" (как бы намекая пользователю, что приложение будет сейчас запущено).
Шаг 1. Продумываем логику.
Итак у нас есть определенная последовательность изображений. Это значит, что у нас должна быть очередь (queue) этих изображений. Из этой очереди мы будем извлекать по одному изображению и показывать на экране. И так до тех пор, пока изображения в очереди не закончатся.
Когда в очереди останется последнее изображение — нужно поменять надпись на кнопке: вместо "Продолжить" — "Старт".
- Когда пользователь увидит последнее изображение и нажмет кнопку "Старт" нам нужно показать первый экран приложения и зафиксировать, что пользователь уже смотрел обучалку (не показывать ее при последующих запусках).
Шаг 2. MVP
Для начала создадим наше приложение (новый проект в Xcode) и назовем его OnBoardingTest.
Сразу включите в проект и юнит-тесты (TDD ведь).
- Добавим в наш проект модуль для нашей обучалки. Назовем его OnBoarding.
Освежить знания об MVP можно перечитав мой пост Через MVP к VIPER. Часть первая: MVP.
Обычно, модуль в MVP-архитектуре представляет из себя два класса (UIViewController и Presenter), которые общаются между собой с помощью протоколов. Но, если честно, у меня не было случая, когда я использовал для одного и того же вью разные презентеры. Поэтому в одном из протоколов (viewOutput/presenterInput) просто нет необходимости.
Наш модуль состоит из следующих файлов:
Для каждого экрана я использую отдельный сториборд, детальнее можно прочитать в статье-переводе Xcode: наверное, лучший способ работы со сторибордами
Шаг 3. V — означает View.
Поскольку мы не тестируем слой View — можем сразу заняться его реализацией. View — это наш сториборд и UIViewController.
UI на сториборде у нас очень простой: ImageView на весь экран и кнопка:
Во вью-контроллер добавим необходимые IBOutlet-ы и IBAction. Наш класс выглядит пока что так:
class OnBoardingViewController: ViewController {
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var continueButton: UIButton!
@IBAction func continueAction(_ sender: UIButton) {
}
}
Также во вью-контроллер нужно добавить ссылку на презентер и сборку модуля. Добавляем свойство:
var presenter: OnBoardingPresenter!
Сборку будем выполнять в awakeFromNib
:
override func awakeFromNib() {
super.awakeFromNib()
self.presenter = OnBoardingPresenter()
}
Теперь нам нужно добавить методы в наш протокол и реализовать их во вью-контроллере. Что должен уметь наш вью-контроллер: во-первых он должен уметь менять изображение в UIImageView
, во-вторых: менять надпись на кнопке, в-третьих: запускать приложение (когда все изображения показаны). Добавим в протокол следующие методы:
func show(image: UIImage)
func updateButton(title: String)
func startApplication()
Для первого метода есть важный нюанс: мы пытаемся передавать параметром UIImage
, что немного противоречит принципу разделения UI-слоя от слоя с логикой. Если так и оставить, то получится, что нам нужно в презентере создать объект типа UIImage
и передать его в вызов метода show(image:)
, а презентер не должен работать с UIKit
. Поэтому мы немного исправим параметр в первом методе:
func showImage(imageName: String)
Будем передавать идентификатор изображения, а вью-контроллер сам будет создавать UIImage
с таким идентификатором.
Теперь реализуем этот протокол в нашем вью-контроллере:
// MARK: - OnBoardingViewProtocol
extension OnBoardingViewController: OnBoardingViewProtocol {
func showImage(imageName: String) {
self.imageView.image = UIImage(named: imageName)
}
func updateButton(title: String) {
self.continueButton.setTitle(title, for: .normal)
}
func startApplication() {
// some logic for application start
print("Application is started")
}
}
Как видите, реализация очень проста: создать UIImage
и показать его в нашем self.imageView
и поставить надпись на кнопку. Логику старта приложения мы реализовывать не будем, поскольку в рамках этой статьи — это не критично. Там может быть вызов какого-либо вью-контроллера (через self.present(...)
), либо более сложная логика, которая должна быть вынесена в какой-нибудь роутер. Выведем в консоль сообщение, чтоб увидеть, что старт приложения вызывается.
Реализация нашей вью готова.
Шаг 4. M — для Model
Моделью в нашем случае будет:
- Очередь из названий изображений.
- Сервис, который зафиксирует, что обучалка показана полностью, и в следующий раз ее показывать не нужно.
Очередь из названий реализуется просто. Поскольку это названия (String
) и они должны быть упорядочены, значит очередь будет представлять из себя массив строк: Array<String>
.
Добавим в наш модуль сервис OnBoardingImageManager
. В нем у нас будет один метод getImageQueue() -> [String]
:
class OnBoardingImageManager {
func getImageQueue() -> [String] {
// some logic may be here
return ["OnBoarding1",
"OnBoarding2",
"OnBoarding3",
"OnBoarding4",
"OnBoarding5"]
}
}
В нашем случае мы не будем углубляться в логику формирования очереди изображений и вернем массив захардкоженных строк. На самом же деле, здесь, как минимум, должна быть логика получения разных названий изображений для разных диагоналей экрана (не показывать же изображение для 4.0" экрана на экране с диагональю 5.5").
Сервис, который запомнит, что пользователь уже смотрел обучалку назовем OnBoardingLocalManager
. В нем у нас также будет один метод setFlagOnBoardingCompleted()
:
class OnBoardingLocalManager {
func setFlagOnBoardingCompleted() {
// some logic for saving onBoardingCompleted flag
// maybe use UserDefaults.standard
print("OnBoarding completed successfully")
}
}
Как видите его логику мы тоже реализовывать не будем. Тут могут быть использованы UserDefaults
или CoreData
(в особых случаях), либо еще какая-то логика (возможно есть требование передать на сервер инфу о том, что пользователь уже посмотрел обучалку). Поэтому мы просто выведем в консоль сообщение, чтоб увидеть, что этот метод вызывается.
Мы практически закончили с MVP. Осталось разобраться с презентером и перейдем к TDD.
Шаг 5. P — Presenter.
На текущий момент наш презентер гол как сокол:
class OnBoardingPresenter {
}
Давайте разберемся какие данные он может хранить и что он должен делать:
- Он должен хранить очередь из названий изображений, причем эта очередь будет изменяться (каждый раз из нее будет извлечено следующее название, пока очередь не станет пустой).
- Он должен выполнять извлечение следующего названия из очереди и передавать его в нашу вью.
- Он должен определить, что очередь закончилась и сообщить вью об этом, а также зафиксировать, что пользователь посмотрел обучалку полностью.
Добавим в наш презентер очередь из названий картинок:
var imageQueue: [String]
Компилятор сразу же попросит добавить инициализатор, поскольку свойство imageQueue
не опциональное и должно быть заполнено значением при инициализации. Добавим инициализатор:
init(imageQueue: [String] = OnBoardingImageManager().getImageQueue()) {
self.imageQueue = imageQueue
}
Как видите в инициализатор мы передаем нашу очередь параметром. Причем у него есть значение по умолчанию. Почему нельзя сделать инициализатор без параметров и присваивать значение self.imageQueue
внутри init
-а? Потому что во время тестов мы будем подставлять свои очереди.
Теперь мы добавим в презентер метод showNextImage(...)
:
func showNextImage(view: OnBoardingViewProtocol,
localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {
}
В нем пока нет реализации, но есть два параметра, давайте разберемся с ними.
view
— это наша вью, которой презентер после выполнения логики отдаст команду (команды) сделать те или иные действия предусмотренные протоколом OnBoardingViewProtocol
.
localManager
— наш сервис, который запоминает, что пользователь посмотрел обучалку. Мы добавили этот параметр, для того, чтоб в тестах подставить вместо него МОК. И дали ему значение по умолчанию, чтоб не указывать каждый раз этот объект в вызовах метода из вью-контроллера.
Вернемся на минуту к нашей вью (OnBoardingViewController
) и добавим в нее обращения к презентеру:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.presenter.showNextImage(view: self)
}
@IBAction func continueAction(_ sender: UIButton) {
self.presenter.showNextImage(view: self)
}
Мы добавляем вызов во viewWillAppear
(экран обучения должен быть показан с первым же изображением из очереди) и в обработчик нажатия на кнопку.
Мы вплотную подошли к TDD.
Шаг 6. МОКи
Для того, чтоб написать тесты для нашего презентера, нужно сделать МОКи объектов с которыми он взаимодействует. А именно: для view
, которая имеет тип OnBoardingViewProtocol
, и для localManager
, тип которого — OnBoardingLocalManager
. Для создания МОКов в Swift я пользуюсь библиотекой Cuckoo. Как с ней работать я писал в статье "Мочим" объекты с помощью Cuckoo. Добавляем библиотеку в проект через CocoaPods, делаем pod install
в папке с проектом, и в скрипте для генерации МОКов указываем файлы OnBoardingViewProtocol
("${INPUT_DIR}/OnBoardingViewProtocol.swift"
) и OnBoardingLocalManager
("${INPUT_DIR}/OnBoardingLocalManager.swift"
).
Шаг 7. TDD. RED
Мы готовы к TDD. Теперь нам нужно написать юнит-тесты, которые опишут необходимую логику, но будут "фэйлиться".
В таргете OnBoardingTestTests
удаляем лишний файл OnBoardingTestTests.swift
(Xcode создал его по умолчанию) и создаем файл OnBoardingPresenterTest
— наследник от XCTestCase
:
import XCTest
class OnBoardingPresenterTests: XCTestCase {
}
Структура юнит-теста следующая:
instance
— объект класса, который мы тестируем- объявления МОКов, которые нужны для тестирования
- корректные/некорректные входящие данные и ожидаемые результаты
Объявляем сущности, которые нам понадобятся в тестах:
import XCTest
import Cuckoo
@testable import OnBoardingTest
class OnBoardingPresenterTests: XCTestCase {
var instance: OnBoardingPresenter!
var view: MockOnBoardingViewProtocol!
var localManager: MockOnBoardingLocalManager!
let correctNexImageName = "correctNextImageName"
var fullImageQueue: [String]!
let lastImageQueue: [String] = ["something"]
let emptyImageQueue: [String] = []
override func setUp() {
super.setUp()
self.fullImageQueue = [correctNexImageName, "something", "something else"]
}
}
Пройдемся по ним по очереди:
instance
у которого типOnBoardingPresenter
— наш презентер, который мы и будем тестировать.view
типаMockOnBoardingViewProtocol
иlocalManager
типаMockOnBoardingLocalManager
— наши МОКи.- разные варианты очередей: очередь из нескольких названий, очередь из одного названия и пустая очередь.
Добавим в метод setUp()
инициализацию МОКов и stub-ы для них:
self.view = MockOnBoardingViewProtocol()
stub(self.view) {
when($0.showImage(imageName: anyString())).thenDoNothing()
when($0.updateButton(title: anyString())).thenDoNothing()
when($0.startApplication()).thenDoNothing()
}
self.localManager = MockOnBoardingLocalManager()
stub(self.localManager) {
when($0.setFlagOnBoardingCompleted()).thenDoNothing()
}
Мы готовы написать непосредственно тесты.
Первый тест будет тестировать, что изображение из очереди извлекается правильно:
func testNextImageExtractsCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
self.instance.showNextImage(view: self.view)
verify(self.view).showImage(imageName: self.correctNexImageName)
}
В этом тесте мы инициализируем наш презентер с очередью fullImageQueue
, вызываем метод презентера showNextImage(...)
и проверяем, что у нашей view
вызвался метод обновления изображения, причем с правильным именем изображения.
Второй тест будет проверять, что мы извлекли элемент из нашей очереди (количество элементов в очереди уменьшилось на единицу):
func testImageQueueReducesCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
self.instance.showNextImage(view: self.view)
XCTAssertEqual(self.instance.imageQueue.count, self.fullImageQueue.count - 1, "image queue should be reduced by one")
}
В третьем тесте мы проверим, что, в случае, когда в очереди несколько названий картинок, устанавливается правильная надпись на кнопке:
func testButtonTitleUpdatesCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
self.instance.showNextImage(view: self.view)
verify(self.view).updateButton(title: "Продолжить")
}
Четвертый тест будет тестировать случай, когда в очереди осталось только одно название изображения. В этом случае надпись на кнопке должна измениться на "Старт":
func testPrepareForApplicationStartCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.lastImageQueue)
self.instance.showNextImage(view: self.view)
verify(self.view).updateButton(title: "Старт")
}
Конечно же, строки "Продолжить" и "Старт" нужно выносить в глобальные константы, но в нашем учебном примере мы на это не будем тратить время.
Пятый тест будет проверять, что приложение стартует, когда очередь из названий пустая:
func testApplicationStartsCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.emptyImageQueue)
self.instance.showNextImage(view: self.view, localManager: self.localManager)
verify(self.view).startApplication()
}
И шестой тест проверит, что наш localManager
вызовет метод сохранения флага об успешном прохождении обучалки пользователем:
func testLocalManagerSetsOnBoardingFlagCorrectly() {
self.instance = OnBoardingPresenter(imageQueue: self.emptyImageQueue)
self.instance.showNextImage(view: self.view, localManager: self.localManager)
verify(self.localManager).setFlagOnBoardingCompleted()
}
Запустим тесты и получим "успешный" результат для первого этапа TDD:
Все тесты красные. Переходим к следующему этапу.
Шаг 8. TDD. GREEN
Наконец-то мы можем заняться программированием логики нашего OnBoardingPresenter
. Итак, первое, что мы сделаем — извлечение следующего элемента из очереди. Реализуем это просто беря из массива первый элемент и проверяя его на nil
:
if let nextImageName = self.imageQueue.first {
} else {
}
Если мы получили следующий элемент из очереди — нужно сказать view
, чтоб она его показала: view.showImage(imageName: nextImageName)
. Если же следующего элемента нет — нужно сказать view
, что пора стартовать приложение: view.startApplication()
. Сейчас метод showNextImage(...)
выглядит так:
func showNextImage(view: OnBoardingViewProtocol,
localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {
if let nextImageName = self.imageQueue.first {
view.showImage(imageName: nextImageName)
} else {
view.startApplication()
}
}
Чтоб определить достаточно ли кода мы написали — запускаем юнит-тесты и смотрим успешно ли они выполняются:
Мы видим, что два из шести тестов уже проходят успешно, но кода написано недостаточно.
Продолжаем кодить: поскольку мы взяли название изображения из очереди — нужно его оттуда убрать, также нужно дать команду нашему localManager
, что обучалка пользователем просмотрена:
func showNextImage(view: OnBoardingViewProtocol,
localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {
if let nextImageName = self.imageQueue.first {
view.showImage(imageName: nextImageName)
self.imageQueue = Array(self.imageQueue.dropFirst())
} else {
view.startApplication()
localManager.setFlagOnBoardingCompleted()
}
}
Еще раз запускаем тесты и смотрим результат:
Успешно пройдены четыре из шести. Осталось только правильно присвоить надпись нашей кнопке:
if self.imageQueue.first != nil {
view.updateButton(title: "Продолжить")
} else {
view.updateButton(title: "Старт")
}
Еще раз напоминаю на всякий случай, что строки "Продолжить" и "Старт" должны быть вынесены в глобальные константы.
Еще раз запустим тесты и вуаля:
Все наши тесты проходят успешно. Мы написали достаточно кода для реализации необходимого нам функционала. Полностью наш презентер выглядит так:
class OnBoardingPresenter {
var imageQueue: [String]
init(imageQueue: [String] = OnBoardingImageManager().getImageQueue()) {
self.imageQueue = imageQueue
}
func showNextImage(view: OnBoardingViewProtocol,
localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {
if let nextImageName = self.imageQueue.first {
view.showImage(imageName: nextImageName)
self.imageQueue = Array(self.imageQueue.dropFirst())
if self.imageQueue.first != nil {
view.updateButton(title: "Продолжить")
} else {
view.updateButton(title: "Старт")
}
} else {
view.startApplication()
localManager.setFlagOnBoardingCompleted()
}
}
}
Шаг 9. TDD. REFACTOR
Успешного выполнения тестов недостаточно, необходимо посмотреть на свой код и привести его в порядок: отрефакторить. В данном случае мне не нравится вот этот кусок:
if self.imageQueue.first != nil {
view.updateButton(title: "Продолжить")
} else {
view.updateButton(title: "Старт")
}
Тут повторяется вызов view.updateButton(...)
, отличие лишь в значении параметра, который в этот метод передается. Можно переписать его так:
let buttonTitle: String
if self.imageQueue.first != nil {
buttonTitle = "Продолжить"
} else {
buttonTitle = "Старт"
}
view.updateButton(title: buttonTitle)
Запустим тесты и убедимся, что не нарушили логику: тесты все еще выполняются корректно.
Теперь нужно заменить проверку self.imageQueue.first != nil
на self.imageQueue.count != 0
(не обязательно пытаться взять первый элемент в массиве, достаточно просто проверить количество элементов в массиве). Запускаем тесты и видим, что все ок.
Но и на этом не останавливаемся, проверка self.imageQueue.count != 0
тоже не совсем правильная, ведь у массива есть свойство isEmpty
. Поэтому меняем self.imageQueue.count != 0
на self.imageQueue.isEmpty
и воспользуемся тернарным оператором (? :
): let buttonTitle = self.imageQueue.isEmpty ? "Продолжить" : "Старт"
.
Запускаем юнит-тесты и видим такую картину:
Юнит-тесты помогают нам понять, что мы допустили ошибку и, даже, где именно мы ее допустили: мы перепутали местами строки "Продолжить" и "Старт". Внесем правки: let buttonTitle = self.imageQueue.isEmpty ? "Старт" : "Продолжить"
, и запустим тесты еще раз:
Вот теперь все в порядке, и мы закончили третий этап TDD.
Шаг 10. Финал
Согласно методологии TDD мы реализовали необходимый и достаточный функционал для нашей обучалки. Давайте посмотрим как она выглядит на экране.
Для этого нам нужно добавить необходимые изображения в Assets (в конце статьи будет ссылка на репозиторий с этим проектом и там будут все изображения), и установить наш OnBoardingViewController.storyboard
, как стартовый для приложения:
Запускаем наше приложение и видим следующее:
Проходим обучалку до конца и видим надпись "Старт" на кнопке, когда показано последнее изображение:
После нажатия на кнопку "Старт" в консоли видим сообщения "Application is started" и "OnBoarding completed successfully":
Это означает, что наша view
получила команду запустить приложение, а localManager
зафиксировал, что пользователь успешно просмотрел обучалку.
Итог
Весь проект, который мы с вами сделали можете скачать тут: Swift, Objective-C.
Спасибо за внимание. Всем добра.
Комментарии (14)
alek_sys
23.08.2017 23:11+1Спасибо за отличную практическую статью про TDD! Их, как мне кажется, мало — но TDD это как катание на велосипеде — можно долго рассказывать как это, но сложно научиться не попробовав :)
Я бы только хотел добавить одну важную деталь, которой многие люди придерживаются при использовании подхода TDD — это "один тест за раз". Т.е. итерация "red — green — refactor" делается на одном тесте. Это не значит, что по другому никак, но обычно это серьезно помогает фокусироваться и писать действительно минимальный код, чтобы тест прошел. Можно написать и 6 падающих тестов сразу, но это обычно немного рассеивает внимание — что именно мы сейчас реализуем — чтение из очереди или изменение текста кнопки?
dr_Irbisov
24.08.2017 10:04+2С другой стороны, тот же Бек писал про «двигайтесь большими шагами, если уверены в том, что делаете». Наверняка каждому даже очень опытному разработчику приходится сталкиваться с необходимостью реализовать что-то тривиальное — и растягивать этот процесс надолго только ради чистоты техники, имхо, излишне.
s_suhanov Автор
24.08.2017 11:47Вот тут: https://ru.wikipedia.org/wiki/Разработка_через_тестирование речь идет о нескольких "новых тестах", которые не проходят.
IvanDulko
24.08.2017 00:45+1К сожалению пример не соответствует действительности и неправильно интерпретирует правила TDD. Согласно TDD не может быть 6 падающих тестов, только один и только первый. После того как первый тест упал, надо править код чтобы он заработал, потом рефакторить, потом писать тест дальше до того момента как он упадет. К слову ошибка компиляции — это тоже упавший тест.
s_suhanov Автор
24.08.2017 11:46Не знаю-не знаю, смотрю сюда: https://ru.wikipedia.org/wiki/Разработка_через_тестирование
и в разделе "Цикл разработки через тестирование" вижу, что речь идет от тестах во множественном числе (заголовок: "Запуск всех тестов: убедиться, что новые тесты не проходят").
А вот про ошибку компиляции, как упавший тест — очень согласен. Несколько раз видел, как люди пишут тесты используя еще не созданные классы. Такие тесты не то что не выполняются, они просто не компилируются. :)
IvanDulko
24.08.2017 15:29+1Похоже просто «трудности перевода» — в английской версии https://en.wikipedia.org/wiki/Test-driven_development написано
Run all tests and see if the new test fails
что в правильном переводе означаетЗапустить все тесты и посмотреть что новый тест падает
Всегда читайте оригинал :)s_suhanov Автор
24.08.2017 21:18Так дело в том, что и в русском переводе есть и про один новый тест, и про несколько новых тестов. :)
Sie
24.08.2017 14:10+1Несколько раз видел, как люди пишут тесты используя еще не созданные классы.
По мне такое похоже на мазохизм ведь IDE сразу укажет на то что класса или метода несуществует, так зачем такое вообще писать, тут ясно и без тестов что не скомпилируется.
Или я не прав и Xcode+Swift не подсвечивает такие ошибки?IvanDulko
24.08.2017 17:05+1Хорошая IDE мало того что укажет, а еще и создаст для вас класс или метод.
Смысл в том что написанный тест определяет задачу которую надо решить (а не решение).
s_suhanov Автор
24.08.2017 21:19Все подсвечивает, и я такого тоже не понимаю. Удобнее поставить на все методы "заглушки", чтоб все компилировалось нормально. :)
Sie
25.08.2017 05:42+1Тоже согласен писать тест с несуществующим классом толкь ради того что бы его создать — глупо, куда проще создать клас + методы и написать тесты выражающие намерение что либо протестировать. Как результат если тесты будет написы правильно то они не выполнятся. И только после этого писать уже реализацию метода.
GigabyteTheOne
24.08.2017 23:05Очень хорошая статья, спасибо :)
Вот только не стоит вызыватьself.presenter.showNextImage(view: self)
в методеviewWillAppear
. В таком случае, если свернуть и развернуть приложение, покажется следующее изображение.s_suhanov Автор
25.08.2017 08:07Нет. Методы жизненного цикла вью-контроллера не вызываются при сворачивании/разворачивании приложения. Можете проверить дебаггером на простом примере.
Sie
Отличный пример.
Было интересно почитать о чем то более менне реальном а не assertTrue(2+2==4)