Применение MVP+TDD в разработке iOS приложений

— Слава TDD!
— Юнит-тестам слава!

В этой статье мы разберемся с принципами применения MVP+TDD в разработке iOS приложений. Разбираться будем на примере создания небольшой обучалки для пользователя, которая показывается при первом запуске.

Требования от бизнеса

Итак, ваш заказчик хочет, чтоб в его приложение добавили обучалку, которая покажется пользователю один раз при первом запуске. Обучалка состоит из нескольких изображений, которые должны быть показаны в определенной последовательности. Переключаться изображения должны по нажатию на кнопку «Продолжить». Также при показе последнего изображения — на кнопке нужно написать «Старт» (как бы намекая пользователю, что приложение будет сейчас запущено).

Шаг 1. Продумываем логику.

  1. Итак у нас есть определенная последовательность изображений. Это значит, что у нас должна быть очередь (queue) этих изображений. Из этой очереди мы будем извлекать по одному изображению и показывать на экране. И так до тех пор, пока изображения в очереди не закончатся.

  2. Когда в очереди останется последнее изображение — нужно поменять надпись на кнопке: вместо «Продолжить» — «Старт».

  3. Когда пользователь увидит последнее изображение и нажмет кнопку «Старт» нам нужно показать первый экран приложения и зафиксировать, что пользователь уже смотрел обучалку (не показывать ее при последующих запусках).

Шаг 2. MVP

  1. Для начала создадим наше приложение (новый проект в Xcode) и назовем его OnBoardingTest.

    Сразу включите в проект и юнит-тесты (TDD ведь).

  2. Добавим в наш проект модуль для нашей обучалки. Назовем его 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

Моделью в нашем случае будет:
1. Очередь из названий изображений.
2. Сервис, который зафиксирует, что обучалка показана полностью, и в следующий раз ее показывать не нужно.

Очередь из названий реализуется просто. Поскольку это названия (String) и они должны быть упорядочены, значит очередь будет представлять из себя массив строк: Array.

Добавим в наш модуль сервис 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 {

}

Давайте разберемся какие данные он может хранить и что он должен делать:
1. Он должен хранить очередь из названий изображений, причем эта очередь будет изменяться (каждый раз из нее будет извлечено следующее название, пока очередь не станет пустой).
2. Он должен выполнять извлечение следующего названия из очереди и передавать его в нашу вью.
3. Он должен определить, что очередь закончилась и сообщить вью об этом, а также зафиксировать, что пользователь посмотрел обучалку полностью.

Добавим в наш презентер очередь из названий картинок:

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.

Спасибо за внимание. Всем добра.

Реклама