Снепшот-тестирование — один из немногих надёжных способов контролировать визуальную целостность SwiftUI-компонентов. Но что делать, если ваш проект ограничен Xcode 13.3 и Swift 5.6, а большинство компонентов дизайн-системы обёрнуты в UIViewRepresentable?
Меня зовут Денис Третьяков, я iOS-разработчик в ПСБ. В этой статье расскажу, как мы организовали снепшот-тестирование SwiftUI-компонентов в условиях жёстких ограничений, с какими проблемами столкнулись и как их решили.
Почему SwiftUI сложно тестировать
SwiftUI построен на декларативной парадигме: UI описывается как функция от состояния. В отличие от UIKit, где мы напрямую манипулировали иерархией View, SwiftUI скрывает детали реализации за opaque type some View.
Apple не предоставляет официального API для инспекции внутренней иерархии SwiftUI View в юнит-тестах. Мы не можем получить доступ к дочерним View, их свойствам или модификаторам — стандартными средствами XCTest это сделать невозможно.
Для дизайн-системы, где критична визуальная консистентность, это создаёт проблему: нужно гарантировать, что каждый компонент корректно отображается в светлой и тёмной темах, адаптируется к Dynamic Type и сохраняет внешний вид при изменениях состояния.
Наши ограничения
Помимо архитектурных особенностей SwiftUI, мы работаем в условиях дополнительных ограничений:
Xcode 13.3 и Swift 5.6
Запрет на использование сторонних библиотек версий позже февраля 2022
Большинство компонентов DSKit обёрнуты в UIViewRepresentable
Эти ограничения существенно сужают выбор инструментов.
Какие подходы мы рассматривали
Юнит-тесты
Для UIKit любой контроллер можно протестировать через Mirror. Для SwiftUI это не работает — жизненный цикл View игнорируется, доступа к иерархии нет. Юнит-тесты могут отловить факт изменения @State, но не визуальный результат.
UI-тестирование
Подходит для проверки пользовательских сценариев, но работает медленно и нестабильно. Каждый тест требует полного запуска приложения, работа в изоляции невозможна. На WWDC 2025 Apple представила улучшенный workflow — Record, replay, and review: UI automation with Xcode, но для нашего стека это пока недоступно.
ViewInspector
Библиотека для инспекции SwiftUI-иерархии через рефлексию. Позволяет симулировать .tap(), .onAppear() и проверять реакцию View. Но последняя доступная нам версия — 0.9.1 от декабря 2021, а библиотека заточена под нативный SwiftUI. Наши компоненты в Representable-обёртках с ней работают плохо.
Снепшот-тестирование
Фиксирует внешний вид компонента как изображение и сравнивает с эталоном. Идеально для контроля вёрстки. Минус — хрупкость: изменение одного отступа ломает тесты. Но для дизайн-системы это скорее плюс: любое визуальное изменение будет замечено.
Мы выбрали снепшот-тестирование как основной инструмент.
Как устроено снепшот-тестирование SwiftUI
Базовый подход для iOS 13+: оборачиваем SwiftUI View в UIHostingController и рендерим через UIGraphicsImageRenderer.
Мы используем swift-snapshot-testing от Point-Free с доработками под нашу специфику.
Почему не ImageRenderer?
С iOS 16 появился ImageRenderer, который напрямую рендерит SwiftUI View в картинку:
@available(iOS 16.0, *)
func test_AccountInfo() {
let renderer = ImageRenderer(content: DSAccountInfoPreview())
guard let image = renderer.uiImage else {
XCTFail("Не удалось создать изображение")
return
}
assertSnapshot(matching: image, as: .image)
}
Но ImageRenderer не умеет рендерить UIViewRepresentable-компоненты — вместо них получаем жёлтые прямоугольники с красным значком запрета. Это документированное ограничение Apple: ImageRenderer работает только с нативными SwiftUI View.

Поскольку большинство наших компонентов обёрнуты в Representable, этот способ нам не подходит.
Наша реализация
Базовый API
Мы создали три функции с единым интерфейсом:
// UIView
assertUIViewSnapshot(matching: UIView())
// SwiftUI View
assertSwiftUIViewSnapshot { Divider() }
// UITableViewCell
assertUITableViewCellSnapshot(matching: cell)
Обёртка для SwiftUI
public func assertSwiftUIViewSnapshot<Content: SwiftUI.View>(
precision: Float = 0.98,
perceptualPrecision: Float = 0.98,
size: CGSize? = nil,
named name: String? = nil,
record recording: Bool = false,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line,
style: UIUserInterfaceStyle? = nil,
@ViewBuilder content: () -> Content
) {
let view = content()
let hostingController = createHostingController(for: view, size: size)
assertUIViewSnapshot(
matching: hostingController.view,
precision: precision,
// ... остальные параметры
)
}
Функция принимает @ViewBuilder, что позволяет передавать как простые View, так и композиции с модификаторами. Если style не указан, автоматически генерируются снепшоты для светлой и тёмной темы.
Создание UIHostingController
private func createHostingController<Content: SwiftUI.View>(
for view: Content,
size: CGSize?
) -> UIViewController {
let hostingController = UIHostingController(
rootView: view.fixedSize(horizontal: false, vertical: true)
)
hostingController.view.backgroundColor = .clear
let finalSize = size ?? hostingController.view.fittingSize()
hostingController.view.frame = CGRect(origin: .zero, size: finalSize)
return hostingController
}
Модификатор .fixedSize(horizontal: false, vertical: true) важен: он заставляет View занять минимально необходимую высоту при фиксированной ширине.
Вычисление размера
Стандартный intrinsicContentSize для SwiftUI часто возвращает некорректные значения. Мы используем Auto Layout:
extension UIView {
func fittingSize() -> CGSize {
systemLayoutSizeFitting(
CGSize(
width: UIScreen.main.bounds.width,
height: UIView.layoutFittingCompressedSize.height
),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
}
}
Ширина фиксируется по экрану, высота вычисляется с минимальным приоритетом — это даёт корректный размер для большинства компонентов.
Проблема с ячейками и сепаратором
При снепшот-тестировании UITableViewCell возникает неочевидная проблема: ячейка учитывает высоту сепаратора даже когда он скрыт. Сепаратор занимает ровно 1 физический пиксель, что в points составляет:
@1x: 1pt
@2x: 0.5pt
@3x: ≈0.33pt
Эти дробные значения при вычислении размера через systemLayoutSizeFitting() приводят к ошибкам округления. При сравнении эталонов, сделанных на симуляторах с разным scale factor, тесты падают из-за расхождения в 1 пиксель.
Решение — работать с contentView вместо самой ячейки:
public func assertUITableViewCellSnapshot(
matching cell: UITableViewCell,
// ... параметры
) {
assertUIViewSnapshot(
matching: cell.contentView, // ключевой момент
// ... параметры
)
}
Почему precision 0.98?
По умолчанию мы используем precision: 0.98 и perceptualPrecision: 0.98.
Согласно исследованиям восприятия цвета, человеческий глаз не замечает разницы при значениях в диапазоне 0.98–1. Это позволяет игнорировать субпиксельные различия в антиалиасинге между симуляторами, сохраняя при этом чувствительность к реальным изменениям вёрстки.
Подробнее об этом — в статье моего коллеги Дмитрия Суркова о внедрении снепшот-тестов в нашу дизайн-систему.
Ограничения при работе со ScrollView
При использовании ScrollView возникает несколько проблем. Во-первых, размер вычисляется некорректно — ScrollView теоретически имеет неограниченную высоту. Во-вторых, библиотека игнорирует текущую позицию скролла.
Эти ограничения не специфичны для нашей реализации. В репозитории SnapshotTesting есть открытые issues:
#264 — позиция скролла в UITableView/UIScrollView игнорируется
#734 — ScrollViewReader.scrollTo() не срабатывает при снепшоте
#368 — некорректный размер при .sizeThatFits
#738 — проблемы с динамическим контентом
Для таких случаев задаём размер явно и используем .device(config:) вместо .sizeThatFits:
func testNotificationPreview() {
assertSnapshot(
matching: DSNotificationPreview()
.fixedSize(horizontal: false, vertical: true)
.ignoresSafeArea(.container, edges: .bottom),
as: .image(
precision: 0.98,
perceptualPrecision: 0.98,
layout: .device(config: .iPhoneX)
)
)
}
Вывод: SnapshotTesting — мощный инструмент, но не серебряная пуля. Часть проблем — это фундаментальные ограничения взаимодействия SwiftUI с Auto Layout системой UIKit, которые библиотека не может обойти.
Пример использования
func test_Notification() {
assertSwiftUIViewSnapshot {
DSNotification(
title: "Success",
text: "Everything worked well!",
state: .success,
expandableState: .nonExpandable
)
}
}
func test_DateInput() {
assertSwiftUIViewSnapshot {
DSDateInput(date: .constant(nil))
.set(\.topText, to: "Дата рождения")
.set(\.placeholder, to: "Выберите дату")
.set(\.bottomText, to: "В формате ДД.ММ.ГГГГ")
}
}
Каждый тест автоматически генерирует два снепшота — для светлой и тёмной темы.
Итоги
Снепшот-тестирование — не серебряная пуля, но для дизайн-системы это один из самых эффективных способов контролировать визуальную целостность компонентов.
Ключевые моменты нашего решения:
Используем UIHostingController + UIGraphicsImageRenderer вместо ImageRenderer — это работает с Representable-компонентами
Вычисляем размер через systemLayoutSizeFitting() вместо intrinsicContentSize
Для ячеек работаем с contentView, чтобы избежать проблем с сепаратором
Допуск 0.98 игнорирует субпиксельные различия, но ловит реальные изменения
Если у вас похожий стек или вопросы по реализации — пишите в комментариях.