Привет! Меня зовут Андрей Максимкин. Я iOS-разработчик в hh.ru. В своих статьях (тут, тут и не только) мы уже не раз говорили о большой любви к тестам и их важной роли в нашем процессе разработки. В этой статье хочу поделиться опытом использования snapshot-тестов, начиная с этапа внедрения. Статья будет полезна для QA и iOS-разработчиков разных уровней. Обсудим не только теорию, но и напишем реальный snapshot-тест — как в нашей практике.
Вместо вступления
Сначала расскажем про snapshot-тесты и для чего они нужны. Snapshot тесты — это метод тестирования, который используется для защиты внешнего вида компонентов и интерфейсов от незапланированных изменений. Они позволяют зафиксировать эталонное состояние компонента и в дальнейшем использовать как образец. Это помогает выявлять изменения в пользовательском интерфейсе, которые могут возникнуть в результате обновлений кода.
В hh.ru мы активно разрабатываем нашу собственную дизайн-систему (ДС). Дело в том, что компоненты ДС могут иметь широкий набор свойств: цвета, размеры, скругления. Проверять корректность, например, цветов обычным ручным тестировщикам на длинной дистанции невозможно, поэтому возникла потребность автоматизировать проверки. Кроме того, при рефакторинге кода некоторые компоненты могут сломаться — хотелось бы находить такие проблемы сразу. Snapshot-тесты показались лучшим решением этих проблем.
Когда-то давно в hh.ru уже были snapshot-тесты. На тот момент они были написаны на экраны приложения. Эти экраны часто меняли свой дизайн, тесты вели себя нестабильно и падали, поэтому их приходилось переписывать. В итоге от snapshot-тестов отказались. Так что в своей новой попытке внедрения snapshot-тестов решили не писать тесты для экранов, а только для ДС.
Причин для внедрения snapshot-тестов две:
Компоненты ДС невозможно постоянно проверять вручную
Хочется сразу находить проблемы в ДС после рефакторинга
Ещё важно отметить, что компоненты ДС максимально стабильны, поэтому большого количества упавших тестов не ожидается, как это было с продуктовыми экранами.
Вопросы и проблемы
Итак, предпосылки понятны, желания ясны — приступаем к проработке и вопросам, которые возникли в результате.
Вопросов и проблем было немало:
Нужно выбрать библиотеку.
Понять, что и как будем покрывать.
-
Насколько snapshot-тесты флакуют?
От коммита к коммиту
Между машинами на CI
Как много времени занимает прогон?
Где хранить сами снапшоты?
Итак, цель — разобраться, нужно ли вообще внедрять snapshot-тесты в проект.
Выбор библиотеки
В iOS выбор фреймворков для снапшотов небольшой. Посмотрим на таблицу — здесь указаны самые популярные варианты, их плюсы и минусы.
Название |
swift-snapshot-testing |
playbook-ios |
snapshot-previews |
Ссылка |
|||
Плюсы |
Активно развивается Поддерживает SwiftUI, UIKit, UIImage, данные можно добавить свои Очень гибкая и лёгкая, можно подменить всё, что угодно Самая популярная, если судить по количеству звёзд |
Поддерживает и SwiftUI, и UIKit Бесплатно можно получить приложение-плейграунд Вдохновлён Storybook'ом, которым у нас пользуется Web |
Поддерживает и SwiftUI, и UIKit Работает на нативных превьюхах По смыслу совпадает с playbook-ios |
Минусы |
Нет прямой поддержки UITest'ов Транзитивная зависимость на Testing создаёт проблемы для tuist |
Практически не настраиваемая Плохо встраивается в наше приложение Снапшоты в ней — скорее побочная функция Не получится легко сделать перебор параметров |
Практически ненастраиваемая Живые превьюхи не очень хорошо работают у нас, но для тестов это, скорее всего, не важно Не получится легко сделать перебор параметров |
Вывод |
Берём эту |
Можно, но очень негибко |
Можно, но очень негибко |
Есть ещё две библиотеки, но их отсекли сразу же:
https://github.com/uber/ios-snapshot-test-case — очень древняя, с 2021 года не развивалась
https://github.com/ashfurrow/Nimble-Snapshots — Nimble обычно идёт в связке с Quick+Nimble. А Quick — это довольно специфичный интерфейс для написания тестов. Если бы мы и остальные тесты на Quick писали, можно было бы взять, но переучивать людей ради снапшотов не имеет смысла.
Итог: будем использовать swift-snapshot-testing.
Можно ли писать snapshot-тесты в рамках нашей ДС
Первый вопрос: можно ли вообще писать snapshot-тесты для нашей ДС? Попробуем разобраться. Для первой пробы были выбраны 5 компонентов, каждый по своей причине:
Text— один из самых проблемных с точки зрения вёрстки, так как рендеринг шрифтов и переносыButton— самое большое количество вариацийInput— хороший пример сплава SwiftUI и UIKitChips— по сути CollectionViewSelect— компонент-экран
Идея: выбрать максимально непохожие друг на друга компоненты, каждый из которых имеет свои особенности. Затем посмотреть, если на этих компонентах snapshot-тесты будут работать, то и на других, более простых, тоже должно быть всё хорошо. Кроме того, у нас была сложность в написании snapshot-тестов для компонента Select, так как он является экраном, а не View. Для View snapshot-тесты пишутся как обычные unit-тесты. Если же компонентом является экран, то необходимо писать уже UI-тест. У UI-тестов есть особенности:
Библиотека swift-snapshot-testing не работает напрямую с UI-тестами, поэтому cнапшоты снимаются вспомогательными расширениями (подробнее ниже)
Желательно открывать экран технической диплинкой, чтобы не тратить время и ресурсы на открытие необходимого экрана — для этого может потребоваться открытие других экранов
Нужно отключать анимации
Не стоит делать снапшоты полного экрана — статус-бар меняется и добавлять флакования
В итоге стало понятно: у нас есть все возможности для покрытия этих компонентов ДС.
Насколько флакуют snapshot-тесты?
Теперь нужно понять, насколько snapshot-тесты стабильны при прогоне на разных коммитах. Сравнения проводились в рамках одной нашей CI-машины, всего машины было четыре.
Был придуман такой план:
Выбрать 20 коммитов из репозитория для проверки (коммит из ветки с фичой в ветку разработки develop), разница по времени между соседними коммитами — 2-3 дня
Написать snapshot-тесты для первого коммита
Получить снапшоты и сравнить со снапшотами из второго коммита
Сравнить снапшоты между вторым и третьим коммитами
Продолжать сравнение до 20 коммита
В таблице столбец 2 — это коммит 2, в котором снапшоты сравнивались с коммитом 1. Если ячейки жёлтые — тесты прошли, красные — тесты упали.

В таблице видны красные ячейки: snapshot-тесты упали при проверке коммитов с номерами 3 и 4. Оказалось, что так и должно быть, так как были правки этих компонентов ДС. На картинке ниже можно заметить, что мы поменяли иконку слева на иконку справа, а посередине дифф.

В остальных местах ситуация аналогичная: snapshot-тесты упали там, где и должны были упасть.
Выходит, что от коммита к коммиту флакования нет — все изменения снапшотов были связаны с изменениями в ДС.
А теперь проверим стабильность snapshot-тестов при прогоне на разных CI-машинах: берём один коммит, генерируем на каждой машине снапшоты и сравниваем их между собой.

Красным выделены ячейки, в которых snapshot-тесты упали, — то есть снапшоты, сгенерированные на разных машинах, но на одном и том же коммите, отличаются. Проблема оказалась только у mac_5 с процессором Intel. Обидно, но ладно. В дальнейшем будем запускать snapshot-тесты только на ARM’ах.
Как много времени длится прогон?
Теперь пришло время вспомнить про наш CI и прогоны уже существующих тестов на нём. Хотелось понять, насколько дольше будут выполняться прогоны, если к ним добавить snapshot-тесты. Точно посчитать было сложно, поэтому прикинули очень и очень примерно.
Для оценки времени прогона были собраны junit-отчёты. Но время в них — время выполнения самого тела теста, а значительную часть прогона составляет подготовка репозитория, сборки и т.д. Поэтому прикинули из нескольких чисел:
По данным junit отчётов — можно ожидать, что, умножив среднее время прогона на количество компонентов, получим примерно 5-8 минут при полном покрытии
На основе длительности работы скрипта на CI — длительность одного прогона порядка 6-10 минут
Поэтому при полном покрытии ожидаем порядок — десяток минут. Такое замедление не выглядит критичным.
Где хранить снапшоты?
Ещё один важный момент — место хранения снапшотов (изображений).
Вариантов несколько:
В самом репозитории
Плюсы:
Легко настроить и использовать
Все изменения отслеживаются Git, поэтому легко возвращаться к предыдущим версиям
Минусы:
Снапшоты могут сильно увеличить размер репозитория, как следствие — проблемы со скоростью работы Git (например, операции clone, fetch, push)
Снапшоты в гите хранятся в виде бинарных файлов, поэтому при любом изменении снапшота добавляется 100% его размера к размеру репозитория
В репозитории через LFS
Плюсы:
Git LFS предназначен для хранения больших бинарных файлов, что позволяет избежать проблем с производительностью и размером репозитория
Большие файлы хранятся отдельно от основного репозитория
Минусы:
Требуется дополнительная настройка CI/CD, а также возможные проблемы в интеграции с другими системами
Требуется установка и настройка Git LFS для пользователей
У нас нет больших бинарных файлов, а есть много маленьких. Непонятно, как использовать этот механизм эффективно
Во внешнем хранилище
Тут рассматривали разные виды внешнего хранилища — от облачных сервисов (например, Amazon S3), до отдельного репозитория.
Плюсы:
Размер репозитория не увеличивается, следовательно, не теряем в скорости работы с Git
Минусы:
Требуется дополнительная достаточно сложная настройка CI/CD
Становимся зависимыми от внешних сервисов
По итогу было принято решение хранить снапшоты в самом репозитории — самое простое с точки зрения реализации решение. По нашим подсчётам, репозиторий может увеличиться на 20-25 МБ в год.
Стоит ли затаскивать snapshot-тестирование?
По итогам исследования snapshot-тесты показались вполне надёжным и приемлемым инструментом:
Выбранная библиотека удобна в использовании и расширяема под наши нужды
Технически можно покрыть и компоненты, и экраны
Необоснованное флакование попадалось только на Intel'ах, в остальном падающие тесты обоснованы
Время прогона — в пределах 10 минут
Отсутствие флакования и маленький вес снапшотов приемлем для хранения их в гите. Деградация репы от них тоже выглядит вполне терпимой
Итог: да, будем внедрять в проект.

Итак, прошло примерно полгода с момента внедрения snapshot-тестов. Какие впечатления?
Плюсы:
Snapshot-тесты работают: мы неоднократно находили ошибки, особенно при рефакторинге компонентов ДС
Техническая диплинка для открытия экранов действительно работает, не нужно прыгать по другим экранам, чтобы дойти до нужного. Кроме того, через диплинку можно передавать нужные параметры для корректной настройки экрана
Удобно и быстро писать тесты: мы разработали небольшие хелперы, о которых расскажу ниже
Как дополнительный бонус: удобно вернуться по коммитам в гите в историю и посмотреть, как компоненты выглядели раньше. Бывает полезно, например, при обсуждении новых доработок
Минусов почти не выявлено, но есть идеи для улучшения:
Генерировать несколько состояний в одной картинке, чтобы сократить количество снапшотов
Выносить снапшоты в отдельный репозиторий, либо использовать git submodule. За полгода в нашем репозитории накопилось примерно 25 МБ картинок. Если посчитать прибавку к размеру репозитория — она будет кратно больше
Добавить удобные методы для подрезки картинок
Пишем snapshot-тест
До этого мы рассматривали много теоретических моментов, но практики не было. Давайте попробуем на практике написать snapshot-тест, который по смыслу и стилистике будет очень похож на те, что пишутся в hh.ru. Скачать пример проекта можно по ссылке https://github.com/AndreyMaksimkin/Snapshots.
Здесь через SPM подтянули библиотеку swift-snapshot-testing, но не оригинал, а fork https://github.com/Shedward/swift-snapshot-testing, так как оригинальный вариант зависел от Testing и мешал использовать библиотеку для написания UITest’ов.
Для начала создадим простой компонент, на который затем напишем snapshot-тест. Назовём компонент CustomButton. Тело его выглядит примерно так:
public struct CustomButton: View {
public enum CornerRadius: CGFloat, CaseIterable {
case s = 8.0
case m = 12.0
}
public enum FontType: CaseIterable {
case title
case subtitle
var value: Font {
switch self {
case .title:
.headline
case .subtitle:
.title2
}
}
}
public var fontType: FontType
public var cornerRadius: CornerRadius
public var body: some View {
Button(action: {
print("did tap button")
}) {
Text("Press")
.font(fontType.value)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(cornerRadius.rawValue)
}
}
}
Для удобства написания тестов используем несколько вспомогательных методов, которые хранятся в папке Snapshots.

Теперь мы готовы написать snapshot-тест. Будет он выглядеть примерно так:
@MainActor
final class CustomButtonTests: XCTestCase {
func testLayout() {
let button = CustomButton(fontType: .title, cornerRadius: .m)
assertSnapshots(
of: button,
configurationSet: SnapshotConfigurationSet()
.layout(.fixed(320, 240)) //выставляем размер кнопки
.all(\.fontType) //пробегаемся по всем значеним fontType
.all(\.cornerRadius, in: ["s": .s, "m": .m]) //пробегаемся по всем значениям cornerRadius из списка
)
}
}
Давайте пройдёмся по коду и разберём, что же тут происходит. Сначала мы создаём кнопку:
let button = CustomButton(fontType: .title, cornerRadius: .m)
По сути, неважно, с каким свойствами её создать — важно получить экземпляр. Дальше используется самописная структура SnapshotConfigurationSet, с помощью которой указываем возможные значения свойств кнопки, а также генерируем названия снапшотов.
В нашем случае выбираем фиксированный размер:
.layout(.fixed(320, 240))
Затем используем метод .all, который сильно помогает сократить код, позволяя пробежаться сразу по всем значениям свойства (либо по заданным значениям). Каждая строка умножает текущее количество снапшотов на количество значений свойства. В нашем случае будет так:
.layout(.fixed(320, 240)) // 1 снапшот
.all(\.fontType) // *2
.all(\.cornerRadius, in: ["s": .s, "m": .m]) // *2
Итого: получаем 1*2*2 = 4 снапшота.
C кодом разобрались, давайте попробуем запустить сам тест:

Получаем ошибку. Проблема в том, что при самом первом запуске мы ещё не сгенерировали снапшоты, поэтому тест падает. Но если посмотреть на дерево объектов, то можно заметить, что снапшоты уже созданы:

В самих снапшотах отрисованы состояния кнопки. Например, снапшот testLayout.layout-320x240-CustomButton-fontType-subtitle-CustomButton-cornerRadius-m будет выглядеть так:

Запустим ещё раз:

Теперь всё отлично. Далее попробуем приблизиться к более жизненным ситуациям — проверим, как будут работать snapshot-тесты, если картинка немного поменяется.
Заменим значение s в enum CornerRadius
public enum CornerRadius: CGFloat, CaseIterable {
case s = 10.0 //было 8.0
case m = 12.0
}
И запустим тест заново:

Ожидаемо тест упал. Осталось разобраться, что пошло не так. Сделать это довольно просто: по тексту ошибки видна директория для нашей эталонной картинки и картинки поломанной. Дальше уже дело техники — смотрим визуально и правим.
Ещё хотелось бы обратить внимание на файл XCUIScreenshotProviding+Asserts — здесь мы добавили метод для написания UI-тестов для компонентов-экранов. Тогда UI-тест будет выглядеть примерно так:
import SnapshotTesting
import XCTest
final class SelectTestSuite: XCTestCase {
public let application = XCUIApplication()
private lazy var deeplinkApp = serviceFactory.deeplinkAppService()
override func setUp() {
super.setUp()
continueAfterFailure = true
}
func test_Component() throws {
application.launch()
deeplinkApp.openDeeplinkDirectly(url: "hhios://custom_deeplink/select")
// Конструируем Object
pageObjectsFactory
.makeSelectPageObject()
.tapOpenSelect()
.assertBlockContentSnapshot(name: "Изначальное состояние")
.tapSelectItem(at: 0)
.assertBlockContentSnapshot(name: "Выбран первый элемент")
}
}
Заключение
Итог эксперимента однозначен: внедрение snapshot-тестов полностью оправдало себя, этот инструмент стал нашим важным союзником в поддержании визуальной целостности дизайн-системы hh.ru. Мы экономим время на ручных проверках, мгновенно ловим баги регрессии после рефакторинга, а благодаря продуманным хелперам процесс написания тестов не отнимает много сил. Snapshot-тестирование доказало свою практическую ценность, и мы планируем расширять его покрытие. Советуем и вам оценить пользу снапшотов в своем проекте.
viordash
У вас в последнее время сайт как-то странно работает, фризится на переходах. Иногда некоторые элементы появляются позже, что сдвигает вертикальную позицию, и бывает мисклик.
McDee Автор
Спасибо, мы посмотрим.