Применение 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.

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

Реклама

«Мочим» объекты с помощью Cuckoo

Пост написан по мотивам статьи Mocking in Swift with Cuckoo by Godfrey Nolan

По долгу своей «службы» мобильным разработчиком, предстала передо мной задача: разобраться с созданием и использованием Моков для юнит-тестирования. Моим коллегой была рекомендована библиотека Cuckoo. Стал я с ней разбираться и вот что из этого вышло.

Документация

Прочитав документацию на гитхабе мне, к сожалению, не удалось «завести» Cuckoo в моем проекте. Через CocoaPods этот фреймворк был установлен, но вот с Run-скриптом возникли проблемы: предложенный пример не создавал файл GeneratedMocks.swift в папке с тестами, и я бы и не разобрался почему, если бы не нашел через гугл статью, которую упомянул в начале поста.

Итак, пройдем все этапы вместе и разберемся с некоторыми нюансами.

Тестовый проект

Естественно, нам нужен какой-нибудь проект в который мы подключим Cuckoo и напишем несколько тестов. Откройте Xcode, и создайте новый Single View Application: язык — Swift, обязательно поставьте галочку Include Unit Tests, имя проекта — UrlWithCuckoo.

Добавьте в проект новый Swift-файл и назовите его UrlSession.swift. Вот полный код:

import Foundation

class UrlSession {
    var url:URL?
    var session:URLSession?
    var apiUrl:String?

    func getSourceUrl(apiUrl:String) -> URL {
        url = URL(string:apiUrl)
        return url!
    }

    func callApi(url:URL) -> String {
        session = URLSession()
        var outputdata:String = ""
        let task = session?.dataTask(with: url as URL) { (data, _, _) -> Void in
            if let data = data {
                outputdata = String(data: data, encoding: String.Encoding.utf8)!
                print(outputdata)
            }
        }
        task?.resume()
        return outputdata
    }
}

Как видите, это простой класс с тремя свойствами и двумя методами. Именно для этого класса мы и будем создавать Мок.

Подключаем Cuckoo

Я использую в работе CocoaPods, поэтому для подключения Cuckoo добавлю в каталог с проектом Podfile такого вида:

platform :ios, '9.0'
use_frameworks!

target 'UrlWithCuckooTests' do
    pod 'Cuckoo'
end

Естественно нужно запустить pod install в терминале из каталога с проектом, и после завершения установки открыть в Xcode UrlWithCuckoo.xcworkspace.

Следующим шагом добавляем Run-скрипт в Build Phases нашего «таргета» тестирования (нужно нажать «+» и выбрать «New Run Script Phase»):

Вот полный текст скрипта:

# Define output file; change "${PROJECT_NAME}Tests" to your test's root source folder, if it's not the default name
OUTPUT_FILE="./${PROJECT_NAME}Tests/GeneratedMocks.swift"
echo "Generated Mocks File = ${OUTPUT_FILE}"

# Define input directory; change "${PROJECT_NAME}" to your project's root source folder, if it's not the default name
INPUT_DIR="./${PROJECT_NAME}"
echo "Mocks Input Directory = ${INPUT_DIR}"

# Generate mock files; include as many input files as you'd like to create mocks for
${PODS_ROOT}/Cuckoo/run generate --testable "${PROJECT_NAME}" \
--output "${OUTPUT_FILE}" \
"${INPUT_DIR}/UrlSession.swift"

Как видите, в комментариях в скрипте написано о необходимости заменить ${PROJECT_NAME} и ${PROJECT_NAME}Tests, но в нашем примере в этом нет необходимости.

Генерируем Мок(и)

Дальше нам нужно, чтоб этот скрипт сработал и создал в каталоге с тестами файл GeneratedMocks.swift, и просто сбилдить проект (Cmd+B) для этого недостаточно. Нужно сделать Build For -> Testing (Shift+Cmd+U):

Проверьте что в каталоге UrlWithCuckooTests появился файл GeneratedMocks.swift. Его (файл) также нужно добавить в сам проект: просто перетащите его из Finder в Xcode в UrlWithCuckooTests:

Наши Моки готовы, поговорим о некоторых нюансах.

1. Сложные файловые структуры

Если у вас в проекте присутствует нормальная файловая структура и файлы разложены по подпапкам, а не просто находятся в корневом каталоге, то в скрипт нужно внести некоторые корректировки.

Допустим вы используете в своем проекте MVP и вам нужен Мок для вью-контроллера модуля MainModule (он у вас в проекте, конечно же, лежит по адресу /Modules/MainModule/MainModuleViewController.swift). В этом случае вам нужно поменять последнюю строку в скрипте из нашего примера "${INPUT_DIR}/UrlSession.swift" на "${INPUT_DIR}/Modules/MainModule/MainModuleViewController.swift".

Также если вы хотите, чтоб файл GeneratedMocks.swift попадал не просто в корневой каталог тестов, а, например, в подпапку Modules, то вам нужно подкорректировать в скрипте вот эту строку: OUTPUT_FILE="./${PROJECT_NAME}Tests/GeneratedMocks.swift".

2. Нужны Моки нескольких классов

Очень вероятно (ожидаемая вероятность — 99.9%), что вам понадобятся Моки нескольких классов. Их можно сделать просто перечислив в конце скрипта файлы из которых нужно сделать Моки, разделив их обратными слэшами:

"${INPUT_DIR}/UrlSession.swift" \
"${INPUT_DIR}/Modules/MainModule/MainModuleViewController.swift" \
"${INPUT_DIR}/MyAwesomeObject.swift"

3. Аннотации типов

В классах к которым вы создаете Моки у всех свойств должны быть аннотации типов. Если у вас есть что-то типа такого:

var someBoolVariable = false

То при генерации Мока вы получите ошибку:

И в файле GeneratedMocks.swift будет фигурировать __UnknownType:

К сожалению Cuckoo не умеет определять тип по значению по умолчанию, и в таком случае необходимо явно указывать тип свойства:

var someBoolVariable: Bool = false

Пишем тесты

Теперь напишем несколько простых тестов используя наш Мок. Откроем файл UrlWithCuckooTests.swift и удалим из него два метода, которые создаются по умолчанию: func testExample() и func testPerformanceExample(). Они нам не понадобятся. И, конечно, не забудьте:

import Cuckoo

1. Свойства

Сначала напишем тесты для свойств. Создаем новый метод:

func testVariables() {

}

Инициализируем в нем наш Мок и пару дополнительных констант:

let mock = MockUrlSession()
let urlStr  = "http://habrahabr.ru"
let url  = URL(string:urlStr)!

Теперь нам нужно написать stub-ы для свойств:

// Arrange
stub(mock) { (mock) in
    when(mock.url).get.thenReturn(url)
}

stub(mock) { (mock) in
    when(mock.session).get.thenReturn(URLSession())
}

stub(mock) { (mock) in
    when(mock.apiUrl).get.thenReturn(urlStr)
}

Stub — это что-то типа подмены возвращаемого результата. Грубо говоря, мы описываем что вернет свойство нашего Мока, когда мы к нему обратимся. Как видите, мы используем thenReturn, но можем использовать и then. Это даст возможность не только вернуть значение, но и выполнить дополнительные действия. Например, наш первый stub можно описать и вот так:

// Arrange
stub(mock) { (mock) in
    when(mock.url).get.then { (_) -> URL? in

        // some actions here

        return url
    }
}

И, собственно, проверки (на значения и на nil):

// Act and Assert
XCTAssertEqual(mock.url?.absoluteString, urlStr)
XCTAssertNotNil(mock.session)
XCTAssertEqual(mock.apiUrl, urlStr)

XCTAssertNotNil(verify(mock).url)
XCTAssertNotNil(verify(mock).session)
XCTAssertNotNil(verify(mock).apiUrl)

2. Методы

Теперь протестируем вызовы методов нашего Мока. Создадим два тестовых метода:

func testGetSourceUrl() {

}

func testCallApi() {

}

В обоих методах также инициализируем наш Мок и вспомогательные константы:

let mock = MockUrlSession()
let urlStr  = "http://habrahabr.ru"
let url  = URL(string:urlStr)!

Также в методе testCallApi() добавим счетчик вызовов:

var callApiCount = 0

Дальше в обоих методах напишем stub-ы.

testGetSourceUrl():

// Arrange
stub(mock) { (mock) in
    mock.getSourceUrl(apiUrl: urlStr).thenReturn(url)
}

testCallApi():

// Arrange
stub(mock) { mock in
    mock.callApi(url: equal(to: url, equalWhen: { $0 == $1 })).then { (_) -> String in
        callApiCount += 1
        return "{'firstName': 'John','lastName': 'Smith'}"
    }
}

Проверяем первый метод:

// Act and Assert
XCTAssertEqual(mock.getSourceUrl(apiUrl: urlStr), url)
XCTAssertNotEqual(mock.getSourceUrl(apiUrl: urlStr), URL(string:"http://google.com"))
verify(mock, times(2)).getSourceUrl(apiUrl: urlStr)

(в последней строке мы проверяем, что метод вызывался два раза)

И второй:

// Act and Assert
XCTAssertEqual(mock.callApi(url: url),"{'firstName': 'John','lastName': 'Smith'}")
XCTAssertNotEqual(mock.callApi(url: url), "Something else")
verify(mock, times(2)).callApi(url: equal(to: url, equalWhen: { $0 == $1 }))
XCTAssertEqual(callApiCount, 2)

(тут мы тоже проверяем количество вызовов, причем двумя способами: с помощью verify и счетчика вызовов callApiCount, который мы объявляли ранее)

Запускаем тесты

После запуска проекта на тестирование (Cmd+U) мы увидим вот такую картину:

Все работает, отлично. 🙂

И напоследок

Ссылка на то что у нас в итоге получилось: https://github.com/ssuhanov/UrlWithCuckoo

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