Внедряем снепшот-тестирование, или пять стадий принятия неизбежного

Привет, Хабр! Меня зовут Дмитрий Сурков, я iOS-разработчик приложения для среднего и малого бизнеса ПСБ. У нас есть практика проводить технические дни, на которых мы вносим улучшения в наше приложение. Одним из таких улучшений оказалось внедрение снепшот-тестов для компонентов дизайн-системы. В статье расскажу, как мы прошли все стадии принятия и обрели покой и умиротворение.

Я постарался собрать полный путь от поиска информации и анализа до внедрения снепшот-тестов в проект и дальнейших улучшений. 

Классические стадии принятия отлично подходят для описания пройденного процесса:

  1. Отрицание

  2. Гнев

  3. Торг

  4. Депрессия

  5. Принятие

Итак, начнем.

8dc6474fcbceb01d2dcbee0c084ec0d9.png

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

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

Для начала необходимо понять, какие цели преследует снепшот-тестирование, что это такое и каковы основные практики его применения. 

Что такое снепшот-тестирование?

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

Snapshot-тестирование

Snapshot-тесты — это тесты, которые делают скриншот экрана (эталонное изображение) и сравнивают с актуальным скриншотом, который делается во время прогона тестов.

Пирамида тестирования

Пирамида тестирования — один из способов обеспечения качества ПО, визуализация, которая помогает группировать тесты по типу их назначения. Так же, позволяет согласовать правила написания тестов, разделения их на типы, обозначить основной фокус тестирования в каждой из групп.

faff4663a7d01da41caf0d90a5d24383.png

Цель проведения snapshot-тестирования

Основные цели — повышение качества разработки, уменьшение количества ошибок верстки при создании и рефакторинге компонентов, а также валидация внешнего вида для пользователя.

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

Поэтому мы выбрали второй путь, в основном за счет сокращения рабочего времени на подготовку изображения дизайнером (формат, размеры, состояния), а также подгонки моделей для тестов под эти изображения.

После сравнения можно сразу убедиться в корректном результате разработки компонента или своевременно выявить ошибку и исправить ее. Также при рефакторинге или неявном изменении UI-компонента будет ясно, что изменения привели к ошибке, если они отличаются от эталонного изображения.

Основные инструменты 

Существует две библиотеки для снепшот-тестирования:

Также можно создать свою нативную реализацию без использования сторонних библиотек.

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

Решение

Native

SnapshotTesting

iOSSnapshotTestCase

Язык

Swift

Swift

Objective-C

Актуальность

~

Release 1.17.4

8 августа 2024

Release 8.0.0

22 октября 2021

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

Все

Все

CocoaPods, Carthage

Diff скриншоты

Реализовывать вручную

Есть

Есть

Поддержка любого UI елемента

Реализовывать вручную

Есть. Можно реализовать кастомную стратегию проверки

UIView, CALayer

Стратегии проверки

Реализовывать вручную

Image, recursiveDescription, plist, dump, hierarchy и тд.

Image

Установка погрешности

Реализовывать вручную

Есть

Есть

Поддержка OS/Device model

Реализовывать вручную

Есть

Есть

Сравнительная таблица решений для снепшот-тестирования

Немного цифр

Для понимания производительности и цифр, которых стоит ожидать в теории, мы провели тестирование и сгенерировали снепшоты в разных количествах. В основе лежит вью с размером 375×90pt, которая соответствует полной ширине экрана и высоте двух средних ячеек. Сам же прогон сделали на Xcode 13 версии. Также для теста добавили вариант с генерацией HIEC изображений на случай, если понадобится экономить размер снепшотов.

Скорость генерации снепшотов 375×90pt

  • 5000 изображений ~ 150 МБ и 30 минут

  • 2000 изображений ~ 65 МБ и 6 минут

  • 1000 изображений ~ 30 МБ и 2 минуты

  • 1000 изображений HIEC ~ 24 МБ и 6 минуты

Скорость тестирования снепшотов 375×90pt

  • 5000 изображений ~ 6 минут с успешным прохождением / 10 минут с полным провалом

  • 2000 изображений ~ 2–3 минуты для обоих вариантов прохождения

  • 1000 изображений ~ 2 минуты для обоих вариантов прохождения

  • 1000 изображений HIEC ~ 2 минуты с успешным прохождением / 6 минут с полным провалом

Продемонстрировав процесс написания снепшот-тестов коллегам и перечислив все их преимущества, мы можем приступать к внедрению в наш проект.

3833d7dfc3215987760af43dd2727412.png

В нашем распоряжении все карты: проект с компонентами, библиотека для тестирования и свободные часы для реализации. А вот и первые сложности!

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

В зависимости от целей тестирования существует несколько вариантов хранения:

  • Папка проекта — все эталонные изображения находятся в одном месте с тестами. Минус данного решения — постоянно растущий объем изображений, поэтому репозиторий может достигнуть лимитов, если у вас крупное приложение или очень динамичная дизайн-система. Зато довольно легко интегрировать хранение эталонных изображений и сами тесты, а также можно упростить процесс слияния ветки в мастер.

  • Выделенный репозиторий — весь объем можно оставить под хранение изображений и иметь чистую и наглядную историю, но, опять же, есть риск переполнения в случае большого количества тестов и постоянных изменений в дизайне.

  • GitLab LFS или внешняя база данных — наиболее подходящий вариант на первый взгляд, так как объем хранилища может быть неограничен, но сложность интеграции может быть выше и вносит свои ограничения.

Поискав информацию о том, кто где хранит снепшоты, стало понятно, что всё зависит от проекта и количества тестов, единого универсального решения нет, и нужно решать самим. Прикинув все за и против, мы решили хранить в выделенном репозитории с возможностью в дальнейшем переехать на банковское хранилище.

Реализовали отдельный репозиторий с SPM-пакетом, в котором эталонные изображения хранятся в ресурсах пакета для оптимизации файлов изображений.

Далее приступаем к внедрению.

От создания компонента до тестов

После создания компонента в проекте дизайн-системы и написания юнит-тестов необходимо написать снепшот-тесты для генерации эталонных изображений с различными состояниями компонента.

К примеру, мы создали компонент 'ChatDateTableHeaderView', у него есть три состояния: число, месяц и год / число и месяц / сегодня. Этот компонент мы добавляем на нужный нам экран в демопроекте с нужными состояниями и возможностью их посмотреть. Теперь можем написать снепшот-тесты на данные состояния. 

Cкриншот из демо проекта с нужными состояниями компонента.

Cкриншот из демо проекта с нужными состояниями компонента.

Создание снепшот-теста на компонент

Снепшот-тесты добавлены как новый тестовый таргет в проект дизайн-системы для отделения его от юнит-тестов и отдельного прогона на CI.

Также необходимо выставить тестовый девайс. У нас принят за стандарт iPhone 11 Pro, на CI проверяется на таком же девайсе. Если выставить другой, при прогоне тестов они будут падать, поэтому оставляем iPhone 11 Pro.

Добавляем папку для снепшот-теста и создаем файл с приставкой SnapshotTests, для компонента 'ChatDateTableHeaderView' будет 'ChatDateTableHeaderViewSnapshotTests'. Нам останется только сделать импорт проекта дизайн-системы и UIKit для доступа к компонентам.


import UIKit

final class ChatDateTableHeaderViewSnapshotTests: SnapshotTestCase {

    private var sut: ChatDateTableHeaderView!
    private var viewModel: ChatDateTableHeaderViewModelMock!

    override func setUp() {
        super.setUp()

        sut = ChatDateTableHeaderView()
        viewModel = ChatDateTableHeaderViewModelMock()
    }

    override func tearDown() {
        super.tearDown()

        sut = nil
        viewModel = nil
    }
}

// MARK: - Mocks
private extension ChatDateTableHeaderViewSnapshotTests {

    struct ChatDateTableHeaderViewModelMock: TextViewModelProtocol {
        var title: String? = "1 июля"
    }
}

Здесь можно заметить 'SnapshotTestCase', который является просто наследником XCTestCase и нужен для удобства. Далее по тексту расскажу подробнее, для чего.

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

Основное различие тестов состоит в цели тестирования: в юнит-тестах мы проверяем отдельные свойства моделей, выполнение событий, а в снепшот-тестах — только конкретное состояние по пикселям в виде изображения.

Далее необходимо сгенерировать эталонное изображение — состояние компонента, чтобы в дальнейшем его сравнивать с полученным в ходе проверки теста.

Пишем первый тест

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

func testDayAndMonthTitle() {
    // when
    sut.viewModel = viewModel

    // then
    assertImageSnapshot(matching: sut)
}

Параметр matching принимает вью, которую нужно сравнить с эталонным изображением. Если автоматический подсчет минимально необходимого размера не подходит, можно явно указать размер компонента для генерации.

func testDayAndMonthTitle() {
    // when
    sut.viewModel = viewModel

    // then
    assertImageSnapshot(matching: sut, size: defaultSize)
}

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

ДБО МСБ > iOS. Snapshot-тестирование > image2023–9–25_11–26–39.png» /></p>

<p>Ошибка при первом запуске нового теста</p>

<p>Чтобы перезаписать все эталонные изображения, нужно выставить свойство 'isRecording'.</p>

<p>Свойство 'isRecording = true' говорит о том, что при прогоне тестовых кейсов будут генерироваться новые эталонные изображения. Свойство можно выставить глобально в методе 'setUp' или в отдельных тест-кейсах, как аргумент 'record: true' в методе 'assertSnapshot' для генерации конкретного снепшота.</p>

<p>После установки свойства и запуска тестов они снова упадут, и это нормально! Все сгенерированные изображения можно найти внутри »__Snapshots__» в папке файла самого теста.</p>

<p>Далее получившиеся эталонные изображения добавляем в наш SPM-пакет для хранения снепшотов и удаляем папку »__Snapshots__», так как сравнение будет уже с снепшотами в ресурсах SPM-пакета.</p>

<p>Если все сделано правильно, то тесты завершатся успешно.</p>

<p>Но тут мы сталкиваемся с тем, что не можем просто указать путь до ресурсов SPM-пакета, в котором лежат наши эталонные изображения. По умолчанию при прогоне тестов идет сравнение сгенерированного снепшота с эталонным изображением, что лежит в папке »__Snapshots__».</p>

<p>Поэтому был сделан наследник XCTestCase — SnapshotTestCase. В нем реализован метод 'assertImageSnapshot', который повторяет почти весь функционал стандартного метода 'assertSnapshot' из библиотеки SnapshotTesting, но с доработками в виде указания пути хранения снепшота и размера в зависимости от контента. </p>

<p>Но даже с этой доработкой наша задумка не удалась. Метод библиотеки 'verifySnapshot', в котором происходит вся магия, создает директорию для чтения и записи снепшотов, и при записи идет попытка записать снепшоты в ресурсы SPM-пакета, который read-only. Поэтому пришлось модифицировать этот метод и указать возможность записи в конкретное место. </p>

<p>В итоге у нас чтение происходит из SPM-пакета, а запись в локальную папку »__Snapshots__» в папку с тестом. </p>

<p>Чтобы убедиться в правильном сравнении, изменим в уже существующем тест-кейсе текст для тайтла и запустим заново. Как видно на изображении ниже, тест падает.</p>

<p><img src=

Failure Diff при получении ошибки сравнения

Нажмем на иконку глаза, и он откроет diff-изображение, которое показывает, чем именно отличается эталонное изображение от полученного. Можно заметить, что цифры разные. Очень помогает, когда есть незаметное глазу отличие, как отступы или оттенки цветов.

ДБО МСБ > iOS. Snapshot-тестирование > image2023–9–25_11–56–24.png» /></p>

<p>Diff-изображение</p>

<p>Также есть механизм выставления погрешности, который может уменьшить точность сравнения. В связи с особенностями рендеринга на разных устройствах может возникнуть ситуация, когда созданные локально эталонные изображения не проходят проверку на CI-машине. Мы снижаем точность проверки через аргумент precision для стратегии .image  в функции assertSnapshot. Согласно документации человеческий глаз не замечает разницы при значениях в диапазоне 0.98–1.</p>

<h3>Дизайн-ревью</h3>

<p>На этом этапе у нас написаны тесты, и они прошли проверку. Поэтому собираем получившиеся снепшоты в архив и отправляем на дизайн-ревью. Также по договоренности с дизайнером можно сделать отдельный скриншот или запись экрана с нашим компонентом и другими элементами для проверки их взаимодействия друг с другом, а также посмотреть, как компонент выглядит «на бою».</p>

<h3>CI/CD</h3>

<p>Для проверки тестов перед влитием в мастер, покрытия снепшот-тестами других модулей в перспективе, а также для отделения от подсчета покрытия тестами, как в юнит-тестах, на CI заведена специальная переменная и добавлена в .gitlab-ci.yml файл.</p>

<p>В нее можно через запятую прописать названия тестовых таргетов в модулях, где содержатся снепшот-тесты. </p>

<p>При создании мердж реквеста будет проверяться соответствие эталонных изображений в нашей библиотеке с ресурсами и компонентами, на которые написаны снепшот-тесты.</p>

<p>После прохождения дизайн-ревью и проверки мердж реквеста на CI, мы можем вливать изменения в мастер.</p>

<h3>Обновление снепшот-теста на компонент</h3>

<p>Когда нам необходимо обновить снепшот-тесты — например, был редизайн компонента, — мы их обновляем с поднятием версии в параметре 'named:»…»'.</p>

<pre><code class=func testDayAndMonthTitle() { // when viewModel.title = "11 марта 2024" sut.viewModel = viewModel // then assertImageSnapshot(matching: sut, named: "2") }

По умолчанию при генерации выставляется версия '1', это можно увидеть в названии изображения «testDayAndMonthTitle.1.png». Далее следуем тем же шагам, что и при написании самих тестов. Также можно указывать свои значения, которые будут приняты для идентификации версии, либо вообще не указывать ее.

Сложности и нюансы

Вроде бы всё! Тесты пишутся быстро, CI прогоняет тесты корректно, но не тут-то было.

По ходу дела всплывают те или иные ошибки — как локально, так и при самом прогоне на CI. 

Давайте рассмотрим выявленные проблемы и варианты их решения, а также возможные нюансы, которые могут возникнуть в будущем:

  • Одной из самых очевидных ошибок была, как ни странно, банальная невнимательность при выставлении девайса для генерации снепшотов. Из-за разных размеров и ppi на локальной машине и CI снепшоты не совпадали, и тесты падали. Так как на данный момент у нас принят один девайс для проверки тестов, снепшотов и дизайн-ревью, то необходимо придерживаться его. Либо реализовывать соответствующую проверку и прогон на нескольких девайсах с разным размером снепшотов.

  • Также приходилось 2–3 раза перезаливать все снепшоты, так как вносились изменения, которые касались почти всех компонентов, в частности, текста. Благо это сделать несложно, просто выставив свойство 'isRecording = true' в тест-файле. Подобные случаи должны стремиться к минимуму с последующим улучшением и выявлением основных ошибок.

  • Следом идет разница в версии Xcode. На CI-машинах стоят более новые версии Xcode, как следствие, есть различия в рендере компонентов. Конкретный пример — отрисовка текста: все одинаково, но текст смещен буквально на пиксель, проблема была в свойстве 'baselineOffset'. Решили небольшими доработками и проверкой версии iOS при выставлении свойства, а также перезаписью всех снепшотов в ресурсах. 

  • Также может быть разница в отрисовке теней либо небольшие изменения системных компонентов с разницей версий. Зачастую решаются понижением значения precision в методе 'assertImageSnapshot', но не стоит понижать ниже 0.98, иначе можно лишиться основной фишки снепшот-тестирования — сравнения по пикселям, кроме того, это диапазон, не видимый глазу.

  • Еще один из моментов, которые стоит учесть — скругления углов: при генерации снепшотов не учитывается свойство 'maskedCorners'. Для этого мы рисуем углы c помощью 'UIBezierPath'.

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

Настал черед следующей стадии.

4fb7a9c2b37400c31ee7bae898a2ce77.png

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

Нужно согласовать актуальность компонента с дизайном, написать сами тесты и убедиться, что все работает. А еще найти на это время, которое есть только в техдни.

Это одна из очевидных причин, почему снепшот-тестирование находится выше юнит-тестов в пирамиде тестирования.

Но не стоит унывать! Все постепенно: компонент за компонентом, тест за тестом. Учитывая плюсы и дополнительный слой защиты приложения от визуальных багов, оно того стоит!

b3c8e60cbb86b57fbde7c6542b531004.png

Вот мы и в финальной стадии принятия снепшот-тестов как новой формы валидации нашего приложения.

Тесты пишутся быстро, читаются легко, порог входа невысокий, а искать ошибки верстки стало проще.

Подводя итоги, хотелось бы поделиться цифрами, которые получились на выходе:

  • 29 секунд на прогон всех снепшот-тестов;

  • около 400 эталонных изображений общим весом 14,6 МБ;

  • несколько довольных разработчиков, которые обезопасили свои компоненты;

  • квартал на изучение, внедрение и доработки.

Дальнейшие планы

Всегда есть, что улучшить. Вот несколько вариантов, которые могут упростить работу со снепшот-тестами:

  • Реализовать тесты с возможностью генерации снепшотов для разных моделей девайсов и взаимосвязи их с CI, а также отправки их на дизайн-ревью, для улучшения качества валидации компонента на разных экранах.

  • Сделать шаблон для быстрого создания файла тестирования.

  • Изучить возможность хранения снепшотов в отдельном хранилище. Стоит убедиться в целесообразности использования в сравнении с отдельным репозиторием в гитлабе: доступы, количество шагов для реализации и добавлении снепшотов, скорость проверки и т.д.

  • Упростить процесс генерации и добавления снепшотов в ресурсы отдельного репозитория либо внешного хранилища.

  • Подготовиться к переходу на SwiftUI или частичное использование.

Заключение

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

Буду рад предложениями и обратной связи, а также опыту вашего внедрения снепшот-тестов.

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

Для информации пользовался данными ссылками, а также видеодокладами на YouTube:

© Habrahabr.ru