Разработка Snapshot-тестов для мобильного приложения
Snapshot-тестирование — это автоматический контроль того, что UI не изменился незапланированно. Разработчик правит отступ в одном компоненте, забывает про экран настроек, где тот же компонент используется с другими пропсами — и через неделю в продакшене обнаруживается съехавшая вёрстка. Snapshot-тест поймал бы это за секунды.
Суть проста: при первом запуске тест сохраняет эталонное изображение или сериализованное дерево компонентов. При каждом следующем запуске сравнивает с эталоном. Расхождение = падающий тест.
iOS: iOSSnapshotTestCase
На iOS snapshot-тесты строятся на FBSnapshotTestCase (Facebook, переехал в pointfreeco/swift-snapshot-testing) или нативном XCTAttachment + ручное сравнение. Наиболее зрелое решение — pointfreeco/swift-snapshot-testing:
import SnapshotTesting
class ProfileViewControllerTests: XCTestCase {
func testProfileScreen() {
let vc = ProfileViewController(user: .mock)
assertSnapshot(of: vc, as: .image(on: .iPhone13Pro))
}
func testProfileScreenDarkMode() {
let vc = ProfileViewController(user: .mock)
assertSnapshot(of: vc, as: .image(on: .iPhone13Pro(.landscape), traits: .init(userInterfaceStyle: .dark)))
}
}
assertSnapshot при первом запуске создаёт файл __Snapshots__/ProfileViewControllerTests/testProfileScreen.1.png. При следующих — сравнивает пиксель за пикселем. Порог — 0, любое отличие = fail.
Поддерживаемые стратегии снимка: .image (скриншот), .recursiveDescription (текстовое дерево view), .dump (структура). Для компонент-библиотек .image — основная.
Проблема с шрифтами в CI
Рендеринг текста на симуляторе может отличаться от рендеринга на CI-машине из-за разных версий системных шрифтов. Решение — фиксировать симулятор (iPhone 15, iOS 17.4) и использовать одну и ту же версию Xcode. В fastlane через xcversion:
xcversion(version: "~> 15.3")
Android: Paparazzi
Для Android лучший инструмент — Paparazzi от Square. Он не требует эмулятора: рендерит View через LayoutInflater в JVM-окружении, используя layoutlib (тот же движок, что и Android Studio Preview).
class ButtonSnapshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "Theme.App"
)
@Test
fun primaryButton() {
paparazzi.snapshot {
PrimaryButton(
text = "Сохранить",
onClick = {}
)
}
}
}
Запуск: ./gradlew recordPaparazziDebug (запись эталонов) и ./gradlew verifyPaparazziDebug (проверка).
Paparazzi поддерживает Jetpack Compose с paparazzi.snapshot { ComposableFunction() } — та же механика.
Главное преимущество перед screenshot-тестами с эмулятором: скорость. Paparazzi-тест выполняется за 200–500 мс против 5–10 секунд на эмуляторе. Для библиотеки на 200 компонентов разница в 30 минут vs 3 минуты.
Flutter: Golden Tests
В Flutter snapshot-тесты называются Golden Tests и являются частью flutter_test:
testWidgets('CustomCard golden', (tester) async {
await tester.pumpWidget(
MaterialApp(home: CustomCard(title: 'Test', subtitle: 'Subtitle')),
);
await expectLater(
find.byType(CustomCard),
matchesGoldenFile('goldens/custom_card.png'),
);
});
Обновление эталонов: flutter test --update-goldens.
Платформо-зависимость golden-файлов — известная проблема. macOS рендерит шрифт иначе, чем Linux (CI). Решение: хранить golden-файлы, сгенерированные на CI (Linux), а локально разработчики используют flutter test --update-goldens только на той же ОС. Либо пакет golden_toolkit с loadAppFonts(), который нивелирует часть различий.
Управление базовыми снимками в git
Эталонные снимки хранятся в репозитории. Несколько правил:
-
__Snapshots__/,src/test/snapshots/,test/goldens/— добавляем в.gitattributesкак бинарные:*.png binary - Pull Request с изменением UI должен включать обновлённые снимки:
git add test/goldens/ && git commit -m "update snapshots" - В CI запускаем только проверку (
verify), не запись (record). Запись — только локально или через специальный workflow
Если CI падает из-за расхождения снимков — это не ошибка тестов, это сигнал о незапланированном изменении UI. Хорошо.
Сроки
2–3 дня — настройка инструмента + написание snapshot-тестов для ключевых компонентов. Полное покрытие компонентной библиотеки (50+ компонентов) — оцениваем отдельно. Стоимость рассчитывается индивидуально.







