Розробка Snapshot-тестів для мобільних додатків
Snapshot-тестування — це автоматичний контроль того, що UI не змінився незапланово. Розробник виправляє відступ в одному компоненті, забуває про екран налаштувань, де той же компонент використовується з іншими props — та через тиждень у продакшені виявляється зіхана верстка. 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+ компонентів) — оцінюємо окремо. Вартість розраховується індивідуально.







