Привет, Хабр! Меня зовут Дима, я iOS-разработчик в компании Doubletapp, и в прошлом году я вместе со своими коллегами и командой Яндекса участвовал в разработке приложения Яндекс Путешествия. В этом проекте мы выбрали фреймворком пользовательского интерфейса SwiftUI (подробнее о том, как мы его выбрали и что из этого получилось, рассказала наша iOS-Head Полина Скалкина здесь). На начальном этапе реализации приложения мы постоянно вносили изменения во множество вью. Это были и обновления, вызванные переработкой дизайна, и исправление багов, и оптимизации, которые мы делали по мере роста наших знаний о SwiftUI. Нам хотелось контролировать все эти изменения, чтобы еще на этапе разработки отлавливать вызванные ими ошибки верстки. Поэтому наша команда приняла решение использовать snapshot-тесты.   Это вид тестов, сравнивающий некоторое представление объекта с эталонным представлением. В нашем случае объект тестирования — это вью, а представление — скриншот вью. Алгоритм простой: сначала создаётся скриншот-эталон и записывается на диск, после чего при очередном запуске тестов генерируется новый скриншот и сравнивается с сохранённым. Если скриншоты отличаются или эталон не найден, то тест не пройдёт. Тесты такого типа дают нам возможность зафиксировать, как выглядит вью при разных состояниях модели (загрузка, данные, ошибка, авторизован/не авторизован и т.д.), цветовых схемах, размерах экрана. Множество вариаций snapshot’ов позволяет достичь необходимого нам контроля над изменениями вью. Скриншоты можно хранить в репозитории вместе с кодом приложения. Тогда в pull request будет попадать не только код вью, но и её snapshot’ы. Благодаря этому ревьюер может посмотреть, как выглядит вью в разных конфигурациях. Кроме этого, в GitHub, GitLab и других сервисах есть встроенные инструменты сравнения картинок, которые помогут проверить изменения в скриншотах. Для snapshot-тестирования мы выбрали библиотеку SnapshotTesting. Тесты в ней аналогичны привычным unit-тестам: Функция assertSnapshot работает по алгоритму, описанному выше: делает скриншот переданной вью и сравнивает его со скриншотом на диске. Если эталон на диске не найден, сохраняет только что созданный скриншот, и тест падает. Нужно иметь в виду, что библиотека использует симулятор для создания скриншотов. На разных симуляторах и при разных настройках локали симулятора SnapshotTesting будет генерировать разные скриншоты. Поэтому команде нужно будет договориться, какой симулятор использовать. Также может быть разница в скриншотах, генерируемых компьютерами на процессорах Intel и Apple Silicon. Это известная проблема, и пока что полностью её исправить не удаётся. Мы обходили это уменьшением параметров precision и perceptualPrecision, отвечающих за проверяемую степень сходства изображений. Если вам известны другие способы, буду рад узнать о них из комментариев. После начала внедрения snapshot-тестов мы заметили, что они очень похожи на SwiftUI Previews. То есть достаточно написать необходимый инфраструктурный код, и при добавлении новых превью можно практически бесплатно получить snapshot-тесты для вью. Нашу реализацию этой инфраструктуры можно разделить на две части: превью и snapshot-тесты. Соединяет эти части протокол Testable. Его роль — предоставить набор вариаций вью при разных состояниях модели (массив samples). Для этого определён associatedtype Sample, позволяющий минимизировать использование AnyView. Кроме того, в определении протокола можно заметить associatedtype Modifier, но о нём расскажу чуть позже. Если говорить про часть превью, то протокол Testable реализуется вместе с PreviewProvider и в некоторой степени заменяет его, поэтому для их связки задано следующее расширение: Вот как выглядит определение превью в самом простом случае на примере экрана списка отелей: Вернёмся к associatedtype Modifier в протоколе Testable. Это модификатор, который применяется ко всем вью из массива samples. PreviewModifier, является расширением SwiftUI-протокола ViewModifier и определяет возможность инстанциировать его реализацию без знания её типа. Modifier используются в случаях, когда нужно дополнительно настроить вью перед ее показом в превью и snapshot-тестах. Например, цвет фона экрана по умолчанию совпадает с фоном клетки отеля, и нам нужно его поменять, чтобы видеть границы вью. С помощью Modifier изменим цвет фона экрана: Если же ничего модифицировать не нужно, то используется IdentityPreviewModifier, который оставляет вью без изменений (используется по умолчанию): На этом с частью превью всё, перейдем к части snapshot-тестов. Её две главные задачи — это интеграция с библиотекой SnapshotTesting и уменьшение дублирования кода при написании тестов.  Как было сказано ранее, каждый скриншот — это вью при заданном состоянии модели, размере и цветовой схеме. Различные состояния модели уже хранятся в массиве samples (протокол Testable), поэтому мы определили отдельную структуру SnapshotEnvironment для хранения размера (layout) и цветовой схемы (traits): Поле descriptionComponents нужно при формировании имени скриншота. Далее будет показано, как оно создается и используется. Чтобы зафиксировать размеры экранов устройств, которые интересны нам для тестирования, и поддерживаемые цветовые схемы, определены перечисления Device и Theme: Эти перечисления, в свою очередь, используются в фабричных методах тестовых конфигураций: В зависимости от вью, может потребоваться определённый набор тестовых конфигураций (размер + цветовая схема). Используемые у нас наборы заданы кейсами перечисления SnapshotBatch: И, наконец, весь ранее описанный код используется в методе test, выполняющем snapshot-тестирование элементов массива samples протокола Testable. Имя файла скриншота автоматически формируется из названия тестовой функции, которое подставляется в параметр testName, и поля descriptionComponents структуры SnapshotEnvironment, которое я показывал ранее. Инфраструктура готова! Теперь для полного тестирования вью достаточно вызвать метод test и передать в него нужный SnapshotBatch. Например, в случае HotelListView snapshot-тестирование будет выглядеть так: В результате выполнения теста будут сгенерированы скриншоты HotelListView-iPhone13-light.1.png HotelListView-iPhone13-dark.1.png HotelListView-iPhone8-light.1.png HotelListView-iPhoneSe-light.1.png Цифра после точки в имени файла добавляется библиотекой автоматически и в нашем случае указывает на номер элемента в массиве samples. Применение snapshot-тестов удовлетворило нашу потребность в контроле над изменениями, которые постоянно вносились во вью. Кроме того, хранящиеся в репозитории с кодом скриншоты экранов сделали процесс ревью UI-компонентов гораздо более удобным. Благодаря этому мы своевременно отлавливали ошибки верстки еще до вливания изменений в основную ветку проекта.   Созданная вокруг библиотеки SnapshotTesting инфраструктура сделала написание snapshot-тестов легким и быстрым. Решение получилось достаточно гибким, так что с небольшими доработками его можно использовать и на других проектах. Узнать подробнее о долгосрочном опыте применения snapshot-тестов и результатах использования описанного инструмента в Яндекс Путешествиях можно на докладе от автора идеи, Николая Пучко, на Vertis Mobile Meetup 13 октября.