В этой статье расскажу о скриншот-тестировании iOS-приложений. Разберём одну из ключевых проблем, с которой сталкиваются проекты при работе с тестами, посмотрим, на чём основано сравнение изображений в современных open-source инструментах и поймём, почему всё сложнее, чем может показаться на первый взгляд. Также попробуем разобраться, как можно выйти за рамки существующих ограничений с помощью AFSnapshotTesting и параллельных вычислений на Metal.

1. Нестабильность тестов

Идея скриншот-тестирования проста: это рендер изображения вашего UIView в определённой конфигурации с последующим попиксельным сравнением с сохранённым эталоном.

import AFSnapshotTesting

class ExampleTests: XCTestCase {
    func test() {
        let view = SomeView(width: 100, height: 300)
        assertSnapshot(view)
    }
}

Представим, что вашей командой принято решение о начале внедрения скриншот тестов. Вы обсудили существующие решения и выбрали популярный open-source проект.

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

В теории snapshot-тестирование должно быть надёжным способом контроля визуальных изменений, но на практике же даже разница в графическом стеке, версии iOS или модели CPU может спровоцировать расхождения. Эти расхождения - «выбросы» - часто не несут смысловой нагрузки, отличаются на уровне погрешности округления, но становятся причиной падения тестов и подрывают доверие к ним.

Рассмотрим визуализацию настоящих выбросов через open-source проект AFSnapshotTesting. Создадим эталонный снимок на симуляторе iOS 18 и попытаемся выполнить этот же тест, но на симуляторе iOS 17.2 с включенным флагом record, который в случае падение создаст изображение с покрашенными несовпадениями.

Рисунок 1: Diff изображение AFSnapshotTesting. Пример выбросов iPhone 15pro simulator iOS 18 vs. iOS 17.2. (XCode 15.3, macbook m1 pro)
Рисунок 1: Diff изображение AFSnapshotTesting. Пример выбросов iPhone 15pro simulator iOS 18 vs. iOS 17.2. (XCode 15.3, macbook m1 pro)

Выше представлено отладочное diff изображение простого скриншот теста. Пиксели окрашенные в зелёный цвет - это несовпадение с эталоном. Много единичных зелёных пикселей - оттуда и название "выброс".

Как видите из описания окружения, разница лишь в версии операционной системы симулятора. Так же выбросы возможны и в разнице моделей процессоров, например вы написали тест на m4, а ваш CI гонит эти тесты на раннерах M1.

Ниже приведу ссылки на обсуждения проблемы в крупных проектах.

  1. 🔗 Самый комментируемый github issue проекта SnapshotTesting посвящен проблеме падений https://github.com/pointfreeco/swift-snapshot-testing/issues/424

  2. 🔗 Github issue проекта Charts 27k+ звёзд https://github.com/ChartsOrg/Charts/pull/4574 посвящённый переезду на SnapshotTesting и есть упоминание проблемы падений.

2. Главное решение индустрии

Если вы не начали создавать своё решение, скорее всего вы выбрали SnapshotTesting от PointFree. Это фактически главный монополист на рынке snapshot-тестирования iOS проектов и все с кем мне удалось побеседовать - используют этот проект. Многие известные вам компании используют SnapshotTesting. Многие ваши любимые инструменты используют SnapshotTesting. Другие open-source проекты или архивированы или последний коммит был несколько лет назад.

Давайте рассмотрим, какой подход к решению нам предлагают ведущие инструменты.

2.1 Понижение чувствительности как основной механизм.

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

PointFree предлагает не требовать абсолютного совпадения сравниваемой пары пикселей бит-в-бит, а считать их «одинаковыми» если цвета не отличаются друг от друга больше чем на заданный допустимый порог (perceptualPrecision). Это поможет уйти от точных вычислений бит-в-бит и игнорировать большинство выбросов.

assertSnapshot(
	matching: myView, 
	as: .image(perceptualPrecision: 0.998)
)
  1. 🔗 Принятый github pull request SnapshotTesting с реализацией механизма цветочувствительности https://github.com/pointfreeco/swift-snapshot-testing/pull/628

perceptualPrecision параметр-коэффициент определяющий насколько точно должны совпадать цвета сравниваемых между собой пикселей. Разница определяется математической формулой \Delta E 1994, определяющая насколько цвета похожи с точки зрения человеческого восприятия.

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

\Delta E = (1 - \text{perceptualPrecision}) \times  100

Восприятие

perceptualPrecision

\Delta E

Незаметно для человеческого глаза

1.00

≤ 1.0

Заметно при внимательном рассмотрении

0.99 – 0.98

1 – 2

Заметно с первого взгляда

0.98 – 0.90

2 – 10

Цвета скорее похожи, чем противоположны

0.90 – 0.51

11 – 49

Цвета прямо противоположны

0.00

100

В следующих главах рассмотрим путь от наивного подхода - сравнения цветов с помощью евклидова расстояния в RGB - до самых передовых стандартов \Delta E_{2000}.

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

2.1.1 Евклидово расстояние в RGB пространстве.

Наивный способ определить степень разности цветов - просто вычислять евклидово расстояние между пикселями в RGB-пространстве :

D = \sqrt{(R_2 - R_1)^2 + (G_2 - G_1)^2 + (B_2 - B_1)^2}

Формула 1: Расчёт цветовой разности D для двух RGB цветове

Тем самым мы можем определить насколько далеко два цвета находятся друг от друга в цветовом пространстве и можем ли мы считать их одинаковыми. Данный метод был одобрен, как основная мера борьбы с пикселями-выбросами в SnapshotTesting. Но раз он принят, то почему тогда PR был закрыт без влития ? Ответ прост.

Коэффициент D не отражает человеческое восприятие. Пары цветов с одинаковым евклидовым расстоянием могут иметь значительные различия в RGB. Левый и правый цвета каждой строки имеют одинаковое евклидово расстояние:

Рисунок 2: Левый и правый цвета каждой строки имеют одинаковое евклидово расстояние, но цвета разные
Рисунок 2: Левый и правый цвета каждой строки имеют одинаковое евклидово расстояние, но цвета разные

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

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

  1. 🔗 Апрувнутый, но закрытый PR с евклидовым расстоянием в SnapshotTesting https://github.com/pointfreeco/swift-snapshot-testing/pull/571

2.1.2 Цветовое пространство CIELAB

Метрика \Delta E отражает степень различия между двумя цветами в пространстве CIELAB - специально разработанном цветовом пространстве, созданном Международной комиссией по освещению (CIE), чтобы лучше отражать восприятие цвета человеком. В отличие от RGB-пространства, где расстояние между цветами не коррелирует с тем, как мы их видим, CIELAB стремится быть перцептуально равномерным, то есть равные изменения координат должны соответствовать примерно одинаковым изменениям, воспринимаемым глазом. (Именно поэтому параметр называется perceptualPrecision)

Однако, несмотря на свою простоту \Delta E_{76} не учитывает некоторые аспекты зрительного восприятия, такие как разная чувствительность человека к оттенкам и насыщенности в различных зонах цветового пространства. Это приводит к ряду неточностей в реальных тестах. В результате, в последующие десятилетия формула была уточнена — сначала в версии \Delta E_{94}, а затем в более точной \Delta E_{2000}

В следующих главах мы подробнее рассмотрим метрику \Delta E - от самой первой версии, представленной в 1976 году, до наиболее точной и современной формулы 2000 года. Я расскажу, в чём заключаются ключевые отличия и почему это имеет значение в контексте современных open-source инструментов для snapshot тестирования iOS приложений.

2.1.2.1 Самая первая формула CIE1976

Цвета можно представлять не только в RGB, но и в других цветовых пространствах. Одним из таких пространств является CIELAB. В 1976 международной комиссией по цвету и освещению (CIE | International Commission on Illumination) было представлено новое цветовое пространство и первая формула расчёта \Delta E

Коэффициент \Delta E_{76} (CIE1976) создана оценивать сходство цветов с учётом человеческого восприятия и вычисляется по следующей формуле:

\Delta E_{76} = \sqrt{(L_2 - L_1)^2 + (a_2 - a_1)^2 + (b_2 - b_1)^2}

Формула 2: Расчёт цветовой разности ΔE76

Самые внимательные или математически одарённые читатели могли заметить, что формула расчёта \Delta E_{76} полная копия формулы расчёта коэффициента D из прошлой главы 2.1.1 про наивное сравнение RBG через евклидово расстояние и они абсолютно правы.

Для измерения разницы между цветами в CIELAB можно применять обычную евклидову метрику. Именно на этом принципе и была построена первая версия, представленная в 1976 году. Она задала основу всей последующей работе в области оценки цветовых различий.

\Delta E - это показатель, позволяющий понять, как человеческий глаз воспринимает разницу в цвете. Термин дельта взят из математики и означает изменение переменной или функции. Суффикс E отсылает к немецкому слову Empfindung, которое в широком смысле означает «ощущение».

В обычной шкале значение \Delta E варьируется от 0 до 100.

\Delta E

Восприятие

<= 1.0

Невидимы для человеческого глаза.

1 - 2

Заметно при внимательном рассмотрении.

2 - 10

Заметно с первого взгляда.

11 - 49

Цвета скорее похожи, чем противоположны

100

Цвета прямо противоположны

Формула \Delta E_{76} была создана в 1976 году и оказалась не идеальна, и в дальшейшем были представлены \Delta E_{94} и \Delta E_{2000} в 1994 и 2000 годах соответственно. Добавлю то, что для создания более менее хорошей формулы различия учитывающие различные факторы (оттенки, насыщенность, светлота) изначальная формула \Delta E созданная в 1976 году International Commission on Illumination была усложнена в \Delta E_{94} и \Delta E_{2000}.

\Delta E CIE1994 считается оптимальным балансом и позволяет SnapshotTesting точнее различать, насколько сильно с точки зрения восприятия отличаются сравниваемые пиксели и полагаясь на \Delta E задавать порог который мы готовы игнорировать. Тем самым отпала необходимость в точном соответствии данных в бит-бит что позволяет откидывать выбросы.

  1. 🔗 https://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e

  2. 🔗 https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/

2.1.2.2 CIE1994 . Используемая SnapshotTesting

В 1994 году исходная формула \Delta E_{76} была усовершенствована. Новая формула учитывала определённые весовые коэффициенты для каждого значения яркости, насыщенности и оттенка.

\Delta E^*_{94} = \sqrt{\left( \frac{\Delta L^*}{k_L S_L} \right)^2 + \left( \frac{\Delta C^*_{ab}}{k_C S_C} \right)^2 + \left( \frac{\Delta H^*_{ab}}{k_H S_H} \right)^2}

Формула 3: Расчёт цветовой разности ΔE94

CIE94 ввёл преобразование заданного значения Lab в CIE LCh (Lch). Две цветовые модели отличаются тем, что Lch представляет цветовой тон как угол, а не как бесконечные точки цвета. Это упрощает диагностику и расчёты цветового тона.

Рисунок 3: Сравнение цветовых пространств CIE Lab и CIE Lch, где Lch упрощает работу с оттенками цвета через угловое представление.
Рисунок 3: Сравнение цветовых пространств CIE Lab и CIE Lch, где Lch упрощает работу с оттенками цвета через угловое представление.

Именно эта формула используется в фильтре CILabDeltaE от Apple CoreImage, которая используется при расчёте \Delta E в SnapshotTesting от PointFree.

    func applyingLabDeltaE(_ other: CIImage) -> CIImage {
      applyingFilter("CILabDeltaE", parameters: ["inputImage2": other])
    }

Код 1: Использование фильтра в SnapshotTesting UIImage.swift

CIE94, безусловно, превосходит CIE76, но это не золото. CIE94 всё ещё не дотягивает при расчёте воспринимаемой яркости двух цветов.

Рисунок 4: Оттенок не тот, а вот светлота та же
Рисунок 4: Оттенок не тот, а вот светлота та же

Это означает, что всё ещё остаётся риск замаскировать баги при попытке обойти часть выбросов. 94 версия считается оптимальной для большинства задач, но если мы хотим большей идеальности и приближения к pixel-perfect то рад представить самую передовую \Delta E

2.1.2.3 CIE2000

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

Рисунок 5: Математическая формула d00
Рисунок 5: Математическая формула d00

\Delta E 94 в отличии от \Delta E 2000 ошибочно делает вывод, что чёрный и зелёный, белый зелёный абсолютно одинаковы с точки зрения нашего восприятия. Но это не так, посмотрите сами:

Рисунок 6: Одинаковые цвета с точки зрения d94
Рисунок 6: Одинаковые цвета с точки зрения d94

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

В AFSnapshotTesting реализована \Delta E 2000 вычисляемая на GPU под стратегией perceptualTollerance, пример использования перцептуальной стратегии сравнения:

assertSnapshot(as: .perceptualTollerance(threshold: 10, deltaE: 1.0))
// Или можно использовать вариант с коэффициентом:
assertSnapshot(as: .perceptualTollerance_v2(precission: 0.99992, perceptualPrecision: 0.9994))

2.1.3 Выводы

За простым сравнением двух изображений с допустимой погрешностью стоит довольно нетривиальная математическая основа. Метрики вроде ΔE - это не просто «чуть менее строгое сравнение», а результат десятилетий развития моделей восприятия цвета. Без этих формул скриншот-тестирование в привычном виде, скорее всего имело другой вид: именно они позволили перевести проблему точного битового совпадения в область восприятия, ближе к реальной визуальной оценке без сокрытия багов при попытке минимизировать выбросы в одной.

SnapshotTesting сделал важный шаг, внедрив такую метрику и фактически определит стандарт подхода к сравнению изображений в скриншот тестировании iOS приложений. Однако на этом развитие не останавливается. Благодаря возможностям вычислительных шейдеров Metal, более современные методы - вроде GPU-ускоренных вычислений для ΔE₂₀₀₀ - открывают путь к ещё большей точности, скорости и контролю над процессом сравнения.

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

3. Как чувствовать детали

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

Существующие инструменты из-за компромисса с производительностью вынуждены полагаться на встроенные фильтры и другие инструменты, но они ограничивают гибкость. Достаточно непросто реализовать настоящее diff изображение (и оно не реализовано) и приходится в настройках полагаться на параметры в виде коэффициента.

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

3.1 Naive kernel

Преимущество GPU в том, что используется массовый параллелизм по принципу SIMT (Single instruction, multiple threads) - это означает, что один код (инструкция, kernel) исполняется одновременно множеством потоков, каждый из которых работает со своими данными. Например 1000 потоков параллельно обрабатывает только свою пару пикселей.

В то время как типичный CPU содержит от нескольких до десятков вычислительных ядер, каждое из которых оптимизировано для сложной логики и последовательных ветвлений, GPU может включать более тысячи ядер. Мы можем написать шейдерный алгоритм на MSL и исполнить его для каждой пары пикселей параллельно насколько это возможно.

3.1.1 Отказ от коэффициента

Давайте рассмотрим основные аспекты на примере Naive kernel этот модуль отвечает за наивную стратегию тестирования, он просто берёт два RGB и сравнивает бит-в-бит на GPU Metal. Для начала необходимо создать сам шейдерный код на языке Metal Shading Language, который будет выполнять наивное сравнение пары пикселей.

#include <metal_stdlib>
using namespace metal;

kernel void naiveKernel(
	texture2d<float, access::read> inputImage1 [[texture(0)]],
	texture2d<float, access::read> inputImage2 [[texture(1)]],
	device atomic_uint* counter [[buffer(0)]],
	uint2 gid [[thread_position_in_grid]]
) {
    if (gid.x >= inputImage1.get_width() || gid.y >= inputImage1.get_height()) {
        return;
    }

    float4 pixel1 = inputImage1.read(gid);
    float4 pixel2 = inputImage2.read(gid);

    bool isPixelNotEqual = !((pixel1.r == pixel2.r) && (pixel1.g == pixel2.g) && (pixel1.b == pixel2.b) && (pixel1.a == pixel2.a));

    if (isPixelNotEqual) {
        atomic_fetch_add_explicit(counter, 1, memory_order_relaxed);
    }
}

Затем при инициализации Metal пайплайна, я загружаю туда исходный код шейдера выше (naiveKernel)

class NaiveKernel: Kernel {
    init(with configuration: Kernel.Configuration) throws {
        try super.init(with: configuration, function: "naiveKernel")
    }
}

Пара изображений - снимок и эталон - конвертируются в текстуры MTLTexture, которые загружаются на GPU передаются аргументами в шейдер под индексами 0 и 1:

    let texture1 = try textureLoader.newTexture(cgImage: lhs, options: options)
    let texture2 = try textureLoader.newTexture(cgImage: rhs, options: options)

    computeEncoder.setTexture(texture1, index: 0)
    computeEncoder.setTexture(texture2, index: 1)

Далее на этапе пайплайна создаётся сетка  width × height потоков, по одному на каждый пиксель. Согласно значению входящего в шейдер аргумента gid, я получаю координаты текущего потока в шейдере и прочитаю нужную пару пикселей через inputImage1.read(gid)

float4 pixel1 = inputImage1.read(gid);
float4 pixel2 = inputImage2.read(gid);

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

    if (isPixelNotEqual) {
        atomic_fetch_add_explicit(counter, 1, memory_order_relaxed);
    }

Так как одновременно может писать параллельный поток, во избежания проблем многопоточности data race используется атомарное увеличение с помощью функции atomic_fetch_add_explicit

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

Далее на этой основе строятся другие более сложные стратегии типа \Delta E 2000 с математическими вычислениями и другие стратегии типо клаcтерного анализа.

3.1.2 Diff изображение

Всем нам хочется понимать, что именно не совпало. В текущих инструментах пиксели просто "подсвечиваются", без учёта выбранной стратегии сравнения и параметров. В результате diff является не достоверным и затрудняет настройку теста.

Входящие аргументы рендера new и эталона old - не связаны с алгоритмом сравнения и являются результатом применения фильтра с blendMode над исходной парой:

  private func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
    let width = max(old.size.width, new.size.width)
    let height = max(old.size.height, new.size.height)
    let scale = max(old.scale, new.scale)
    UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), true, scale)
    new.draw(at: .zero)
    old.draw(at: .zero, blendMode: .difference, alpha: 1)
    let differenceImage = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext()
    return differenceImage
  }

swift-snapshot-testing/Sources/SnapshotTesting/Snapshotting/UIImage.swift

Теперь на примере NaiveKernelDifferenceImage рассмотрим создание отладочного diff-изображения. Логика создания незначительно отличается от модулей сравнения, но выделены в отдельные модули. Это сделано в целях оптимизации производительности. Diff изображение относительно редкая операция и не хочется нагружать пайплайн созданием и инициализацией дополнительных параметров и текстур. К тому же это вызывает некоторое нарушение принципа единой ответственности.

kernel void naiveKernelTextureRecord(texture2d<float, access::read> inputImage1 [[texture(0)]],
                          texture2d<float, access::read> inputImage2 [[texture(1)]],
                          texture2d<float, access::write> outputImage [[texture(2)]],
                          constant float4& newColor [[buffer(0)]],
                          uint2 gid [[thread_position_in_grid]]) {
    if (gid.x >= inputImage1.get_width() || gid.y >= inputImage1.get_height()) {
        return;
    }

    float4 pixel1 = inputImage1.read(gid);
    float4 pixel2 = inputImage2.read(gid);

    bool isPixelNotEqual = !((pixel1.r == pixel2.r) && (pixel1.g == pixel2.g) && (pixel1.b == pixel2.b) && (pixel1.a == pixel2.a));

    if (isPixelNotEqual) {
        outputImage.write(newColor, gid);
    } else {
        outputImage.write(pixel1, gid);
    }
}

Логика до банальности проста, сравниваем и если не совпало записываем в новую текстуру по нужным координатам потока(gid) заданный цвет. А если совпало - оригинальный цвет, в итоге получаем diff изображение с подсвеченными не совпавшими значениями.

В исходном коде нужно включить differenceRecord и по желанию выбрать цвет покраски, по умолчанию зелёный.

import AFSnapshotTesting

func test() {
	let view = CustomView()
    assertSnapshot(view, differenceRecord = true, color = .green) 
}

В процессе обработки каждый пиксель получает статус на основе алгоритма, который фактически использовался при тесте. Это превращает diff-изображение в полноценный инструмент диагностики.

3.2 Вывод

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

Всё это становится возможным благодаря использованию Apple Metal — библиотека получает прямой, производительный доступ к каждой паре пикселей и выполняет сравнение на уровне GPU. Такой подход обеспечивает производительность, которую невозможно достичь при вычислениях на CPU, и открывает новые возможности без потери производительности.

Производительность является серьёзной проблемой. Все эти вещи можно реализовать на CPU, но тогда 1 тест будет занимать в среднем 1 секунду. Если это 1000 тестов, то это как минимум более 16.6 минут времени на CI, что ударяет по time-to-market.

4. GPU кластерная фильтрация в snapshot тестировании

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

Зелёные точки — это одиночные выбросы
Зелёные точки — это одиночные выбросы

На примере выше видно, что зелёные точки — это одиночные выбросы, не несущие смысловой разницы.

Основная проблема текущих подходов с перцептивным сравнением в том, что игнорированию подвергаются все пиксели подряд. Метрики вроде ΔE хоть и стандарт, но не дают абсолютной точности: они всё ещё могут маскировать баги в одном месте с увеличением порога при попытке избавиться от шумов в другом особенно если используется старая метрика.

Идея новой стратегии проста: определять скопления(кластеры) выбросов и, если их размер меньше заданного порога, игнорировать их полностью. Так можно избавиться почти от всех зелёных точек на примере выше — без применения перцептивных стратегий по всему изображению.

Если группа несовпадений содержит меньше двух пикселей, она игнорируется: .cluster(threshold: 0, clusterSize: 3) и тест упадёт при первой ошибке т.к threshold равен нулю. Такой режим позволяет без рисков полностью отбросить значительную часть выбросов. В следующей главе наглядно посмотрим на работу и эффективность стратегий на примерах.

5. Демо

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

5.1 Создание теста

Библиотека представлена в виде SPM пакета и самый простой способ её подключить

  1. Открыть через Xcode ваш проект или создать новый с таргетом для тестов.

  2. Перейти в верхних вкладках File -> Add Package Dependencies

  3. Ввести в поиске URL репозитория https://github.com/afanasykoryakin/AFSnapshotTesting

  4. При установке выбрать таргет с тестами и последнюю версию библиотеки (она будет проставлена автоматически)

Основное поведение в основном было позаимствовано у популярных инструментов, т.к это проверенная временем практика. После создания теста и первого запуска - автоматически будет создан эталонный снимок. При провале будет создана папка Difference для созданных diff изображений.

5.2 Минимизация в действии

Скорее всего в следующие 5 лет при современном стеке вашей инфраструктуры и техники у разработчиков - проблема выбросов будет уходить в прошлое и будет достаточно наивного подхода, но в настоящее время выбросы всё ещё возможны. Давайте смоделируем ситуацию: Представим, что разработчик создающий тест работает на современном macbook pro m4 и (Xcode26.0.1 + iOS 26), а наша CI или другой разработчик работает на более простом m1 pro и прогоняют на Xcode 16.2 + iOS 17.4.

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

5.2.1 Просто текста

Зелёным отмечено 564 несовпадения
Зелёным отмечено 564 несовпадения

Слева представлено diff изображение. Эталон получен на M4 pro, iOS 26.0.1, Xcode 26, а diff сгенерирован на M1 pro, iOS 18, Xcode 16.2.

Пробуем минимизировать с помощью стратегий:

Стратегия

Влияние на изображение

Комментарий

.cluster(threshold: 0, clusterSize: 4)

Кластерная стратегия снизила выбросы с 564 до 256

Удалены группы выбросов содержащие менее 4 пикселей

.perceptualTollerance(threshold: 0, deltaE: 1.0)

Полностью совпало.

deltaE стратегия полностью избавила от выбросов сохранив практически полную цветочувствительность.

5.2.2 Кастомный label

h
Эталон на M4 pro, iOS 26.0.1, Xcode 26

Усложним ситуацию. Добавим UILabel и сделаем кастомное зачёркивание через UIBezierPath с небольшим добавлением тригонометрии. Расчёт с sin и cos создаст устойчивое отклонение (т.к более сложная работа с плавающей точкой)

h
Наивный diff на M1 pro, iOS 18, Xcode 16.2

Зелёным отмечены 4516 несовпадения.

Применим минимизацию с помощью стратегий:

Стратегия

Влияние

Комментарий

.cluster(threshold: 0, clusterSize: 7)

Удалены группы выбросов содержащие менее 7 пикселей

Чисто кластерная стратегия оказалась бесполезна. Кастомное зачёркивание оказалось непосильно для кластерной стратегии (слишком большие группы)

.perceptualTollerance(threshold: 0, deltaE: 3.0)

Полное совпадение

Подход с цветочувствительностью полностью избавил от выбросов с порогом 3, согласно таблице из первых глав \Delta E 2-10 - заметно с первого взгляда. Мы находимся чуть выше границы, где различить цвета достаточно трудно. Но дальнейшее повышение порога дельты влечёт за собой риск игнора настоящих несовпадений вплоть до их полного игнора.

5.2.3 Градиент

h
Эталон на M4 pro, iOS 26.0.1, Xcode 26

Рассмотрим самый сложный случай. Добавим градиент.

h
Наивный diff на M1 pro, iOS 18, Xcode 16.2. Зелёным отмечено 454357 несовпадения.

Это один из самых сложных случаев. Градиент вызывает массовое расхождение, самый действенный способ использовать игнор через цветочувствительность.

h
diff стратегии .perceptualTollerance(threshold: 0, deltaE: 7.0) Зелёным отмечено 2881 несовпадения

Кластерная фильтрация в чистом виде ожидаемо не показала существенного результата. Стратегия цветочувствительности снизила количество несовпадений с 454357 до 2881 и снизила процент расхождения до 0.53 %. Помните в главах выше, я рассказывал о формулах различия 94 и 2000 годов? Если воспользоваться формулой 94 года то мы при тех же параметрах получим 5 879 (1.09 %). GPU реализация deltaE2000 в этом случае показывает двукратное снижение.

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

    func testWithGradient() {
        assertSnapshot(
            view.withShadow.withCrossedLabel.withGradient,
            as: .combined(threshold: 0, clusterSize: 7, deltaE: 7)
        )
    }
h
Diff комбинированной стратегии: Для clusterSize 7 - 819 (0.15 %) несовпадений.
Для clusterSize 12 - 516 (0.096 %) несовпадений.

Комбинация GPU реализаций современного deltaE2000 и кластерной фильтрации позволяет дополнительно снизить количество выбросов без лишнего понижения цветочувствительности до 11,5 раз. Расхождение снизилось c одного процента (1.09 %) до одной сотой процента (0.096 %).

5.3 Выводы

Если вам всё же пришлось использовать алгоритмы минимизации, главы выше на примерах показывают эффективность каждой стратегии. Некоторые случаи достаточно хорошо обрабатываются цветочувствительным алгоритмом, но некоторые показывают большую эффективность в виде комбинированных подходов и позволяют снизить количество расхождений до сотых процента. GPU реализация deltaE2000 позволяет получить прирост точности до 2 раз, а производительность возрастает на 230% относительно классической реализации через фильтры CIImage.

5.3.1 Таблица стратегий минимизации расхождений

Стратегия

Основной принцип

Параметры

Когда эффективна

Преимущества

Недостатки / ограничения

.cluster(threshold, clusterSize)

Объединяет соседние пиксели в группы (кластеры) и фильтрует группы меньше заданного порога clusterSize, уменьшая количество одиночных шумовых выбросов.

threshold - количество допустимых несовпадений; clusterSize -максимально допустимый размер кластера.

Мелкие одиночные шумы.

Снижает случайные выбросы, не требует цветового анализа.

Неэффективен при крупных отличиях или изменениях цвета (например кастомные линии, градиенты).

.perceptualTollerance(threshold, deltaE)

Использует метрику восприятия цвета (ΔE 2000), сравнивая изображения по визуальной близости цветов.

deltaE - допустимая разница цветов; threshold - количество допустимых несовпадений.

Изменения цвета, тени, градиенты, различие рендеров GPU. Эффективный инструмент.

Иммитирует восприятие человека, эффективно устраняет крупные расхождения.

При завышенном deltaE есть риск игнорировать реальные отличия.

.combined(threshold, clusterSize, deltaE)

Комбинирует оба метода: сначала фильтрация по цветовой чувствительности, затем кластерная фильтрация оставшихся расхождений.

Все три параметра (thresholdclusterSizedeltaE).

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

Позволяет кратно снизить оставшиеся шумы без повышения порога deltaE

В текущей версии есть ограничения на размер кластера

6. Заключение

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

Мною была создана open-source библиотека AFSnapshotTesting , которая стремится улучшить текущие подходы с помощью GPU вычислений, и создать нов��е подходы, позволяющие ещё сильнее снизить влияние шума на ваши тесты.

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

Хоть за год мне и удалось довести разработку до оптимального рабочего состояния, она всё ещё остаётся молодой и будет развиваться дальше. Любой инструмент требует обкатки и улучшений. Спасибо за внимание! Всем хорошей недели и надёжных тестов.