Привет, Хабр!
Меня зовут Никита. Я iOS Teamlead в Московском кредитном банке.

В этой статье расскажу про то, как мы пришли к snapshot-тестам и теперь их используем на своем проекте. 
Статья будет полезна как для iOS-разработчиков, так и для iOS-автоматизаторов. 

Здесь мы разберем:

  1. Что такое, как работает и для чего нужно snapshot-тестирование

  2. Какие цели мы преследовали

  3. Как внедрить snapshot-тестирование к себе в проект

Начнем сначала немного с теории для нахождения ответов на вопросы: для чего это нам нужно и как можно использовать snapshot-тестирование. 

Что такое snapshot-тестирование 

С помощью snaphot-тестирования мы можем узнать изменился ли в целом наш интерфейс в процессе изменения кода существующего функционала за счет эталонного изображения (снапшота), которое было сформировано с помощью snapshot-тестов. Путем сравнения эталонного и полученного снапшота. Быстрый способ понять, что изменения в коде не повлекли к изменениям UI-интерфейса.

Snapshot-тесты формируют эталонное изображение экрана и сравнивают полученное изображение с эталонным, если не новый функционал. Забегу немного вперед: мы не стали писать snapshot-тесты на все подряд, а только на Success-ответ запроса (если имеется) для проверки верстки экрана и общих UI-компонентов. 

Как работает snapshot-тестирование 

Мы тестируем UI-интерфейс и хотим убедиться, что он остается неизменным. Начинаем snapshot-тестирование с выбора UI-интерфейса в определенном состоянии для формирования эталона. Во время последующих прогонов snapshot-тестов текущий UI-интерфейс сравнивается с этим сохраненным эталоном.

Если они совпадут, то значит изменений нет и все хорошо. Но если snapshot-тест завершится неудачей, то есть возникают расхождения, указывающие неожиданное изменение UI-интерфейса. Данный метод эффективен для поддержки визуальной целостности и выявления проблем с UI-интерфейсом, обеспечивающих безупречный пользовательский опыт.

Преимущества snapshot-тестирования

  • Экономит время разработчиков за счет простых тестов, при этом без особого поддерживая их в актуальном состоянии;

  • Улучшает отслеживание изменений, сокращает количество ошибок и обеспечивает стабильность приложения:

  • Обеспечивает целостность кода и как следствие - эффективную поставку качественного продукта;

  • Легко настроить и внедрить;

  • Обнаруживает непреднамеренные визуальные изменения.

Для себя лично нашел много преимуществ: автоматизация review на различных устройствах; новый вид тестов; уменьшение количества визуальных ошибок; скорость прохождение snapshot тестов, но все перечислять не буду, так как думаю, что каждый найдет для себя те или иные преимущества у snapshot-тестирования. Свои минусы само собой есть, к примеру: не стабильность из за разных процессоров (intel и m1), нельзя проверить анимацию.

Какие цели преследовали

  • Уменьшение ошибок, связанных с версткой общими компонентами;

  • Ускорение проведение code review на визуальное качество;

  • Проверка визуального отображение на нескольких устройствах с разным расширением дисплея.

Бизнес-ценность 

  • Время исполнения и разработки практически аналогично Unit-тестированию;

  • Не затрагивает основную кодовую базу проекта;

  • Разрабатывает сам разработчик;

  • Наилучший способ автоматизированной защиты/проверки/контроля целостности экранов пользовательского интерфейса в соотношении «цена/качество».

Какие есть библиотеки

При поиске решения нашел всего 2 библиотеки для закрытия задачи:

Сводная таблица:


SnapshotTesting

iOSSnapshotTestCase

Язык реализации 

Swift

Objective-C

Diff-скриншоты 

Есть

Есть

Гибкая настройка погрешности совпадения скриншотов

1 параметр

2 параметра

Поддержка ОS

iOS, macOS, visionOS, tvOS. watchOS, Linux 

iOS (возможно, что есть еще OS, но информации не нашел)

Поддерживает менеджеры зависимостей

CocoaPods, Carthage, Swift Package Manager (Последнии версии поддерживают только SPM)

CocoaPods, Carthage, Swift Package Manager

Скриншоты любого UI-компонента

Есть

Нужно реализовывать самому

Последняя дата обновления

13 октября 2023

22 октября 2021

Возможно, что библиотек больше, но все нужные потребности закрыла библиотека – SnapshotTesting.

Как внедрить snapshot-тесты

Перед тем как начать реализовывать - нам потребуется:

  • Добавить target-тесты в проект;

  • Выбрать один из вариантов установки библиотеки SnapshotTesting.

Реализация

На момент внедрения snapshot-тестов мы имели на проекте архитектуру VIPER и для unit- тестов готовый механизм по мокированию запросов (с помощью макросов, разветвления кода и наличия локальных файлов, имитирующих ответ на запрос), который нам очень сильно помог ускорить скорость написания snapshot-тестов. Однако наличие этих пунктов необязательно – у каждого своего проекта есть свой подход.  

После того как выполнили все шаги из пункта «Как внедрить snapshot-тесты», нам требуется импортировать подключенную библиотеку для получения доступа к ней - import SnapshotTesting.

Дальше нам требуется реализовать snapshot-тест:

  1. Создаем метод теста, например, func testSnapshotIphoneXr(). Мы выбрали для себя 3 устройства (iPhone Xr, iPhone Se, iPhone 8), но библиотека позволяет тестировать на большом количестве.

  2. Дальше нам нужно сконфигурировать наш экран и получишь его ViewController, у нас это метод screenConfigutation().
    Обязательным условием для snapshot-тестов является запуск жизненного цикла ViewController – у нас это presenter.present(from: UIViewController()), который презентует нам экран. 

    private func screenConfiguration() -> UIViewController {
      let view: CalculatorViewInput = CalculatorViewController.create()
      view.output = presenter
      presenter.view = view
      presenter.present(from: UIViewController()) 
      return view.viewController
    }
  3. Теперь вызовем метод для формирования эталонного изображения (метод же отвечает и за валидацию в дальнейшем) у SnapshotTesting – assertSnapshots.

На выходе получаем:

func testSnapshotIphone8() {
  let vc = screenConfiguration()
  assertSnapshots(matching: vc, 
                  as: [.image(on: iPhone8, precision: 1)],
                  record: false, 
                  testName: “iPhone 8 @\(Int(UIScreen.main.scale))x”)
}

func testSnapshotIphoneSe() {
  let vc = screenConfiguration()
  assertSnapshots(matching: vc,
                  as: [.image(on: iPhoneSe, precision: 1)],
                  record: false,
                  testName: “iPhone Se @\(Int(UIScreen.main.scale))x”)
}

func testSnapshotIphoneXr() {
  let vc = screenConfiguration()
  assertSnapshots(matching: vc,
                  as: [.image(on: iPhoneXr, precision: 1)],
                  record: false,
                  testName: “iPhone Xr @\(Int(UIScreen.main.scale))x”)
}

Запускаем наш тест, при первом запуске он завершится fail, так как у нас формируется эталонное изображение. Запускаем повторно, теперь success. Сформированные снапшоты можно посмотреть по пути нашего теста в проекте в папке __Snapshots__.

Параметры

Теперь поговорим о параметрах assertSnapshots из примеров:

  1. Matching – сюда мы передаем наш ViewController для того, чтобы assertSnapshots понимал, у какого именно экрана нужно сформировать/провалидировать снапшоты;

  2. As – массив, в который мы кладем «действие которое нам нужно совершить», а в нашем случае это либо image (отвечает за формирование снапшота всего экрана), либо wait (формирование снапшота с заданной задержкой если имеется запрос), либо нужно подождать какого - то определенного действия:

    1. Image: on - на каком устройстве требуется сформировать/провалидировать снапшот и precision – процент совпадения. В данном случае 1 это 100% совпадение – идеальное значение. Также вместо конкретного устройства можно задать свой size устройства и установить safeArea.

      func testSnapshotIphone8() {
        let vc = screenConfiguration()
        assertSnapshots(matching: vc,
                        as: [.image(on: .init(safeArea: .init(top: 20, left: 0, bottom: 0, right: 0),
                                        size: .init(width: 375, height: 1000), traits: .init()), precision: 1)],
                        record: false,
                        testName: “iPhone 8 @(Int(UIScreen.main.scale))x”)
      }
    2. Wait: for – сколько по времени нам нужна задержка, on – что мы хотим после задержки – в нашем случае сформировать снапшот всего экрана.

    func testSnapshotIphone8() {
      let vc = screenConfiguration()
      assertSnapshots(matching: vc,
                      as: [.image(on: iPhone8, precision: 1),
                           .wait(for: 0,1, on: .image(on: iPhone8, precision: 1))],
                      record: false,
                      testName: “iPhone 8 @(Int(UIScreen.main.scale))x”)
    }

    Почему в параметре as 2 элемента, рассмотрим вариант с запросом – 1 отвечает за момент загрузки экрана, а 2 за уже отображения отрисованного экрана после получения ответа на запрос. И для примера покажу, как изменился screenConfiguration(). Мы добавляем собственный механизм мокирования запросов, который нам возьмет json файл из проекта.

    На выходе получаем такой результат:

    private func screenConfiguration() -> UIViewController {
      let view: CalculatorViewInput = CalculatorViewController.create()
      view.output = presenter
      presenter.view = view
      UnitTestManager.sharedInstance.nameTest = “testSnapshot”
      UnitTestManager.sharedInstance.startSession() 
      presenter.present(from: UIViewController()) 
      return view.viewController
    }
  3. Record - флаг, с помощью которого можем решить нужно переписать существующие снапшоты или нет в рамках нашего теста, но также можем использовать глобальную переменную isRecording.

  4. TestName - имя нашего снапшота.

Все параметры не буду рассматривать, так как их достаточно большое количество, с ним можно ознакомиться на SnapshotTesting.

Результаты

Теперь разберем ситуацию, когда у нас тест завершился fail при каких-либо изменениях на экране. У нас после прогона теста сформируются 3 снапшота: reference, failure и difference. 

Reference
Reference
Failure
Failure
Difference
Difference


На примере можем увидеть, что отступ изменился. О чем нам говорит difference-снапшот.
Для нас это означает, что был затронут общий компонент интерфейса или изменился интерфейс, и это не ошибка. В данном случае 1-й вариант.


И еще один пример для большего понимания:

Reference
Reference
Failure
Failure
Difference
Difference

Итог

Мы узнали, что такое snapshot-тестирование, как внедрить к себе в проект и, конечно, писать snaphot-тесты. Цели который были поставлены, мы закрыли: с помощью snapshot-тестов уменьшили количество ошибок, связанных с версткой, ускорили процесс code review и автоматизировали проверку отображения на нескольких устройствах с разным расширением дисплея. Можно пойти дальше и внедрить данный механизм в CI/CD в разрез pipelines из-за хорошего времени прохождения, у нас прохождение каждого snapshot-теста занимает в среднем меньше 1 секунды.

Из подводных камней заметил, что если запускать snapshot-тесты на разных симуляторах, отличные от симулятора, на котором были сформированы эталонные снапшоты, то тесты будут fail.

Надеюсь, что статья оказалась для вас полезной, спасибо за внимание. Если у вас был или вы хотите поделиться своим опытом в snapshot-тестировании, напишите, пожалуйста, в комментариях, а я постараюсь ответить.

Комментарии (2)


  1. Adnako
    29.12.2023 03:42

    Спасибо за вводную часть.

    Ждём продолжения про:

    • градиенты, тени, скругления, шрифты, емоджи;

    • векторные pdf в ассетах;

    • загрузку картинок из сети;

    • скорость и стабильность работы всей конструкции;

    • точность сравнения с эталоном;

    • особенности жизненного цикла UIVC, например, ожидание окончания анимаций;

    • реальную необходимость использования снапшот-тестов для проверки отображения данных.


    1. ArsenalMagus Автор
      29.12.2023 03:42

      Спасибо за обратную связь
      Обдумаю Ваши предложения