Перед прочтением я рекомендую ознакомиться с первой статьей серии. Она подробно рассказывает, чем хорошие тесты отличаются от плохих, какие виды тестов существуют и в каких пропорциях они должны распределяться. Этот же пост больше сфокусирован на практике модульного тестирования с использованием Quick и Nimble, поэтому теория из первой статьи вам очень пригодится. Кроме того, в серии мы рассказываем про UI-тесты. До того как начать писать модульные тесты, нам надо разобраться, чем они отличаются от Unit-тестов. Это довольно дискуссионный вопрос, и он уже поднимался в первой статье, но я думаю, тут стоит немного повториться. Unit-тестом мы считаем тест на один изолированный класс, где все его зависимости закрыты моками. А модульным мы считаем тест, который тестирует связку классов (модуль) с минимальным возможным количеством моков. Иногда такие тесты еще могут называться интеграционными или функциональными. Модульный тест проверяет работу модуля (читай: экрана) целиком, используя минимальное количество моков и заглушек. Поэтому тестируемый модуль получается гораздо ближе к реальному модулю в продакшене, чем если бы мы тестировали все части модуля по отдельности. Ведь если заменить все зависимости класса на заглушки и протестировать, то мы сможем узнать лишь то, что класс хорошо работает с заглушками, но о взаимодействии с другими классами мы узнаем мало. В модульных тестах нас в первую очередь интересует проверка пользовательских историй, а не детали реализации и интерфейсы, по которым между собой связываются тестируемые части модуля. Такой подход к разработке тестов сильно упрощает рефакторинг модуля и поддержку самих тестов в будущем. Даже в случае замены архитектуры модуля не придется писать все покрывающие его тесты с нуля. При выборе инструментов мы остановились на фреймворках Quick и Nimble, и они неплохо себя зарекомендовали. С их помощью удобно валидировать поведение системы целиком, они позволяют избегать дублирования кода и делать тесты компактными и выразительными. Если говорить высокоуровнево, то Quick — это фреймворк, помогающий организовать код тестов, а Nimble — это метчер. Проще всего будет разобраться на небольшом примере, показывающем базовую функциональность этих фреймворков. Мы будем тестировать класс калькулятора, который умеет складывать числа.  Выражения expect(calculator.result).to(equal(3)) (3) и expect(calculator.result) == 0 (11) относятся к фреймворку Nimble, поговорим о них чуть дальше. А функции describe() (1), beforeEach() (2, 4, 8), context() (3, 7) и it() (5, 10) относятся к фреймворку Quick. Они помогают организовывать код тестов и являются базой этого фреймворка. Далее они будут называться блоками. Подробнее о блоках describe() — блок, вложенный в метод spec() и описывающий назначение всех вложенных тест-кейсов. Блоков describe() внутри функции spec() может быть несколько, особенно если нужно разбить один большой describe() для улучшения читаемости кода тестов. context() —  такой блок, как правило, описывает какое-либо действие из пользовательской истории. Блоки context() вкладываются в блоки describe() или другие блоки context(). Описание этих блоков удобно писать в виде условия, например: “Если данные загрузились успешно” или “Если ввели неверные данные”. beforeEach() — такой блок должен находиться внутри describe() или context(). Код внутри beforeEach() переводит тестируемую систему в новое состояние. Желательно, чтобы все действия над системой проводились только внутри этих блоков. it() — в этих блоках выполняется проверка того, что совершенные в beforeEach() действия привели к ожидаемым результатам.  В описание блока обычно вписывают результат действия, например: “View обновляется с правильным State” или “Во View была показана ошибка”. Порядок выполнения блоков кода: Понимание порядка вызова блоков является ключом к пониманию работы Quick. Важно понимать, что блоки it() не вызываются последовательно друг за другом. Для каждого блока it() формируется последовательность вызовов, согласно вложенности блоков describe(), context() и it(). В этом примере последовательность вызова блоков будет такая: [2, 4, 5] и [2, 8, 10], т.е. по одной последовательности для каждого it() (5, 10). Но никак не [2, 4, 5, 8, 10]. Если бы мы захотели преобразовать этот набор тестов на Quick в классические XCTest, то выглядели бы они примерно так: Важные замечания: Формально каждый блок it() является самостоятельным тестом, поэтому важно организовать код так, чтобы к моменту выполнения блока it() все объекты были правильно подготовлены для проверки.  Важно следить за читаемостью описаний блоков describe(), context() и it(). Читаемость — одна из основных характеристик теста. А еще это описание позволит проще анализировать результаты упавших тестов. Не стоит вызывать какой-либо код вне блоков beforeEach() или it(). Это может привести к неожиданному поведению теста. Но вне этих блоков разрешается размещать переменные, как, например, SumCalculator из теста, приведенного выше. Главное — не забывать присваивать ему новое значение в соответствующих блоках beforeEach().  Блоки на одном уровне вложенности выполняются не в порядке объявления в коде, а в алфавитном порядке их описаний. Но не стоит на это завязываться. Для удобства лучше считать, что они вызываются в случайном порядке. Nimble — это метчер. Метчер нужен для проверки и сравнения значений результатов выполнения тестов. В примере Nimble используется в expect(calculator.result).to(equal(3)) (6) и expect(calculator.result) == 0 (11). Проверки == и .to(equal()) эквиваленты. На мой взгляд, == читается чуть лучше, но тут уже дело вкуса, каждый выбирает, что ему удобнее. Еще из важных функций Nimble стоит отметить проверку toEventually(). Она позволяет работать с асинхронными вызовами и ждать момента, когда условие станет верным. Подробнее ее использование рассмотрим чуть позже. А еще советую почитать документацию, там много всего интересного. Например, в Nimble есть проверки исключений, сравнения (включая неточные сравнения для чисел с плавающей точкой), проверки вхождения элементов в коллекции и много другое. Тестировать будем модуль, отвечающий за экран воображаемого приложения для загрузки интересных фактов. В реализации модуля используется слоистая архитектура с разделением View, Presenter и Service. Тут могла бы быть любая архитектура, в которой View можно отделить от логики. Это ключевое условие для тестируемой архитектуры, потому что вещи, связанные с UIKit, очень неудобно тестировать.  Нам важно вынести всю логику из View-слоя, чтобы вероятность логических ошибок в нем была минимальна. Кроме этого, работа с Date() должна быть вынесена в специальный DateProvider, потому что создание через Date()  — это неявная изменяемая зависимость, которая может повлиять на результаты тестирования.  Работу с локацией (тут не используется) тоже лучше закрыть протоколом и вынести из тестируемого кода. А вот работу с базами данных (тут тоже не используется), UserDefault и другую работу с данными можно оставить в коде, главное не забывать очищать эти хранилища данных перед выполнением теста.  Модуль, который мы будем тестировать, довольно прост. При заходе на экран по центру показывается кнопка “Загрузить полезный факт”. При нажатии на нее показывается лоадер и происходит попытка загрузки данных из сети. После успешной загрузки данных показывается полезный факт и его время публикации. При нажатии на кнопку “Посмотреть подробнее” откроется экран с более подробной информацией. В рамках статьи экран с подробной информацией нас не интересует, сфокусируемся на нажатии на кнопку. При неудачной загрузке показывается алерт с информацией об ошибке. При закрытии алерта снова видно кнопку “Загрузить полезный факт”. Перейдем к реализации в коде. Наш модуль состоит из следующих основных частей: View, Presenter, Service, Router и NetworkTransport. Разберем каждую из них более подробно. Принесем в жертву некоторые хорошие практики разработки ради компактности статьи, так что если вам кажется, что код мог бы выглядеть элегантнее, то, скорее всего, оно так и есть.  Начнем с NetworkTransport и Service. Есть протокол NetworkTransport, закрывающий NetworkTransportImpl (сам код приводить не буду, предположим, что он оборачивает URLSession), который умеет ходить в сеть за данными.  Использоваться он будет внутри InfoService. Это сервис, который знает, по какому URL обратиться и с какими параметрами отправить запрос. От сервера нам должны будут прийти такие данные: И нам надо будет замаппить их в эту модель: Дальше идет Presentation-слой. В нашем примере в нем идет основная логика.  Сам Presenter будет общаться с View через протокол InfoViewInput. Функция update() обновляет state экрана, переводя его в требуемое состояние, а showError() показывает алерт об ошибке. Состояний у экрана может быть всего три: изначальный с синей кнопкой “Загрузить полезный факт”, загрузка в процессе с лоадером по центру экрана и уже экран с полезным фактом, датой и кнопкой “Посмотреть подробнее”. Эти состояния как раз и отражает перечисление InfoViewState. Presenter занимается тем, что связывает View и Service. View сообщает Presenter о своих событиях (контроллер показался на экране или нажата кнопка), а Presenter решает, что делать с этой информацией (обновлять state, начинать загрузку данных из сети или показывать новый модуль). Рассмотрим этот класс подробнее.Метод презентера start() должен вызываться при вызове viewDidLoad() у UIViewController’а. Метод start() обновляет стейт у этого UIViewController’а и приводит его в начальное состояние с кнопкой “Загрузить полезный факт” по центру.  Метод didTapLoadInfoButton() вызывается при нажатии кнопку “Загрузить полезный факт” и начинает загружать полезный факт, а также переводит View в состоянии отображения лоадера. При успешной загрузке полученные данные обрабатываются, и Presenter обновляет с ними стейт у View. Если загрузка неудачная, то показывается ошибка и View переводится в изначальный стейт. А метод didTapShowDetailsButton() вызывается при нажатии на кнопку “Посмотреть подробнее”, что приводит к вызову соответствующей функции Router’а. Сам Router и протокол, закрывающий его, выглядят так: Стоит отдельно обратить внимание на DateProvider внутри Presenter и сам процесс получения даты. Проблема в том, что если мы будем использовать простое создание Date() для получения текущей даты, то с течением времени будут возвращаться разные значения, а это, в свою очередь сделает тестирование связанной с датами функциональности невозможным. Но у этой проблемы есть довольно простое решение: использовать отдельный DateProvider. DateProvider — это протокол, который закрывает класс, возвращающий текущую дату. Для реального кода используется класс, который возвращает просто Date(), а для тестируемого кода — предзаданную дату для тестов. Выглядит это так: А теперь код UIViewController’а, в нем все очень просто. Оставим за скобками стилизацию и настройки layout’а:: Тут стоит обратить внимание на update(), в ней при изменении state показываются, скрываются и обновляются соответствующие элементы. Все эти элементы модуля собираются через фабрику: Вот и всё описание модуля. С одной стороны, довольно объемно для такого экрана, с другой стороны, компактнее сделать не получается, учитывая тестируемость и достаточное разделение по слоям. Дальше нас ждет этап подготовки модуля к тестированию. Основная логика этого модуля лежит в Presenter и в Service, эту связку мы и будем тестировать. Если бы в нашем модуле был еще и Interactor, то он тоже попал бы в тестируемую связку. А чтобы мы могли протестировать слои, содержащие логику, мы должны сначала закрыть моками те части, которые невозможно или неоправданно сложно протестировать. Какие части закрыть моками? Моками мы закрываем View, Router, DateProvider и NetworkTransport. View и Router закрываем, потому что в них есть зависимости от UIKit. Про DateProvider уже пояснили чуть выше, так мы избавляемся от неявной изменяемой зависимости Date(). А NetworkTransport нужно закрыть моком, чтобы не ходить за реальными данными в сеть и сделать тесты быстрыми и воспроизводимыми. Стоит отметить, что для упрощения в этой статье моками будет называться любая часть, которая подменяет реальный код. Хотя по факту некоторые из них было бы правильнее назвать Spy или Fake. Подробнее о моках, стабах и шпионах можно почитать в статье Mocks aren’t Stubs. Создаем моки: В первую очередь нужно решить, как мы будем создавать моки. Во-первых, их можно создать руками. Но это довольно утомительный процесс, потому что потребуется писать много бойлерплейта и шанс ошибиться довольно высок.  Чтобы избежать написания этого бойлерплейта, можно использовать SwiftMockGeneratorForXcode. Это расширение для Xcode, которое позволяет создавать моки в несколько кликов мышкой. Не буду дублировать инструкцию тут, ее можно найти на страничке проекта, там всё очень просто и потребует минут 5 на установку и запуск. Для начала работы с тестами я советую использовать именно эту утилиту. А еще моки можно генерировать с помощью Sourcery, в документации которой есть отдельный пункт про кодогенерацию для моков. В Циан мы используем именно этот инструмент. Начнем с InfoViewInput и сгенерируем для него мок через SwiftMockGeneratorForXcode. Поскольку у методов InfoViewInput нет возвращаемых значений, то нам не нужно добавлять эти возвращаемые значения для моков. Такой подход сильно упрощает работу с тестами. А теперь создадим мок для InfoRouter: Дальше перейдем к самому интересному моку: NetworkTransport. В простом варианте можно просто сделать отдельный NetworkTransport для каждого набора тестов, но это в перспективе отнимает довольно много времени. Поэтому постараемся создать универсальное решение.  Смысл в том, что мы будем класть json-файл с ответом от сервера в bundle с тестами и загружать данные уже оттуда, а не из сети. А сам NetworkTransportMock уже будет решать, для каких запросов какие данные загружать, и маппить их в соответствующие модели. Код минималистичной версии FakeNetworkTransport будет выглядеть примерно так: Тут стоит обратить внимание на функции mockSuccess() и mockFailure(). Через них можно сообщить NetworkTransportMock для каких запросов какие json-файлы с ответами использовать. А при вызове load() уже будет происходить поиск нужных ответов в bundle и их декодинг. На этом с моками всё. Теперь нам нужно всё это как-то собрать воедино, чтобы протестировать. Объекты, которые понадобятся нам во время тестирования, мы будем хранить в TestInfoModule.  А собирать этот модуль для тестирования мы будем с помощью специальной фабрики. Наконец, подготовка закончена. Мы заменили на моки всё, что нам было нужно, дальше переходим к тестированию! Вот мы и почти готовы к тестированию нашего модуля. Чтобы начать писать тесты, осталось только интегрировать Quick в проект. Как это сделать, лучше всего описано в документации, выберите там подходящий вам способ.  В модульных тестах очень важно соблюдать последовательность вызовов, максимально близкую к реальной. У нас в Presenter есть функция start(), которая вызывается из UIViewController.viewDidLoad(), и функция didTapLoadInfoButton(), которая начинает загрузку данных. На реальном устройстве нажать на кнопку на экране можно будет только после того, как вызовется viewDidLoad(). Вот и в тестах, чтобы проверить работу функции didTapLoadInfoButton(), мы сначала должны вызвать функцию start(). Благо в Quick это всё довольно легко организовать. Начнем с создания модуля: Пойдем по порядку. В (1) мы объявляем наш класс с тестами и наследуем его от QuickSpec, в (2) переопределяем функцию spec(). Потом объявляем внешний блок describe() (3) и в его описании пишем, что именно будем проверять в этом тесте. Обычно это название тестируемого модуля. Едем дальше: в (4) идет объявление переменной модуля, а в (4.1, 4.2, 4.3) — вычислимые проперти-хелперы для упрощения обращения к частям модуля. Важно, что сам модуль создается (5) в блоке beforeEach(), а не в момент объявления переменной. Это нужно, чтобы части теста не влияли друг на друга.А на место (6) мы будем добавлять новые части теста. Начнем с проверки изначального состояния. После вызова viewDidLoad() у UIViewController на экране должна показаться кнопка “Загрузить полезный факт”, а это значит, что Presenter должен сообщить View, что надо перейти в состояние initial. В этот момент еще никаких запросов не должно быть отправлено на сервер. Код теста будет выглядеть так, мы вставляем его на место (6): Итак, пойдем по порядку. В этой части теста у нас появляется context() (7), внутри описания context() мы пишем определенное условие, последствия выполнения которого мы будем проверять внутри. Читать это можно так: “Если View появилось на экране, то …”.  Далее идет блок beforeEach() (8). В нем мы переводим систему в состояние, которое соответствует условию в описании context(). Все действия над системой нужно производить в блоках beforeEach().  Чуть ниже идут две проверки it() (9 и 12). В описании проверок it() мы пишем, что именно будем проверять. Проверки разных аспектов (например, изменение View и работы с сетью) лучше разделять на разные блоки it(). В (9) мы проверяем, что было выполнено одно обновление View (10) и что состояние стало именно initial (11). А в (12) проверяем, что никаких обращений к сети не было (13). Тут очень важно еще раз отметить порядок выполнения блоков кода. Нужно учитывать, что каждый it() фактически является отдельным тестом, и beforeEach() будет выполняться перед каждым вложенным блоком на одном уровне. Это значит, что порядок кода будет такой: [(8), (9)] и [(8), (12)], но никак не [(8), (9), (12)]. Теперь наш модуль переведен в начальное состояние, и все необходимые проверки выполнены. Следующую проверку будем вписывать на место (14). После того как система переведена в начальное состояние, можно нажать на кнопку “Загрузить полезный факт”, которая вызовет загрузку данных. Сразу после нажатия на кнопку должен появиться лоадер (View переходит в состояние loading), а после успешной загрузки View перейдет в состояние отображения полезного факта (состояние info). Пройдемся по коду теста: context() (15) идет на месте (14), т. е. он вложен внутрь предыдущего context() (7). Это нужно, чтобы избежать дублирования вызова presenter.start() из (8), а еще это позволяет более внятно организовать код тестов. В beforeEach() (16) мы добавляем обработчик запроса (17) в NetworkTransportMock через функцию в его extension (17.1). Добавление вынесено в extension специально, чтобы повысить читаемость кода теста. Это значит, что если в NetworkTransportMock придет запрос с url: https://info-example.com/get_info и method: GET, то ответ будет взят из info-fake-response.json.  А в (18) мы уже нажимаем на саму кнопку загрузки данных и в it() (19) сначала проверяем, что обновление View было сделано в общей сложности 2 раза (20) (первый раз стейт был initial). Таким образом, итоговый стейт, сразу после нажатия на кнопку, становится loading (21).  В (22) становится интереснее. Из-за того что у NetworkTransportMock мы настроили задержку ответа в 0.1 секунду (в методе (17.1)), чтобы эмулировать ожидание выполнения запроса к серверу, нам придется подождать эту 0.1 секунды, пока стейт не обновится до ожидаемого. А для того чтобы дождаться отложенного действия, мы будем использовать функцию toEventually(). Прочитать, как она работает, можно тут.  В (22) мы как раз ждем, пока стейт обновится до необходимого нам. Создание правильного стейта для проверки вынесено в отдельную функцию (22.1) для улучшения читаемости кода. В (23) мы проверяем, что стейт обновился ровно 3 раза, и мы случайно не обновили его лишний раз после загрузки данных. В (24) мы будем вставлять еще один context() для проверки нажатия на кнопку “Посмотреть подробнее”, которая будет открывать новый экран. Всё, успешную загрузку данных мы проверили, осталось проверить еще буквально пару вещей.  После того, как данные загружены, мы можем нажать на кнопку, и откроется экран с подробной информацией. Из-за того, что на реальном устройстве нажать на эту кнопку можно только после загрузки данных, в тестах мы тоже должны проверять открытие экрана только после проверки загрузки данных, т.е. context() (25) должен идти на месте (24). Чтобы этот тест правильно работал, придется сделать небольшой трюк. Поскольку context() (25) начнет выполняться сразу после выполнения кода в beforeEach() на предыдущем уровне (16), то и в стейт info система перейти не успеет. Поэтому тут нам надо подождать, пока это произойдет прямо в этом блоке beforeEach() (25). Но чтобы внутри beforeEach() не использовать expect(), их можно заменить на waitUntilTrue(), это просто небольшая обертка над expect().toEventually()(30), но она делает код немного чище. После того как мы дождались перехода системы в состояние info с помощью waitUntilTrue(), мы нажимаем на кнопку (27) и дальше проверяем, что нажатие на кнопку было одно (28), и в роутер был передан правильный идентификатор факта (29). С этой частью всё, нам осталось проверить неуспешную загрузку данных, но там всё предельно просто. При неуспешной загрузке должен показаться алерт об ошибке, а система должна перейти в состояние initial.  Проверка неуспешной загрузки данных (31) должна идти на том же уровне вложенности, что и context() проверки успешной загрузки (15). В начале в NetworkTransportMock мы добавляем обработчик (32, 32.1), который будет возвращать ошибку для обращения к сети. Потом мы нажимаем на кнопку (33) и проверяем, что система в итоге перешла в состояние initial (34), всего было 3 обновления стейта (первый на initial, потом на loading, потом снова на initial) и был показан алерт об ошибке (36). Готово, модуль полностью протестирован. Полный код тестов модуля приведен ниже, по нему будет немного понятнее, какие блоки context() куда вложены. А еще код тестов выступает в виде простой документации модуля. С его помощью можно понять, какая функциональность есть у модуля и в каком порядке она может использоваться. Тут приведен небольшой список приемов, которые могут улучшить код тестов Для удобства тестирования рекомендуется создавать специальные тестовые модули, которые содержат в себе все классы, необходимые для тестирования. Это и сами тестируемые классы, и моки для проверки. Создаются такие модули через фабрики тестовых модулей. Пример такого модуля и фабрики можно посмотреть в нашем тесте (TestInfoModule и TestInfoFactory). Иногда получается так, что у модуля есть несколько больших сценариев использования, его работа сильно меняется от входных данных или у модуля просто слишком много разных возможностей, и тест сильно разрастается.  В этом случае имеет смысл разделить тест на несколько блоков describe() или даже разных тестовых файлов. Слишком длинные тесты и тесты со слишком глубокой вложенностью блоков describe() и context() плохо читаются, поэтому они должны быть разделены. Довольно очевидный пункт, но все же. Почему-то некоторые разработчики считают, что вынос кода в отдельные функции в тестах — плохая идея, но это не так. Если один и тот же код повторяется несколько раз или с небольшими изменениями, то его можно вынести в отдельную функцию, если это улучшит читаемость. Очень похоже на вынос кода в функции, но немного под другим углом. В нашем тесте уже был такой пример, когда мы выносили добавление обработчиков в NetworkTransportMock. Без добавления кода extension: С добавлением через extension: Во время чтения кода теста нас в целом не волнует, как добавляется обработчик с ошибкой, нас интересует только сам факт добавления обработчика. Поэтому в данном случае такой подход вполне оправдан и улучшает читаемость теста. Иногда, вместо того, чтобы проверять каждое поле проверяемого объекта отдельно, можно сразу сравнить получаемый объект с ожидаемым объектом. Это становится очень удобно, если такие проверки повторяются в нескольких частях теста. Пример прямой проверки Пример сравнения с образцом: В тестах можно использовать force unwrap, особенно для проверяемых значений. Если значения нет, то тест упадет, а это значит, что в клиентском коде появились какие-то ошибки. Если вы используете CocoaPods для деления проекта на модули, то тесты для модулей можно добавить вот так.  Теперь вы знаете о модульном тестировании с использованием Quick и Nimble чуточку больше. В этой статье приведен довольно простой пример, но он затрагивает основные аспекты работы с тестами. Тут есть мокирование зависимостей, подготовка модуля для тестирования и организация кода тестов. Пришло время переложить эти знания на модули из вашего проекта и написать пару своих тестов. Лучше начать с нового модуля, так как подготовить старый модуль к тестированию может оказаться нетривиальной задачей, убивающей все желание писать тесты. Во время написания кода задавайте себе вопрос: удобно ли мне будет его тестировать?  Хорошие тесты нужны не только для поиска багов, но и имеют некоторые неочевидные положительные эффекты. Например, они выступают в роли документации к коду, способствуют переработке архитектуры в лучшую сторону и помогают избегать появления легаси. Чтобы начать писать качественные тесты, придется приложить немало усилий. Скорее всего, с первого раза не всё получится идеально, и код модуля потребует доработок во время написания тестов. Или тесты могут получиться не очень удобными в поддержке, и их придется отрефакторить в будущем. Придется много искать и применять новых подходов для повышения качества тестов. Но, поверьте, преимущества хороших тестов легко окупают усилия, потраченные на них! iOS developer