Снепшот-тестирование — один из немногих надёжных способов контролировать визуальную целостность 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 игнорирует субпиксельные различия, но ловит реальные изменения

Если у вас похожий стек или вопросы по реализации — пишите в комментариях.