← Valtive QA Quiz quiz.valtive.io/notes/appium valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для автоматизаторів, які йдуть на співбесіду на мобільний напрям: Android, iOS або гібридні застосунки. Гайд пояснює, як команда з тесту доходить до пристрою через Appium 2 і драйвер, як писати capabilities і локатори, як робити жести, перемикатись у WebView і як тримати мобільний набір стабільним у CI та на фермі пристроїв. Кожне питання квізу Appium покрите щонайменше однією карткою.
For automation engineers interviewing for a mobile role: Android, iOS or hybrid apps. The guide explains how a command travels from a test to the device through Appium 2 and a driver, how to write capabilities and locators, how to perform gestures, switch into a WebView, and keep a mobile suite stable in CI and on a device farm. Every question of the Appium quiz is covered by at least one card.
Як користуватисьHow to use it
Фільтруй картки за рівнем або розділом. Прочитай відповідь, потім запусти приклад на емуляторі чи симуляторі: Java-код розрахований на java-client 9, JS-код — на WebdriverIO. Наприкінці закрий текст і перекажи відповідь уголос за блоком 🗣 Як читати вголос — так ти тренуєш живу відповідь інтерв'юеру, а не лише впізнавання тексту.
Filter cards by level or section. Read the answer, then run the example on an emulator or simulator: the Java code targets java-client 9 and the JS code targets WebdriverIO. Finally, close the text and retell the answer aloud using the 🗣 How to say it out loud block — that trains a live answer to an interviewer, not just recognising text on a page.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · Клієнт → сервер → драйверANCHOR 1 · Client → server → driver
Тест лише шле HTTP-команди WebDriver. Appium-сервер передає їх драйверу (UiAutomator2, XCUITest), а той — нативному фреймворку платформи. Більшість «магічних» помилок сидить на одному з цих трьох рівнів.
A test only sends WebDriver HTTP commands. The Appium server hands them to a driver (UiAutomator2, XCUITest), and the driver calls the platform's native framework. Most "magic" failures live on one of these three levels.
ЯКІР 2 · Дерево UI, а не пікселіANCHOR 2 · The UI tree, not pixels
Локатор шукає вузол у дереві доступності платформи. Accessibility id і resource-id живуть довше за XPath і координати, а HTML у WebView взагалі видно лише після перемикання контексту.
A locator looks for a node in the platform's accessibility tree. Accessibility id and resource-id outlive XPath and coordinates, and HTML inside a WebView is only visible after switching the context.
ЯКІР 3 · Стан пристрою — частина тестуANCHOR 3 · Device state is part of the test
Дані застосунку, дозволи, мова, анімації, мережа й порти драйвера впливають на результат. Стабільний мобільний набір явно задає цей стан і збирає логи пристрою, коли щось падає.
App data, permissions, locale, animations, network and driver ports all affect the outcome. A stable mobile suite sets that state explicitly and collects device logs when something fails.
46
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
50/50
питань квізу покритоquiz questions covered
📚 6 РОЗДІЛІВ📚 6 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 7
1 Що таке Appium і яким шляхом команда з тесту доходить до пристрою? What is Appium and what path does a command take from the test to the device?
Архітектура Architecture Junior ▶

Що це. Appium — це open-source сервер автоматизації, який керує нативними, гібридними й мобільними веб-застосунками через протокол W3C WebDriver. Тест не знає нічого про Android чи iOS: він надсилає HTTP-запити на кшталт «знайди елемент» або «клікни», а сервер і драйвер перекладають їх мовою платформи. Сам застосунок перекомпільовувати не потрібно — тестується той самий APK чи IPA, що піде в магазин.

What it is. Appium is an open-source automation server that drives native, hybrid and mobile web apps over the W3C WebDriver protocol. The test knows nothing about Android or iOS: it sends HTTP requests such as "find element" or "click", and the server plus a driver translate them into the platform's language. The app does not have to be recompiled — you test the same APK or IPA that ships to the store.

Навіщо розуміти шлях. Ланцюжок виглядає так: клієнтська бібліотека (java-client, WebdriverIO, Python) → Appium-сервер на Node.js → драйвер платформи (UiAutomator2 для Android, XCUITest для iOS) → нативний фреймворк на пристрої (UiAutomator, XCTest через WebDriverAgent). Коли щось ламається, знання шару одразу звужує пошук: помилка 404 на /session — це клієнт і сервер, «could not find element» — дерево UI, «xcodebuild failed» — збірка WebDriverAgent.

Why the path matters. The chain is: client library (java-client, WebdriverIO, Python) → Appium server on Node.js → platform driver (UiAutomator2 for Android, XCUITest for iOS) → native framework on the device (UiAutomator, or XCTest through WebDriverAgent). When something breaks, knowing the layer narrows the search right away: a 404 on /session is client and server, "could not find element" is the UI tree, "xcodebuild failed" is the WebDriverAgent build.

Приклад. Команда driver.findElement(AppiumBy.accessibilityId("login")).click() стає двома запитами: POST /session/{id}/element і POST /session/{id}/element/{elementId}/click. Сервер знаходить сесію, віддає команду драйверу UiAutomator2, а той через свій серверний APK на телефоні викликає UiAutomator.

Example. The call driver.findElement(AppiumBy.accessibilityId("login")).click() becomes two requests: POST /session/{id}/element and POST /session/{id}/element/{elementId}/click. The server looks up the session and hands the command to the UiAutomator2 driver, which calls UiAutomator through its server APK on the phone.

Підводні камені. В Appium 2 драйвери не входять у сервер — після npm i -g appium сесія не стартує, доки не виконаєш appium driver install uiautomator2. І базовий шлях тепер /, а не /wd/hub, тож старі URL дають 404.

Pitfalls. In Appium 2 drivers are not bundled with the server — after npm i -g appium no session starts until you run appium driver install uiautomator2. The base path is now / rather than /wd/hub, so old URLs return 404.

Уточнююче питання. «Чи потрібен вихідний код застосунку?» — ні, Appium працює з готовою збіркою, хоча для стабільних локаторів розробники мають проставити accessibility-ідентифікатори.

Follow-up question. "Do you need the app's source code?" No, Appium works with a built binary, although developers need to add accessibility identifiers for stable locators.

Шлях однієї команди
The path of a single command
 Test (java-client / WebdriverIO)
        |  HTTP, W3C WebDriver JSON
        v
 Appium server (Node.js, :4723)
        |  routes by appium:automationName
        v
 Driver: UiAutomator2  |  XCUITest
        |                   |
        v                   v
 UiAutomator2 server   WebDriverAgent
 APK on the device     (XCTest runner)
        |                   |
        v                   v
    Android app          iOS app
Встановлення і перша сесія
Installation and the first session
npm i -g appium
appium driver install uiautomator2
appium driver install xcuitest
appium driver list --installed
appium --port 4723          # базовий шлях за замовчуванням — /
npm i -g appium
appium driver install uiautomator2
appium driver install xcuitest
appium driver list --installed
appium --port 4723          # the default base path is /
🔧 Розбір синтаксису: Стрілки на схемі — межі процесів: клієнт і сервер спілкуються HTTP-запитами з JSON, сервер обирає драйвер за appium:automationName, а драйвер тримає власний агент на пристрої (серверний APK або WebDriverAgent). appium driver install кладе драйвер у каталог APPIUM_HOME (за замовчуванням ~/.appium), --installed показує, що вже є.
🔧 Syntax breakdown: The arrows in the diagram are process boundaries: client and server talk over HTTP with JSON, the server picks a driver by appium:automationName, and the driver keeps its own agent on the device (a server APK or WebDriverAgent). appium driver install puts the driver into the APPIUM_HOME folder (~/.appium by default), and --installed lists what is already there.
💡 Простими словами: Тест говорить із сервером, сервер — з драйвером, драйвер — з телефоном. Знаєш, на якому поверсі помилка, — знаєш, де шукати.
💡 In plain words: The test talks to the server, the server to the driver, the driver to the phone. Know which floor the error is on and you know where to look.
🗣 Як читати вголос Я пояснюю Appium як ланцюжок із трьох ланок. Мій тест — просто WebDriver-клієнт, він шле HTTP-команди. Appium-сервер приймає їх і за automationName віддає потрібному драйверу: UiAutomator2 на Android або XCUITest на iOS. А драйвер уже говорить з нативним фреймворком через свій агент на пристрої. Застосунок я не перезбираю. І одразу згадую нюанс Appium 2: драйвери ставляться окремо командою appium driver install, а базовий шлях став просто слеш, без wd/hub.
🗣 How to say it out loud I describe Appium as a chain of three links. My test is just a WebDriver client that sends HTTP commands. The Appium server receives them and, based on automationName, passes them to the right driver: UiAutomator2 on Android or XCUITest on iOS. The driver then talks to the native framework through its own agent on the device. I don't rebuild the app. And I mention the Appium 2 detail straight away: drivers are installed separately with appium driver install, and the base path is now just a slash, not wd/hub.
2 Що змінилося в Appium 2: навіщо драйвери й плагіни встановлюються окремо? What changed in Appium 2: why are drivers and plugins installed separately?
Архітектура Architecture Junior ▶

Що це. В Appium 1 усі драйвери їхали всередині одного npm-пакета, і оновлення iOS-частини тягнуло за собою оновлення всього сервера. Appium 2 розділив ядро і розширення: сервер — це лише маршрутизатор команд, драйвери (UiAutomator2, XCUITest, Espresso, Mac2, Chromium) і плагіни (images, execute-driver, inspector, relaxed-caps) встановлюються окремо через CLI appium driver та appium plugin.

What it is. In Appium 1 every driver shipped inside one npm package, so updating the iOS part meant updating the whole server. Appium 2 split the core from extensions: the server is only a command router, while drivers (UiAutomator2, XCUITest, Espresso, Mac2, Chromium) and plugins (images, execute-driver, inspector, relaxed-caps) are installed separately with the appium driver and appium plugin CLI.

Навіщо. Кожен драйвер має власний цикл релізів: можна оновити XCUITest під нову версію Xcode, не чіпаючи Android. Команда тримає в CI лише потрібні драйвери, а сторонні автори публікують свої драйвери й плагіни як звичайні npm-пакети. Плагін змінює поведінку сервера: наприклад, images додає пошук елементів за картинкою, а inspector відкриває Appium Inspector прямо в браузері на тому ж порту.

Why. Each driver has its own release cycle: you can update XCUITest for a new Xcode without touching Android. A team keeps only the drivers it needs in CI, and third-party authors publish drivers and plugins as ordinary npm packages. A plugin changes server behaviour: images, for example, adds finding elements by picture, and inspector serves Appium Inspector in the browser on the same port.

Приклад. На CI фіксуємо версії, щоб збірка була відтворюваною: appium driver install uiautomator2@3.x замість «останньої», а потім appium driver doctor uiautomator2 перевіряє ANDROID_HOME, JAVA_HOME і adb. Плагін треба не лише встановити, а й увімкнути при старті: appium --use-plugins=images.

Example. In CI we pin versions so the build is reproducible: appium driver install uiautomator2@3.x instead of "latest", then appium driver doctor uiautomator2 checks ANDROID_HOME, JAVA_HOME and adb. A plugin must not only be installed but also enabled at start-up: appium --use-plugins=images.

Підводні камені. Драйвери лежать в APPIUM_HOME; якщо на агенті CI два користувачі або два різні APPIUM_HOME, сервер «не бачить» драйвер. Встановлений, але не активований плагін нічого не робить. Ще одна пастка — несумісність версій: новий драйвер може вимагати новішого ядра Appium, тож оновлюють їх разом за changelog.

Pitfalls. Drivers live in APPIUM_HOME; if a CI agent has two users or two different APPIUM_HOME values, the server "cannot see" the driver. An installed plugin that is not activated does nothing. Another trap is version mismatch: a new driver may require a newer Appium core, so the two are upgraded together following the changelog.

Уточнююче питання. «Як оновити драйвер?» — appium driver update xcuitest; для мажорних версій додають прапорець --unsafe, бо можуть змінитися capabilities.

Follow-up question. "How do you update a driver?" With appium driver update xcuitest; for major versions you add the --unsafe flag, because capabilities may have changed.

Керування драйверами і плагінами
Managing drivers and plugins
appium driver list --installed
appium driver install uiautomator2
appium driver install xcuitest
appium driver install espresso
appium driver doctor uiautomator2      # перевіряє SDK, JDK, adb
appium driver update xcuitest --unsafe # мажорне оновлення

appium plugin install images
appium plugin install inspector
appium --use-plugins=images,inspector  # без цього плагін не активний
appium driver list --installed
appium driver install uiautomator2
appium driver install xcuitest
appium driver install espresso
appium driver doctor uiautomator2      # checks SDK, JDK, adb
appium driver update xcuitest --unsafe # major upgrade

appium plugin install images
appium plugin install inspector
appium --use-plugins=images,inspector  # without this the plugin is inactive
🔧 Розбір синтаксису: driver list --installed показує драйвери з версіями; driver doctor запускає перевірки середовища, які драйвер сам оголосив; --unsafe дозволяє стрибок через мажорну версію. --use-plugins приймає список через кому, і лише ці плагіни будуть підключені до кожної сесії.
🔧 Syntax breakdown: driver list --installed prints the drivers with their versions; driver doctor runs the environment checks the driver declares itself; --unsafe allows a jump across a major version. --use-plugins takes a comma-separated list, and only those plugins are attached to every session.
💡 Простими словами: Сервер — це розетка, драйвери — вилки для різних країн. Встановлюєш лише ті, що потрібні.
💡 In plain words: The server is the socket, drivers are plugs for different countries. You install only the ones you need.
🗣 Як читати вголос Головна зміна Appium 2 — ядро окремо від драйверів і плагінів. Сервер тепер лише маршрутизує команди, а UiAutomator2, XCUITest чи Espresso я ставлю окремо через appium driver install. Мені це подобається тим, що iOS-драйвер можна оновити під новий Xcode, не зачепивши Android. Плагіни розширюють сервер, наприклад пошук за картинкою, але їх треба явно ввімкнути прапорцем use-plugins. На CI я фіксую версії драйверів і запускаю driver doctor перед тестами.
🗣 How to say it out loud The key change in Appium 2 is that the core is separate from drivers and plugins. The server now only routes commands, and I install UiAutomator2, XCUITest or Espresso separately with appium driver install. What I like is that I can update the iOS driver for a new Xcode without touching Android. Plugins extend the server, for example finding by image, but you must enable them explicitly with the use-plugins flag. In CI I pin driver versions and run driver doctor before the tests.
3 Як працює драйвер UiAutomator2 і як стартувати Android-сесію через UiAutomator2Options? How does the UiAutomator2 driver work and how do you start an Android session with UiAutomator2Options?
Архітектура Architecture Junior ▶

Що це. UiAutomator2 — стандартний драйвер Appium для Android. На початку сесії він встановлює на пристрій два службові APK (io.appium.uiautomator2.server і його test-пакет), запускає їх через instrumentation, і далі всі команди йдуть HTTP-запитами до цього сервера всередині телефона. Той уже викликає Google UiAutomator, який бачить будь-який застосунок, системні діалоги й шторку сповіщень.

What it is. UiAutomator2 is Appium's standard Android driver. At session start it installs two helper APKs on the device (io.appium.uiautomator2.server and its test package), launches them through instrumentation, and from then on every command is an HTTP request to that server on the phone. The server calls Google UiAutomator, which can see any app, system dialogs and the notification shade.

Навіщо. Це драйвер «чорної скриньки»: не треба вихідного коду, підходить для будь-якого APK, вміє працювати з дозволами, налаштуваннями і кількома застосунками в одному тесті. У java-client 9 зручно збирати capabilities через типізований клас UiAutomator2Options — він сам додає префікс appium: і не дасть зробити помилку в назві.

Why. It is a black-box driver: no source code needed, works with any APK, and can handle permissions, settings and several apps in one test. In java-client 9 capabilities are best built with the typed UiAutomator2Options class — it adds the appium: prefix itself and prevents typos in names.

Приклад. Нижче — сесія з локальним APK на емуляторі. Для Java 20+ URL будують через URI.create(...).toURL(), бо конструктор new URL(String) застарів.

Example. Below is a session with a local APK on an emulator. On Java 20+ the URL is built through URI.create(...).toURL(), because the new URL(String) constructor is deprecated.

Підводні камені. Перший старт повільний: встановлення серверних APK займає секунди, тому appium:skipServerInstallation вмикають лише коли версії гарантовано збігаються. На пристроях Xiaomi чи Huawei треба дозволити «Install via USB» і вимкнути оптимізацію MIUI, інакше APK сервера не встановиться. Поширена помилка — adbExecTimeout на повільних емуляторах; його збільшують, а не ставлять sleep.

Pitfalls. The first start is slow: installing the server APKs takes seconds, so appium:skipServerInstallation is only switched on when versions are guaranteed to match. On Xiaomi or Huawei devices you have to allow "Install via USB" and disable MIUI optimisation, otherwise the server APK will not install. A common error is adbExecTimeout on slow emulators; you raise it rather than add a sleep.

Уточнююче питання. «Чим UiAutomator2 відрізняється від Espresso-драйвера?» — UiAutomator2 працює ззовні застосунку і бачить систему, Espresso — всередині процесу застосунку і синхронізується з його головним потоком.

Follow-up question. "How is UiAutomator2 different from the Espresso driver?" UiAutomator2 works from outside the app and sees the system; Espresso runs inside the app process and synchronises with its main thread.

Android-сесія на java-client 9
An Android session with java-client 9
UiAutomator2Options options = new UiAutomator2Options()
    .setUdid("emulator-5554")
    .setApp("/builds/app-debug.apk")
    .setAppPackage("com.example.shop")
    .setAppActivity(".ui.MainActivity")
    .setNewCommandTimeout(Duration.ofSeconds(120))
    .setAdbExecTimeout(Duration.ofSeconds(60));

AndroidDriver driver = new AndroidDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);
try {
    driver.findElement(AppiumBy.accessibilityId("login")).click();
} finally {
    driver.quit(); // звільняє порт і сесію
}
UiAutomator2Options options = new UiAutomator2Options()
    .setUdid("emulator-5554")
    .setApp("/builds/app-debug.apk")
    .setAppPackage("com.example.shop")
    .setAppActivity(".ui.MainActivity")
    .setNewCommandTimeout(Duration.ofSeconds(120))
    .setAdbExecTimeout(Duration.ofSeconds(60));

AndroidDriver driver = new AndroidDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);
try {
    driver.findElement(AppiumBy.accessibilityId("login")).click();
} finally {
    driver.quit(); // frees the port and the session
}
🔧 Розбір синтаксису: Кожен setXxx у UiAutomator2Options записує capability з префіксом appium: (наприклад, setUdid → appium:udid), а platformName і automationName клас виставляє сам. Duration задає таймаути без плутанини секунд і мілісекунд. quit() у finally гарантує, що сесія не зависне на сервері після падіння.
🔧 Syntax breakdown: Each setXxx in UiAutomator2Options writes a capability with the appium: prefix (for example setUdid → appium:udid), and the class fills in platformName and automationName on its own. Duration sets timeouts without mixing up seconds and milliseconds. quit() in finally makes sure the session does not hang on the server after a failure.
💡 Простими словами: UiAutomator2 ставить на телефон маленький сервер-посередник і керує застосунком ззовні, як дуже швидкий користувач.
💡 In plain words: UiAutomator2 puts a small proxy server on the phone and drives the app from outside, like a very fast user.
🗣 Як читати вголос UiAutomator2 — це основний Android-драйвер. На старті сесії він встановлює на телефон свій серверний APK, і далі кожна моя команда йде туди, а він викликає UiAutomator від Google. Тому він бачить і мій застосунок, і системні діалоги. У Java я збираю capabilities через UiAutomator2Options: він сам ставить префікс appium і прибирає одруківки. Із практики: на китайських телефонах треба окремо дозволити встановлення через USB, інакше сервер драйвера просто не стане.
🗣 How to say it out loud UiAutomator2 is the main Android driver. At session start it installs its server APK on the phone, and from then on each of my commands goes there and it calls Google's UiAutomator. That is why it sees both my app and system dialogs. In Java I build capabilities with UiAutomator2Options: it adds the appium prefix and removes typos. From experience: on some Chinese phones you have to allow USB installs separately, otherwise the driver's server simply won't install.
4 Як працює XCUITest-драйвер, що таке WebDriverAgent і які пастки з підписом на реальному iPhone? How does the XCUITest driver work, what is WebDriverAgent and what are the signing traps on a real iPhone?
Архітектура Architecture Middle ▶

Що це. XCUITest — основний драйвер Appium для iOS і tvOS. Він збирає через xcodebuild проєкт WebDriverAgent (WDA) — це XCTest-раннер із вбудованим HTTP-сервером, — запускає його на симуляторі чи пристрої і пересилає йому команди. WDA за замовчуванням слухає порт 8100 на пристрої, а драйвер прокидає його на хост через appium:wdaLocalPort.

What it is. XCUITest is Appium's main driver for iOS and tvOS. It builds the WebDriverAgent (WDA) project with xcodebuild — an XCTest runner with an embedded HTTP server — launches it on a simulator or device and forwards commands to it. WDA listens on port 8100 on the device by default, and the driver forwards it to the host through appium:wdaLocalPort.

Навіщо знати WDA. На симуляторі WDA збирається без підпису, і все працює «з коробки». На реальному iPhone кожен XCTest-раннер мусить бути підписаний сертифікатом розробника з вашої команди Apple, інакше iOS його не запустить. Саме тут виникає більшість падінь «xcodebuild failed with code 65».

Why WDA matters. On a simulator WDA is built without signing and everything works out of the box. On a real iPhone every XCTest runner must be signed with a developer certificate from your Apple team, otherwise iOS refuses to launch it. That is where most "xcodebuild failed with code 65" failures come from.

Приклад. Для реального пристрою вказують Team ID (appium:xcodeOrgId), ідентичність підпису Apple Development і унікальний bundle id для WDA (appium:updatedWDABundleId), бо стандартний com.facebook.WebDriverAgentRunner уже зайнятий у чужих профілях. appium:showXcodeLog показує справжню причину помилки збірки.

Example. For a real device you set the Team ID (appium:xcodeOrgId), the signing identity Apple Development and a unique bundle id for WDA (appium:updatedWDABundleId), because the default com.facebook.WebDriverAgentRunner is already taken in other people's profiles. appium:showXcodeLog reveals the real cause of a build error.

Підводні камені. Безкоштовний Apple ID дає профіль лише на 7 днів — у понеділок ферма «раптом» не стартує. На iOS 16+ треба ввімкнути Developer Mode, а після першого встановлення — довіряти розробнику в Settings → General → VPN & Device Management. Збірка WDA займає хвилину, тому на CI використовують заздалегідь зібраний раннер (appium:usePrebuiltWDA разом із appium:derivedDataPath).

Pitfalls. A free Apple ID gives a profile valid for only 7 days — on Monday the farm "suddenly" fails to start. iOS 16+ needs Developer Mode turned on, and after the first install you must trust the developer in Settings → General → VPN & Device Management. Building WDA takes a minute, so CI uses a prebuilt runner (appium:usePrebuiltWDA together with appium:derivedDataPath).

Уточнююче питання. «Чому паралельні iOS-сесії конфліктують?» — усі використовують порт 8100 і спільний DerivedData; кожній потрібні свої wdaLocalPort і каталог збірки.

Follow-up question. "Why do parallel iOS sessions clash?" They all use port 8100 and a shared DerivedData; each needs its own wdaLocalPort and build folder.

XCUITestOptions для реального iPhone
XCUITestOptions for a real iPhone
XCUITestOptions options = new XCUITestOptions()
    .setUdid("00008110-001A2B3C4D5E801E")
    .setBundleId("com.example.shop")
    .setWdaLocalPort(8101)
    .setShowXcodeLog(true);
// підпис WebDriverAgent — дані вашої команди Apple
options.setCapability("appium:xcodeOrgId", "A1B2C3D4E5");
options.setCapability("appium:xcodeSigningId", "Apple Development");
options.setCapability("appium:updatedWDABundleId", "com.example.wda");

IOSDriver driver = new IOSDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);
XCUITestOptions options = new XCUITestOptions()
    .setUdid("00008110-001A2B3C4D5E801E")
    .setBundleId("com.example.shop")
    .setWdaLocalPort(8101)
    .setShowXcodeLog(true);
// WebDriverAgent signing — your Apple team's data
options.setCapability("appium:xcodeOrgId", "A1B2C3D4E5");
options.setCapability("appium:xcodeSigningId", "Apple Development");
options.setCapability("appium:updatedWDABundleId", "com.example.wda");

IOSDriver driver = new IOSDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);
🔧 Розбір синтаксису: Типізовані сетери покривають часті capabilities, а рідкісні задаються через setCapability з повною назвою і префіксом appium:. xcodeOrgId — 10-символьний Team ID з Apple Developer, xcodeSigningId — тип сертифіката, updatedWDABundleId — bundle id, під який перепідписується WDA.
🔧 Syntax breakdown: The typed setters cover common capabilities, while rare ones are set through setCapability with the full name and the appium: prefix. xcodeOrgId is the 10-character Team ID from Apple Developer, xcodeSigningId is the certificate type, and updatedWDABundleId is the bundle id WDA is re-signed under.
💡 Простими словами: На iPhone Appium керує не напряму, а через маленький застосунок-посередник WDA, і Apple пускає його лише з правильним підписом.
💡 In plain words: On an iPhone Appium does not drive the app directly but through a small helper app, WDA, and Apple only lets it run with a proper signature.
🗣 Як читати вголос На iOS між Appium і застосунком стоїть WebDriverAgent — XCTest-раннер з HTTP-сервером, який драйвер збирає через xcodebuild. На симуляторі він працює одразу, а на реальному телефоні його треба підписати сертифікатом команди, тому я вказую xcodeOrgId, signing id і свій bundle id для WDA. Коли бачу код 65, першим ділом вмикаю showXcodeLog. І пам'ятаю побутові речі: Developer Mode, довіра до розробника, термін дії профілю. На CI беру заздалегідь зібраний WDA, щоб не чекати хвилину на кожну сесію.
🗣 How to say it out loud On iOS there is WebDriverAgent between Appium and the app — an XCTest runner with an HTTP server that the driver builds with xcodebuild. On a simulator it just works, but on a real phone it must be signed with the team certificate, so I set xcodeOrgId, the signing id and my own bundle id for WDA. When I see code 65, the first thing I do is turn on showXcodeLog. I also remember the mundane parts: Developer Mode, trusting the developer, profile expiry. In CI I use a prebuilt WDA so each session doesn't wait a minute.
5 Що таке Espresso-драйвер в Appium і коли його обирати замість UiAutomator2? What is the Espresso driver in Appium and when would you choose it over UiAutomator2?
Архітектура Architecture Middle ▶

Що це. Espresso-драйвер (appium driver install espresso) — альтернативний Android-драйвер. Він збирає тестовий сервер на базі Google Espresso і запускає його як instrumentation всередині процесу застосунку. Тобто це «сіра скринька»: драйвер бачить не лише дерево доступності, а й внутрішні View, стан головного потоку і черги повідомлень.

What it is. The Espresso driver (appium driver install espresso) is an alternative Android driver. It builds a test server based on Google Espresso and runs it as instrumentation inside the app's process. That makes it grey-box: the driver sees not only the accessibility tree but also internal Views, the main thread state and the message queues.

Навіщо. Головна перевага — вбудована синхронізація: Espresso чекає, поки головний потік простоює й асинхронні задачі завершились, тож явних очікувань потрібно менше, а тести швидші. Також доступні специфічні команди, наприклад mobile: backdoor для виклику методу Activity або прямий доступ до Compose-вузлів. Це корисно, коли в команді є Android-розробники, які готові підтримувати тестові гачки.

Why. Its main advantage is built-in synchronisation: Espresso waits until the main thread is idle and async work has finished, so fewer explicit waits are needed and tests run faster. It also exposes specific commands, such as mobile: backdoor to call a method on the Activity, or direct access to Compose nodes. That pays off when the team has Android developers willing to maintain test hooks.

Приклад. Для перемикання змінюють automationName на Espresso через EspressoOptions, а APK має бути підписаний тим самим ключем, що й тестовий сервер, — драйвер перепідписує збірку або використовує ваш keystore.

Example. To switch, you change automationName to Espresso through EspressoOptions, and the APK must be signed with the same key as the test server — the driver re-signs the build or uses your keystore.

Підводні камені. Espresso не бачить нічого поза своїм застосунком: системний діалог дозволу, шторка сповіщень чи інший застосунок — недосяжні. Сервер має збігатися з версіями Kotlin, AGP і compileSdk застосунку; при розбіжності сесія падає при збірці, і доводиться задавати appium:espressoBuildConfig. Release-збірки з обфускацією R8 часто ламають пошук за внутрішніми іменами.

Pitfalls. Espresso sees nothing outside its own app: a system permission dialog, the notification shade or another app are out of reach. The server must match the app's Kotlin, AGP and compileSdk versions; on a mismatch the session fails at build time and you have to set appium:espressoBuildConfig. Release builds obfuscated by R8 often break lookups by internal names.

Уточнююче питання. «Чи можна змішати драйвери в одному наборі?» — так, але кожна сесія має один драйвер; зазвичай Espresso беруть для внутрішніх сценаріїв, а UiAutomator2 — для наскрізних з системними екранами.

Follow-up question. "Can you mix drivers in one suite?" Yes, but each session has one driver; typically Espresso covers in-app scenarios and UiAutomator2 covers end-to-end flows with system screens.

Сесія Espresso і backdoor-виклик
An Espresso session and a backdoor call
EspressoOptions options = new EspressoOptions()
    .setUdid("emulator-5554")
    .setApp("/builds/app-debug.apk");

AndroidDriver driver = new AndroidDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);

// виклик тестового методу Activity замість довгого UI-флоу
driver.executeScript("mobile: backdoor", Map.of(
    "target", "activity",
    "methods", List.of(Map.of("name", "seedCartForTests"))));
EspressoOptions options = new EspressoOptions()
    .setUdid("emulator-5554")
    .setApp("/builds/app-debug.apk");

AndroidDriver driver = new AndroidDriver(
    URI.create("http://127.0.0.1:4723").toURL(), options);

// call a test method on the Activity instead of a long UI flow
driver.executeScript("mobile: backdoor", Map.of(
    "target", "activity",
    "methods", List.of(Map.of("name", "seedCartForTests"))));
🔧 Розбір синтаксису: EspressoOptions виставляє appium:automationName=Espresso. mobile: backdoor приймає target (activity, application або element) і список методів з іменами й аргументами; метод має реально існувати в debug-збірці, тому це домовленість із розробниками.
🔧 Syntax breakdown: EspressoOptions sets appium:automationName=Espresso. mobile: backdoor takes a target (activity, application or element) and a list of methods with names and arguments; the method must actually exist in the debug build, so it is an agreement with the developers.
💡 Простими словами: UiAutomator2 дивиться на застосунок ззовні, Espresso сидить усередині нього. Зсередини швидше, але за стіни не видно.
💡 In plain words: UiAutomator2 looks at the app from outside, Espresso sits inside it. From inside it is faster, but you cannot see past the walls.
🗣 Як читати вголос Espresso-драйвер я розглядаю як сіру скриньку. Його сервер працює всередині процесу застосунку, тому він сам чекає, поки головний потік вільний, і тести виходять швидшими з меншою кількістю очікувань. Є й бонуси на кшталт backdoor, щоб підготувати дані через метод застосунку. Але за межі застосунку він не бачить: системні дозволи чи сповіщення з ним не протестуєш. Ще він чутливий до версій Kotlin і Gradle. Тож я беру його там, де є підтримка від Android-розробників.
🗣 How to say it out loud I see the Espresso driver as grey-box. Its server runs inside the app process, so it waits by itself until the main thread is free, and tests come out faster with fewer waits. There are bonuses like backdoor to seed data through an app method. But it can't see beyond the app: you won't test system permissions or notifications with it. It's also sensitive to Kotlin and Gradle versions. So I choose it where Android developers are on board to support it.
6 Як ти спроєктуєш інфраструктуру Appium-серверів: один сервер на пристрій чи багато сесій на одному, логи, безпека? How would you design the Appium server infrastructure: one server per device or many sessions on one, logging, security?
Архітектура Architecture Senior ▶

Що це. Appium-сервер — це Node.js-процес, який приймає W3C-команди, тримає таблицю сесій і маршрутизує кожну команду в драйвер. Один сервер технічно обслуговує кілька сесій одночасно, якщо в них різні пристрої й порти. Архітектурне питання — як розкласти сервери, щоб падіння однієї сесії не валило інші і щоб логи можна було читати.

What it is. The Appium server is a Node.js process that accepts W3C commands, keeps a session table and routes each command to a driver. One server can technically serve several sessions at once if they use different devices and ports. The architectural question is how to lay out servers so that one failing session does not bring down the others and the logs stay readable.

Навіщо / трейдофи. Варіант «один сервер на пристрій» дає ізоляцію: окремий лог-файл, окремий порт, перезапуск без впливу на сусідів, а зависання драйвера не блокує інші телефони. Ціна — більше процесів і конфігурації. Варіант «один сервер на хост» простіший в обслуговуванні, але логи змішуються, а витік пам'яті в одному драйвері б'є по всіх. На фермах середнього розміру зазвичай ставлять сервер на пристрій і зверху Selenium Grid 4 з relay-вузлами або власний маршрутизатор.

Why / trade-offs. "One server per device" gives isolation: a separate log file, a separate port, restarts that do not affect neighbours, and a hung driver does not block other phones. The price is more processes and configuration. "One server per host" is simpler to run, but logs get interleaved and a memory leak in one driver hits everybody. Mid-size farms usually run a server per device with Selenium Grid 4 relay nodes or their own router on top.

Приклад. Сервер для пристрою №3 стартує з власним портом, лог-файлом із часовими мітками та лише тими небезпечними можливостями, які справді потрібні — наприклад adb_shell для очищення даних. --relaxed-security на спільному сервері — ризик: будь-який тест зможе виконати shell-команду на хості.

Example. The server for device #3 starts with its own port, a timestamped log file and only the insecure features that are actually needed — for example adb_shell to clear data. --relaxed-security on a shared server is a risk: any test could run a shell command on the host.

Підводні камені. Сесії, які не закрили через quit(), тримають пристрій до newCommandTimeout; при 60 с за замовчуванням і великому паралелізмі це черги. Ще одна пастка — лог рівня debug на кожному сервері швидко забиває диск агента, тому потрібна ротація.

Pitfalls. Sessions not closed with quit() hold the device until newCommandTimeout; with the 60-second default and high parallelism that means queues. Another trap is debug-level logging on every server, which quickly fills the agent's disk, so rotation is needed.

Уточнююче питання. «Як моніторити здоров'я?» — опитувати GET /status, збирати кількість активних сесій і час старту сесії як метрики.

Follow-up question. "How do you monitor health?" Poll GET /status, and export the number of active sessions and session start time as metrics.

Сервер на пристрій з ізольованим логом
A server per device with an isolated log
# пристрій 3: свій порт, свій лог, лише потрібні небезпечні фічі
appium --address 127.0.0.1 --port 4725 \
  --log /var/log/appium/device-3.log --log-timestamp --local-timezone \
  --log-level info:debug \
  --allow-insecure=uiautomator2:adb_shell \
  --default-capabilities '{"appium:udid":"R58N123ABC","appium:systemPort":8203}'

# перевірка здоров'я для моніторингу
curl -s http://127.0.0.1:4725/status
# device 3: its own port, its own log, only the insecure features it needs
appium --address 127.0.0.1 --port 4725 \
  --log /var/log/appium/device-3.log --log-timestamp --local-timezone \
  --log-level info:debug \
  --allow-insecure=uiautomator2:adb_shell \
  --default-capabilities '{"appium:udid":"R58N123ABC","appium:systemPort":8203}'

# health check for monitoring
curl -s http://127.0.0.1:4725/status
🔧 Розбір синтаксису: --log-level info:debug пише в консоль рівень info, а у файл — debug. --allow-insecure вмикає одну конкретну небезпечну фічу; префікс драйвера (uiautomator2:) обмежує її одним драйвером і обов'язковий в Appium 3, а в Appium 2 працює і без нього. --default-capabilities підмішує capabilities у кожну сесію цього сервера, тож тест не мусить знати udid.
🔧 Syntax breakdown: --log-level info:debug writes info to the console and debug to the file. --allow-insecure enables one specific insecure feature; the driver prefix (uiautomator2:) scopes it to one driver and is required in Appium 3, while Appium 2 also accepts the bare name. --default-capabilities merges capabilities into every session on this server, so the test does not need to know the udid.
💡 Простими словами: Один сервер на телефон — як окрема каса в супермаркеті: одна зламалась, інші працюють, і чек видно, чий.
💡 In plain words: One server per phone is like a separate checkout in a supermarket: if one breaks the others keep working, and you can tell whose receipt is whose.
🗣 Як читати вголос Я зазвичай роблю один Appium-сервер на пристрій. Так у мене окремий порт, окремий лог і перезапуск без впливу на сусідні телефони, а зависання одного драйвера не блокує ферму. Зверху ставлю Grid або свій маршрутизатор. Безпеку тримаю вузькою: не relaxed-security на весь сервер, а лише конкретна фіча на кшталт adb_shell. І стежу за незакритими сесіями — вони тримають пристрій до таймауту і створюють черги. Для моніторингу опитую status і збираю час старту сесії.
🗣 How to say it out loud I usually run one Appium server per device. That gives me a separate port, a separate log and restarts that don't affect neighbouring phones, and one hung driver doesn't block the farm. On top I put Grid or our own router. I keep security narrow: not relaxed security for the whole server, just a specific feature like adb_shell. And I watch for sessions left open — they hold the device until the timeout and create queues. For monitoring I poll status and track session start time.
7 Appium чи Espresso, XCUITest, Detox, Maestro: коли Appium — неправильний вибір? Appium versus Espresso, XCUITest, Detox or Maestro: when is Appium the wrong choice?
Архітектура Architecture Senior ▶

Що це. Appium — крос-платформний чорноящиковий інструмент з однією мовою тестів для Android та iOS. Espresso і XCUITest — нативні фреймворки платформ, тести пишуться на Kotlin/Java та Swift і живуть у репозиторії застосунку. Detox — сірий ящик для React Native із синхронізацією з JS-потоком. Maestro — YAML-сценарії поверх дерева доступності з вбудованими повторами.

What it is. Appium is a cross-platform black-box tool with one test language for Android and iOS. Espresso and XCUITest are the platforms' native frameworks, with tests in Kotlin/Java and Swift living in the app repository. Detox is grey-box for React Native, synchronised with the JS thread. Maestro runs YAML flows on top of the accessibility tree with built-in retries.

Навіщо порівнювати / трейдофи. Appium виграє, коли QA-команда окрема від розробки, тестів на обидві платформи багато і потрібні системні сценарії: дозволи, кілька застосунків, мобільний браузер, реальна ферма. Програє у швидкості і стабільності: кожна команда — HTTP-запит через кілька процесів, синхронізації з UI-потоком немає. Нативні фреймворки у 3–10 разів швидші і запускаються розробниками на кожен коміт, але вимагають двох кодових баз. Detox дає стабільність для RN, проте прив'язаний до нього. Maestro швидко стартує, але складну логіку і дані в YAML тримати важко.

Why compare / trade-offs. Appium wins when the QA team is separate from development, there are many tests for both platforms, and system scenarios matter: permissions, several apps, the mobile browser, a real device farm. It loses on speed and stability: every command is an HTTP request through several processes, with no synchronisation to the UI thread. Native frameworks are 3–10 times faster and run by developers on every commit, but mean two codebases. Detox gives stability for RN but is tied to it. Maestro starts fast, but complex logic and test data are hard to keep in YAML.

Приклад. Для банківського застосунку розумна піраміда: компонентні і UI-тести на Espresso/XCUITest у репозиторії застосунку на кожен PR, а Appium — 30–60 наскрізних сценаріїв на фермі реальних пристроїв щоночі і перед релізом.

Example. For a banking app a sensible pyramid is: component and UI tests in Espresso/XCUITest in the app repo on every PR, plus 30–60 Appium end-to-end scenarios on a real-device farm nightly and before release.

Підводні камені. Типова помилка — переписати на Appium усе, включно з перевірками окремих екранів; набір росте до годин і стає нестабільним. Зворотна помилка — лише нативні тести, і ніхто не перевіряє логін через системний браузер чи push.

Pitfalls. The typical mistake is rewriting everything in Appium, including single-screen checks; the suite grows to hours and becomes flaky. The opposite mistake is native tests only, with nobody checking login through the system browser or push.

Уточнююче питання. «Що ти обереш для нової команди на Flutter?» — перевірю підтримку: є окремий Appium Flutter-драйвер і нативний integration_test, і вибір залежить від того, хто пише тести.

Follow-up question. "What would you pick for a new Flutter team?" I would check support: there is a separate Appium Flutter driver and the native integration_test, and the choice depends on who writes the tests.

Той самий крок у трьох інструментах
The same step in three tools
// Appium (java-client): чорна скринька, явне очікування
new WebDriverWait(driver, Duration.ofSeconds(10))
    .until(ExpectedConditions.elementToBeClickable(AppiumBy.accessibilityId("pay")))
    .click();

// Espresso (Kotlin у репозиторії застосунку): синхронізація вбудована
// onView(withContentDescription("pay")).perform(click())

// Maestro (YAML): - tapOn: { id: "pay" }
// Appium (java-client): black box, explicit wait
new WebDriverWait(driver, Duration.ofSeconds(10))
    .until(ExpectedConditions.elementToBeClickable(AppiumBy.accessibilityId("pay")))
    .click();

// Espresso (Kotlin in the app repo): synchronisation is built in
// onView(withContentDescription("pay")).perform(click())

// Maestro (YAML): - tapOn: { id: "pay" }
🔧 Розбір синтаксису: В Appium очікування — відповідальність автора тесту, тому WebDriverWait з elementToBeClickable обов'язковий. В Espresso той самий клік не потребує очікування: perform виконується після того, як головний потік простоює. У Maestro tapOn сам повторює пошук кілька секунд.
🔧 Syntax breakdown: In Appium waiting is the test author's job, so WebDriverWait with elementToBeClickable is required. In Espresso the same click needs no wait: perform runs once the main thread is idle. In Maestro tapOn retries the lookup for a few seconds on its own.
💡 Простими словами: Appium — універсальний ключ для всіх дверей, нативні фреймворки — рідні ключі. Рідним відкривати швидше, але до кожних дверей свій.
💡 In plain words: Appium is a master key for every door; native frameworks are the original keys. Originals open faster, but each door needs its own.
🗣 Як читати вголос Я не вважаю Appium відповіддю на все. Його сила — одна кодова база на дві платформи, незалежність від команди розробки і системні сценарії: дозволи, пуші, браузер, реальна ферма. Слабкість — швидкість і відсутність синхронізації з UI-потоком. Тому я будую піраміду: швидкі тести екранів на Espresso і XCUITest живуть у репозиторії застосунку, а Appium покриває кілька десятків наскрізних сценаріїв. Detox розглядаю для React Native, Maestro — для швидкого старту смоуків.
🗣 How to say it out loud I don't treat Appium as the answer to everything. Its strength is one codebase for two platforms, independence from the dev team, and system scenarios: permissions, push, the browser, a real farm. Its weakness is speed and no synchronisation with the UI thread. So I build a pyramid: fast screen tests in Espresso and XCUITest live in the app repo, and Appium covers a few dozen end-to-end scenarios. I'd consider Detox for React Native and Maestro for a quick smoke start.
▶ 2 8
8 Що таке capabilities в Appium і як виглядає повний JSON для старту сесії? What are capabilities in Appium and what does a complete JSON for starting a session look like?
Capabilities Capabilities Junior ▶

Що це. Capabilities — це набір пар «ключ–значення», який клієнт надсилає в запиті POST /session. Вони кажуть серверу, яку платформу автоматизувати (platformName), яким драйвером (appium:automationName), на якому пристрої (appium:udid) і який застосунок встановити та запустити (appium:app). Без них сервер не знає, що саме піднімати.

What it is. Capabilities are a set of key–value pairs the client sends in the POST /session request. They tell the server which platform to automate (platformName), with which driver (appium:automationName), on which device (appium:udid), and which app to install and launch (appium:app). Without them the server does not know what to bring up.

Навіщо. Capabilities — єдине місце, де описано середовище тесту. Змінивши їх, той самий тест запускається на іншому телефоні, збірці чи версії ОС. Тому їх не хардкодять у тесті, а збирають з конфігурації або змінних середовища CI.

Why. Capabilities are the single place that describes the test environment. Change them and the same test runs on another phone, build or OS version. That is why they are not hard-coded into the test but assembled from configuration or CI environment variables.

Приклад. Нижче — тіло запиту для Android. platformName — стандартна W3C-capability, тому йде без префікса; решта належить Appium і позначена appium:. У W3C-форматі все загорнуто в capabilities.alwaysMatch; клієнтські бібліотеки роблять це самі.

Example. Below is the request body for Android. platformName is a standard W3C capability, so it has no prefix; the rest belong to Appium and are marked appium:. In W3C format everything is wrapped in capabilities.alwaysMatch; client libraries do that for you.

Підводні камені. platformName не чутливий до регістру, але значення automationName краще писати так, як у документації драйвера. appium:app — абсолютний шлях на машині, де працює сервер, або URL; відносний шлях від каталогу тестів не спрацює, якщо сервер запущено деінде. deviceName на Android майже ні на що не впливає — пристрій обирає udid.

Pitfalls. platformName is case-insensitive, but it is safer to spell automationName values the way the driver docs do. appium:app is an absolute path on the machine where the server runs, or a URL; a path relative to the test folder will not work if the server runs elsewhere. On Android deviceName barely matters — the device is chosen by udid.

Уточнююче питання. «Що буде, якщо capability з одруківкою?» — сервер попередить у лозі й проігнорує її; тому варто читати лог старту сесії, де Appium друкує всі прийняті capabilities.

Follow-up question. "What happens with a misspelled capability?" The server warns in the log and ignores it, which is why you should read the session start log, where Appium prints every capability it accepted.

Тіло POST /session
The POST /session body
{
  "capabilities": {
    "alwaysMatch": {
      "platformName": "Android",
      "appium:automationName": "UiAutomator2",
      "appium:udid": "emulator-5554",
      "appium:app": "/builds/shop-2.14.0-debug.apk",
      "appium:appPackage": "com.example.shop",
      "appium:appActivity": ".ui.MainActivity",
      "appium:newCommandTimeout": 120,
      "appium:noReset": false
    },
    "firstMatch": [{}]
  }
}
Те саме у WebdriverIO
The same in WebdriverIO
const driver = await remote({
  hostname: "127.0.0.1",
  port: 4723,
  capabilities: {
    platformName: "Android",
    "appium:automationName": "UiAutomator2",
    "appium:udid": process.env.ANDROID_UDID,
    "appium:app": process.env.APP_PATH,
  },
});
🔧 Розбір синтаксису: alwaysMatch — capabilities, які мають виконатися завжди; firstMatch — список альтернатив, з яких сервер бере першу придатну. newCommandTimeout задається в секундах. У WebdriverIO ключі з двокрапкою пишуться в лапках, а значення беруться з process.env, щоб CI міняв пристрій і збірку без правки коду.
🔧 Syntax breakdown: alwaysMatch holds capabilities that must always apply; firstMatch is a list of alternatives from which the server takes the first that fits. newCommandTimeout is in seconds. In WebdriverIO keys containing a colon are quoted, and values come from process.env so CI can change the device and build without editing code.
💡 Простими словами: Capabilities — це замовлення в ресторані: яка кухня, який кухар, за яким столом і яка страва.
💡 In plain words: Capabilities are an order at a restaurant: which cuisine, which chef, which table and which dish.
🗣 Як читати вголос Capabilities — це те, що мій клієнт надсилає при створенні сесії. Мінімум для Android — platformName, automationName з префіксом appium, udid пристрою і шлях до застосунку. Платформа — стандарт W3C, тому без префікса, а все специфічне для Appium — з ним. Важливо, що шлях до APK рахується з машини, де стоїть сервер. Я ніколи не хардкоджу ці значення в тесті: вони приходять із конфігу або змінних CI. І завжди дивлюсь лог старту — там видно, які capabilities сервер реально прийняв.
🗣 How to say it out loud Capabilities are what my client sends when it creates a session. The minimum for Android is platformName, automationName with the appium prefix, the device udid and the app path. Platform is a W3C standard key, so no prefix, and everything Appium-specific carries it. Importantly, the APK path is resolved on the machine running the server. I never hard-code these values in the test: they come from config or CI variables. And I always check the start log — it shows which capabilities the server actually accepted.
9 Навіщо префікс appium: у capabilities і що станеться, якщо його забути? Why is there an appium: prefix on capabilities and what happens if you forget it?
Capabilities Capabilities Junior ▶

Що це. Протокол W3C WebDriver визначає короткий список стандартних capabilities: platformName, browserName, browserVersion, acceptInsecureCerts, pageLoadStrategy, proxy, timeouts, unhandledPromptBehavior та кілька інших. Усе інше має бути «розширенням вендора» з префіксом і двокрапкою. Для Appium це appium:, для хмар — власні простори на кшталт bstack:options чи sauce:options.

What it is. The W3C WebDriver protocol defines a short list of standard capabilities: platformName, browserName, browserVersion, acceptInsecureCerts, pageLoadStrategy, proxy, timeouts, unhandledPromptBehavior and a few more. Anything else must be a "vendor extension" with a prefix and a colon. For Appium that is appium:; clouds have their own namespaces such as bstack:options or sauce:options.

Навіщо. Префікс робить запит валідним за W3C і прибирає конфлікти імен: хмара, Grid і Appium можуть мати однакові назви параметрів у різних просторах. Appium 2 працює лише за W3C-протоколом, тому старі «desired capabilities» без префікса з Appium 1 тепер відкидаються.

Why. The prefix keeps the request valid under W3C and removes name clashes: a cloud, Grid and Appium can all have parameters with the same names in different namespaces. Appium 2 speaks only the W3C protocol, so old unprefixed Appium 1 "desired capabilities" are now rejected.

Приклад. Запит із "automationName": "UiAutomator2" без префікса сервер відхилить з помилкою про невалідні capabilities, бо це нестандартний ключ. Типізовані класи java-client (UiAutomator2Options, XCUITestOptions) ставлять префікс самі, тому з ними цієї помилки просто не виникає.

Example. A request with "automationName": "UiAutomator2" and no prefix is rejected with an invalid-capabilities error, because it is a non-standard key. java-client's typed classes (UiAutomator2Options, XCUITestOptions) add the prefix themselves, so with them this error simply cannot happen.

Підводні камені. Легко змішати: appium:platformName — неправильно, бо платформа стандартна. Плагін relaxed-caps дозволяє старі ключі без префікса, але це милиця на час міграції, а не рекомендація. Для хмарних ферм частина параметрів іде не з appium:, а всередину об'єкта провайдера — інакше хмара їх не прочитає.

Pitfalls. It is easy to overdo it: appium:platformName is wrong, because platform is standard. The relaxed-caps plugin accepts old unprefixed keys, but that is a crutch for a migration period, not a recommendation. On cloud farms some parameters go not under appium: but inside the provider's object — otherwise the cloud will not read them.

Уточнююче питання. «А browserName потрібен префікс?» — ні, це стандартна W3C-capability для мобільного вебу, як і platformName.

Follow-up question. "Does browserName need a prefix?" No, it is a standard W3C capability for mobile web, just like platformName.

Правильно і неправильно
Right and wrong
{
  "platformName": "iOS",
  "appium:automationName": "XCUITest",
  "appium:platformVersion": "17.5",
  "appium:deviceName": "iPhone 15",
  "bstack:options": { "projectName": "shop", "buildName": "nightly-512" }
}
Що видасть сервер без префікса
What the server says without the prefix
POST /session  { "platformName": "Android", "automationName": "UiAutomator2" }

ERROR: invalid argument: Illegal key values seen in w3c capabilities: [automationName]
🔧 Розбір синтаксису: Ключі без двокрапки мають бути лише зі стандартного W3C-списку. appium:platformVersion і appium:deviceName — розширення Appium, bstack:options — окремий простір BrowserStack, куди кладуть назву проєкту і збірки. Текст помилки може трохи відрізнятися між версіями, але суть однакова: нестандартний ключ без префікса.
🔧 Syntax breakdown: Keys without a colon must come from the standard W3C list only. appium:platformVersion and appium:deviceName are Appium extensions, and bstack:options is BrowserStack's own namespace for the project and build name. The error text may vary slightly between versions, but the gist is the same: a non-standard key without a prefix.
💡 Простими словами: Префікс — як код країни в номері телефону: без нього незрозуміло, куди дзвонити.
💡 In plain words: The prefix is like a country code in a phone number: without it nobody knows where to call.
🗣 Як читати вголос Префікс appium з'явився через перехід на чистий W3C-протокол. Стандарт дозволяє без префікса лише кілька ключів, наприклад platformName чи browserName, а все, що належить конкретному вендору, має бути в його просторі імен. Appium 2 старі ключі без префікса відкидає, тож при міграції з першої версії це перша помилка, яку я бачу. У Java я просто використовую Options-класи, і проблема зникає. А для хмар пам'ятаю, що їхні параметри йдуть в окремий об'єкт на кшталт bstack options.
🗣 How to say it out loud The appium prefix came with the move to a pure W3C protocol. The standard allows only a few keys without a prefix, like platformName or browserName, and anything vendor-specific must live in its own namespace. Appium 2 rejects old unprefixed keys, so when migrating from version one this is the first error I see. In Java I simply use the Options classes and the problem disappears. And for clouds I remember that their parameters go into a separate object like bstack options.
10 Чим сесія з appium:app відрізняється від сесії з browserName і коли що використовувати? How does a session with appium:app differ from one with browserName, and when do you use each?
Capabilities Capabilities Junior ▶

Що це. appium:app (або appium:appPackage/appium:bundleId для вже встановленого застосунку) каже драйверу: встанови і запусти нативний чи гібридний застосунок. browserName каже інше: відкрий мобільний браузер — Chrome на Android або Safari на iOS — і тестуй вебсторінку. В одній сесії вказують щось одне.

What it is. appium:app (or appium:appPackage/appium:bundleId for an app already installed) tells the driver: install and launch a native or hybrid app. browserName says something else: open the mobile browser — Chrome on Android or Safari on iOS — and test a web page. A session specifies one or the other.

Навіщо розрізняти. Від цього залежить, у якому контексті стартує сесія і які команди доступні. У нативній сесії ви працюєте з деревом елементів платформи і локаторами на кшталт accessibility id. У сесії з browserName драйвер одразу перемикається у веб-контекст, працюють driver.get(url), CSS-селектори і JavaScript, як у Selenium.

Why tell them apart. It decides which context the session starts in and which commands are available. In a native session you work with the platform's element tree and locators such as accessibility id. In a browserName session the driver switches straight into a web context, and driver.get(url), CSS selectors and JavaScript work just like in Selenium.

Приклад. Команда перевіряє мобільну версію сайту магазину і нативний застосунок. Для сайту — browserName: "Chrome" і get("https://shop.example.com"); для застосунку — шлях до APK. Той самий framework, але різні фабрики сесій.

Example. A team checks both the shop's mobile website and its native app. For the site: browserName: "Chrome" and get("https://shop.example.com"); for the app: the APK path. Same framework, different session factories.

Підводні камені. Вказати і app, і browserName одночасно — помилка конфігурації: сервер відмовить або проігнорує одне з них. Для Chrome на Android потрібен chromedriver під версію браузера; драйвер уміє сам завантажити потрібну версію (capability у прикладі нижче), але цю можливість сервер дозволяє лише через --allow-insecure. Safari на реальному iPhone потребує ввімкненого Web Inspector у налаштуваннях Safari.

Pitfalls. Setting both app and browserName is a configuration error: the server either refuses or ignores one of them. Chrome on Android needs a chromedriver matching the browser version; the driver can fetch the right version itself (see the capability in the example below), but the server only allows that through --allow-insecure. Safari on a real iPhone needs Web Inspector switched on in Safari settings.

Уточнююче питання. «А WebView всередині застосунку — це browserName?» — ні, це гібридний застосунок: сесія стартує з app, а у WebView перемикаються через контексти.

Follow-up question. "Is a WebView inside an app a browserName case?" No, that is a hybrid app: the session starts with app, and you enter the WebView by switching contexts.

Дві фабрики сесій
Two session factories
// нативний застосунок
UiAutomator2Options app = new UiAutomator2Options()
    .setUdid(udid)
    .setApp("/builds/shop.apk");

// мобільний веб у Chrome
UiAutomator2Options web = new UiAutomator2Options()
    .setUdid(udid)
    .withBrowserName("Chrome");
web.setCapability("appium:chromedriverAutodownload", true);

AndroidDriver driver = new AndroidDriver(serverUrl, web);
driver.get("https://shop.example.com/cart");
driver.findElement(By.cssSelector("button[data-test=checkout]")).click();
// native app
UiAutomator2Options app = new UiAutomator2Options()
    .setUdid(udid)
    .setApp("/builds/shop.apk");

// mobile web in Chrome
UiAutomator2Options web = new UiAutomator2Options()
    .setUdid(udid)
    .withBrowserName("Chrome");
web.setCapability("appium:chromedriverAutodownload", true);

AndroidDriver driver = new AndroidDriver(serverUrl, web);
driver.get("https://shop.example.com/cart");
driver.findElement(By.cssSelector("button[data-test=checkout]")).click();
🔧 Розбір синтаксису: withBrowserName — метод базового класу опцій, він записує стандартний browserName без префікса. У веб-сесії працюють звичайні By.cssSelector і get, бо команди йдуть через chromedriver. serverUrl — той самий URI.create(...).toURL(), що й для нативних тестів.
🔧 Syntax breakdown: withBrowserName is a base options method that writes the standard browserName without a prefix. In a web session plain By.cssSelector and get work, because commands go through chromedriver. serverUrl is the same URI.create(...).toURL() used for native tests.
💡 Простими словами: app — «відкрий цю програму», browserName — «відкрий браузер і зайди на сайт».
💡 In plain words: app means "open this program", browserName means "open the browser and go to the site".
🗣 Як читати вголос Для мене це два різні типи сесій. Якщо я вказую app, драйвер встановлює і запускає нативний чи гібридний застосунок, і я працюю з нативними локаторами. Якщо browserName, відкривається Chrome або Safari, і далі все як у Selenium: get, CSS, JavaScript. Змішувати їх в одній сесії не можна. Для Chrome на Android треба стежити за відповідністю chromedriver, а для Safari на реальному айфоні — ввімкнути Web Inspector. WebView у застосунку — окрема історія з контекстами.
🗣 How to say it out loud For me these are two different session types. If I set app, the driver installs and launches a native or hybrid app and I work with native locators. If I set browserName, Chrome or Safari opens and everything is like Selenium: get, CSS, JavaScript. You can't mix them in one session. For Chrome on Android you have to keep chromedriver matched, and for Safari on a real iPhone you need Web Inspector on. A WebView inside an app is a separate story with contexts.
11 Як вибрати конкретний пристрій: чим appium:udid відрізняється від appium:deviceName? How do you pick a specific device: how does appium:udid differ from appium:deviceName?
Capabilities Capabilities Junior ▶

Що це. appium:udid — унікальний ідентифікатор конкретного пристрою: серійний номер з adb devices на Android (наприклад, emulator-5554 або R58N123ABC) чи UDID з xcrun xctrace list devices / xcrun simctl list на iOS. appium:deviceName — людська назва на кшталт «Pixel 7» чи «iPhone 15».

What it is. appium:udid is the unique identifier of a specific device: the serial from adb devices on Android (for example emulator-5554 or R58N123ABC), or the UDID from xcrun xctrace list devices / xcrun simctl list on iOS. appium:deviceName is a human-readable name such as "Pixel 7" or "iPhone 15".

Навіщо розрізняти. На Android UiAutomator2 обирає пристрій саме за udid; без нього береться перший підключений, і на машині з трьома телефонами тест потрапляє куди завгодно. На iOS-симуляторах усе навпаки: пару deviceName + platformVersion драйвер використовує, щоб знайти або створити симулятор, а udid потрібен для реального iPhone чи конкретного вже створеного симулятора.

Why the difference matters. On Android UiAutomator2 picks the device by udid; without it the first connected device is taken, and on a machine with three phones the test lands anywhere. iOS simulators are the other way round: the driver uses the deviceName + platformVersion pair to find or create a simulator, while udid is needed for a real iPhone or a specific existing simulator.

Приклад. На CI-агенті з двома емуляторами кожен потік отримує свій udid зі змінної середовища. Для iOS-симулятора досить deviceName: "iPhone 15" і platformVersion: "17.5" — драйвер завантажить його сам.

Example. On a CI agent with two emulators each thread gets its own udid from an environment variable. For an iOS simulator deviceName: "iPhone 15" and platformVersion: "17.5" are enough — the driver boots it itself.

Підводні камені. Якщо на Android вказати лише deviceName, тест «працює на моїй машині», де підключено один телефон, і ламається на фермі. Для iOS помилка у версії (17.5 проти встановленого 17.4) дає «Could not find a device». Емулятор, перезапущений на іншому порту, змінює свій серійний номер, тому краще фіксувати порт при старті.

Pitfalls. If you set only deviceName on Android, the test "works on my machine" with one phone attached and breaks on the farm. On iOS a wrong version (17.5 vs the installed 17.4) gives "Could not find a device". An emulator restarted on another port changes its serial, so it is better to fix the port at start-up.

Уточнююче питання. «Як дізнатися udid реального iPhone?» — у Finder чи Xcode Devices, або xcrun xctrace list devices; на пристроях з чипом A12 і новіших він має формат 00008xxx-....

Follow-up question. "How do you find a real iPhone's udid?" In Finder or Xcode Devices, or with xcrun xctrace list devices; on A12 chips and newer it looks like 00008xxx-....

Звідки брати ідентифікатори
Where the identifiers come from
adb devices -l
# emulator-5554  device product:sdk_gphone64 model:sdk_gphone64_x86_64
emulator -avd Pixel_7_API_34 -port 5556   # фіксований порт = стабільний serial

xcrun simctl list devices available | grep "iPhone 15"
xcrun xctrace list devices               # реальні iPhone і симулятори
adb devices -l
# emulator-5554  device product:sdk_gphone64 model:sdk_gphone64_x86_64
emulator -avd Pixel_7_API_34 -port 5556   # fixed port = stable serial

xcrun simctl list devices available | grep "iPhone 15"
xcrun xctrace list devices               # real iPhones and simulators
Вибір пристрою в тесті
Choosing the device in a test
UiAutomator2Options android = new UiAutomator2Options()
    .setUdid(System.getenv("ANDROID_UDID"));

XCUITestOptions iosSim = new XCUITestOptions()
    .setDeviceName("iPhone 15")
    .setPlatformVersion("17.5");
🔧 Розбір синтаксису: adb devices -l показує serial і модель. Емулятор отримує serial emulator-<порт>, тому -port 5556 дає передбачуване emulator-5556. simctl list devices available друкує лише симулятори з наявним рантаймом. У Java udid приходить зі змінної середовища, яку виставляє CI.
🔧 Syntax breakdown: adb devices -l shows the serial and model. An emulator gets the serial emulator-<port>, so -port 5556 gives a predictable emulator-5556. simctl list devices available prints only simulators whose runtime is installed. In Java the udid comes from an environment variable set by CI.
💡 Простими словами: udid — номер паспорта пристрою, deviceName — його ім'я. Однакових імен багато, паспорт один.
💡 In plain words: udid is the device passport number, deviceName is its first name. Many devices share a name, only one has that passport.
🗣 Як читати вголос Udid — це точний ідентифікатор пристрою, а deviceName — просто назва. На Android я завжди задаю udid, бо без нього драйвер візьме перший підключений телефон, і на фермі тести розбіжаться хаотично. На iOS-симуляторах навпаки: назви і версії платформи досить, драйвер сам знайде і завантажить симулятор, а udid потрібен для реального айфона. Емулятори я запускаю на фіксованих портах, щоб серійний номер не мінявся між запусками.
🗣 How to say it out loud Udid is the exact device identifier, while deviceName is just a name. On Android I always set udid, because without it the driver takes the first connected phone and on a farm tests scatter randomly. On iOS simulators it is the opposite: the name and platform version are enough, the driver finds and boots the simulator, and udid is for a real iPhone. I start emulators on fixed ports so the serial doesn't change between runs.
12 Що таке appPackage/appActivity на Android і bundleId на iOS, і як їх дізнатися? What are appPackage/appActivity on Android and bundleId on iOS, and how do you find them?
Capabilities Capabilities Junior ▶

Що це. appPackage — ідентифікатор Android-застосунку (applicationId з Gradle, наприклад com.example.shop). appActivity — Activity, яку треба запустити, наприклад .ui.MainActivity. На iOS роль ідентифікатора виконує bundleId (com.example.shop.ios), а окремого поняття «стартовий екран» немає — застосунок завжди запускається як ціле.

What it is. appPackage is the Android app identifier (the Gradle applicationId, for example com.example.shop). appActivity is the Activity to launch, for example .ui.MainActivity. On iOS the identifier role is played by bundleId (com.example.shop.ios), and there is no separate "start screen" concept — the app always launches as a whole.

Навіщо. Якщо застосунок уже встановлено, досить цих ідентифікаторів замість шляху до файлу — сесія стартує швидше. Вони ж потрібні для команд керування: activateApp, terminateApp, queryAppState, mobile: clearApp. З appActivity можна одразу відкрити внутрішній екран, якщо Activity експортована.

Why. If the app is already installed, these identifiers are enough instead of a file path, and the session starts faster. They are also needed for app management commands: activateApp, terminateApp, queryAppState, mobile: clearApp. With appActivity you can open an inner screen directly if that Activity is exported.

Приклад. Відкрий застосунок на емуляторі і виконай adb shell dumpsys window | grep mCurrentFocus — у виводі буде com.example.shop/.ui.MainActivity. Для iOS-симулятора — xcrun simctl listapps booted, для реального пристрою — Info.plist зі збірки або ideviceinstaller -l.

Example. Open the app on an emulator and run adb shell dumpsys window | grep mCurrentFocus — the output contains com.example.shop/.ui.MainActivity. For an iOS simulator use xcrun simctl listapps booted; for a real device, the build's Info.plist or ideviceinstaller -l.

Підводні камені. Застосунок зі сплеш-екраном перемикається на іншу Activity, і сесія падає з «waiting for activity». Рішення — appium:appWaitActivity зі списком або маскою (*.MainActivity), а не збільшення таймаутів. applicationId debug-збірки часто має суфікс .debug — тест, написаний під release, не знайде пакет. Запуск неекспортованої Activity напряму дає SecurityException.

Pitfalls. An app with a splash screen switches to another Activity, and the session fails with "waiting for activity". The fix is appium:appWaitActivity with a list or a wildcard (*.MainActivity), not longer timeouts. A debug build's applicationId often has a .debug suffix — a test written for release will not find the package. Launching a non-exported Activity directly throws a SecurityException.

Уточнююче питання. «Як перезапустити застосунок у середині тесту?» — terminateApp і activateApp з тим самим ідентифікатором; стан даних при цьому зберігається.

Follow-up question. "How do you restart the app mid-test?" terminateApp and activateApp with the same identifier; app data is kept.

Знайти ідентифікатори
Finding the identifiers
adb shell dumpsys window | grep mCurrentFocus
# mCurrentFocus=Window{... com.example.shop/com.example.shop.ui.MainActivity}
xcrun simctl listapps booted | grep CFBundleIdentifier
adb shell dumpsys window | grep mCurrentFocus
# mCurrentFocus=Window{... com.example.shop/com.example.shop.ui.MainActivity}
xcrun simctl listapps booted | grep CFBundleIdentifier
Керування застосунком за ідентифікатором
Managing the app by identifier
options.setAppPackage("com.example.shop")
       .setAppActivity(".ui.MainActivity")
       .setAppWaitActivity("*.MainActivity");

driver.terminateApp("com.example.shop");
driver.activateApp("com.example.shop");
ApplicationState state = driver.queryAppState("com.example.shop");
// RUNNING_IN_FOREGROUND, RUNNING_IN_BACKGROUND, NOT_RUNNING...
options.setAppPackage("com.example.shop")
       .setAppActivity(".ui.MainActivity")
       .setAppWaitActivity("*.MainActivity");

driver.terminateApp("com.example.shop");
driver.activateApp("com.example.shop");
ApplicationState state = driver.queryAppState("com.example.shop");
// RUNNING_IN_FOREGROUND, RUNNING_IN_BACKGROUND, NOT_RUNNING...
🔧 Розбір синтаксису: dumpsys window показує вікно у фокусі у форматі пакет/Activity. Крапка на початку .ui.MainActivity означає «відносно пакета». setAppWaitActivity приймає маску з *. queryAppState повертає enum ApplicationState, на iOS ті самі методи приймають bundleId.
🔧 Syntax breakdown: dumpsys window shows the focused window as package/Activity. A leading dot in .ui.MainActivity means "relative to the package". setAppWaitActivity accepts a * wildcard. queryAppState returns the ApplicationState enum, and on iOS the same methods take a bundleId.
💡 Простими словами: Пакет чи bundleId — це «прописка» застосунку в системі, Activity — конкретна кімната, в яку заходиш.
💡 In plain words: The package or bundleId is the app's registered address on the system; the Activity is the room you walk into.
🗣 Як читати вголос appPackage — це ідентифікатор Android-застосунку, appActivity — екран, з якого стартуємо. На iOS є лише bundleId. Я дістаю їх командою dumpsys window на відкритому застосунку або через simctl для симулятора. Вони потрібні не лише для старту, а й щоб у тесті закрити й знову відкрити застосунок чи перевірити його стан. Типова пастка — сплеш-екран: застосунок переходить в іншу Activity, і сесія падає, тож я задаю appWaitActivity. І стежу за суфіксом debug у пакеті тестової збірки.
🗣 How to say it out loud appPackage is the Android app identifier, appActivity is the screen we start from. iOS has only a bundleId. I get them with dumpsys window on the open app, or with simctl for a simulator. They're needed not only to start but also to close and reopen the app mid-test or check its state. The typical trap is a splash screen: the app moves to another Activity and the session fails, so I set appWaitActivity. And I watch for the debug suffix on the test build's package.
13 Чим noReset відрізняється від fullReset і як обрати стратегію скидання стану застосунку? How does noReset differ from fullReset and how do you choose a strategy for resetting app state?
Capabilities Capabilities Middle ▶

Що це. Дві capabilities визначають, що драйвер робить із застосунком до і після сесії. appium:noReset=true — нічого не чистити: дані, логін і налаштування зберігаються. appium:fullReset=true — найжорсткіший варіант: застосунок видаляється й установлюється заново. Якщо обидві false (за замовчуванням), на Android драйвер зупиняє застосунок і очищує його дані, але не перевстановлює; на iOS поведінка залежить від того, симулятор це чи реальний пристрій, тож її варто звірити з документацією XCUITest-драйвера.

What it is. Two capabilities decide what the driver does with the app before and after a session. appium:noReset=true means clean nothing: data, login and settings are kept. appium:fullReset=true is the harshest option: the app is uninstalled and installed again. With both false (the default), on Android the driver stops the app and clears its data but does not reinstall it; on iOS behaviour differs between simulator and real device, so check it against the XCUITest driver docs.

Навіщо. Це компроміс між швидкістю й ізоляцією. fullReset дає чистий стан, як у нового користувача, але коштує десятки секунд на сесію. noReset миттєвий, проте тести починають залежати один від одного: залишки кошика чи логін з попереднього тесту змінюють результат.

Why. It is a trade-off between speed and isolation. fullReset gives a clean state like a new user, but costs tens of seconds per session. noReset is instant, but tests start depending on each other: a leftover cart or login from the previous test changes the outcome.

Приклад. Робоча стратегія: noReset=true для швидкості, а потрібний стан кожен тест готує явно — mobile: clearApp на Android, deep link у потрібний екран, логін через API і токен. fullReset лишають для окремих сценаріїв онбордингу і перевірки першого запуску.

Example. A working strategy: noReset=true for speed, and each test prepares the state it needs explicitly — mobile: clearApp on Android, a deep link to the right screen, login through the API with a token. fullReset is kept for onboarding and first-launch scenarios.

Підводні камені. fullReset на реальному iPhone перевстановлює застосунок і скидає видані дозволи — тест раптом зустрічає системні діалоги. noReset разом із app може не встановити нову збірку поверх старої; для цього є appium:enforceAppInstall. Очищення даних не скидає дозволи на Android 11+, якщо їх видано через autoGrantPermissions.

Pitfalls. fullReset on a real iPhone reinstalls the app and resets granted permissions — the test suddenly meets system dialogs. noReset together with app may not install a new build over the old one; appium:enforceAppInstall exists for that. Clearing data does not revoke permissions on Android 11+ if they were granted via autoGrantPermissions.

Уточнююче питання. «Що робиш, якщо тести проходять поодинці і падають разом?» — перше підозріле місце: спільний стан через noReset.

Follow-up question. "What do you do if tests pass alone and fail together?" The first suspect is shared state through noReset.

Порівняння стратегій
Strategies compared
                     | noReset=true | default (false/false) | fullReset=true
---------------------+--------------+-----------------------+---------------
Android: app data    | kept         | cleared               | removed
Android: reinstall   | no           | no                    | yes
Session start speed  | fastest      | medium                | slowest
Test isolation       | weak         | good                  | full
Typical use          | fast suites  | regular runs          | onboarding
Явна підготовка стану при noReset
Explicit state setup with noReset
options.setNoReset(true);

// перед тестом: чисті дані лише там, де це потрібно
driver.executeScript("mobile: clearApp", Map.of("appId", "com.example.shop"));
driver.activateApp("com.example.shop");
options.setNoReset(true);

// before the test: clean data only where it is needed
driver.executeScript("mobile: clearApp", Map.of("appId", "com.example.shop"));
driver.activateApp("com.example.shop");
🔧 Розбір синтаксису: Таблиця описує Android-поведінку UiAutomator2; на iOS рядки для симулятора і реального пристрою відрізняються. mobile: clearApp виконує аналог pm clear для одного пакета і залишає застосунок встановленим; після нього застосунок треба знову активувати.
🔧 Syntax breakdown: The table describes UiAutomator2 behaviour on Android; on iOS the simulator and real-device rows differ. mobile: clearApp performs the equivalent of pm clear for one package and leaves the app installed; afterwards you need to activate the app again.
💡 Простими словами: noReset — продовжити вчорашню гру, fullReset — купити нову приставку. Зазвичай досить почати новий рівень.
💡 In plain words: noReset is continuing yesterday's game, fullReset is buying a new console. Usually starting a fresh level is enough.
🗣 Як читати вголос noReset означає нічого не чистити між сесіями, fullReset — видалити й заново встановити застосунок. За замовчуванням на Android дані очищаються без перевстановлення. Я зазвичай беру noReset заради швидкості, але тоді кожен тест сам готує свій стан: чистить дані конкретного застосунку, логіниться через API, відкриває екран діплінком. fullReset лишаю для онбордингу. Якщо тести окремо проходять, а разом падають, перше, що я перевіряю, — чи не тягнеться стан з попереднього тесту.
🗣 How to say it out loud noReset means don't clean anything between sessions; fullReset means uninstall and reinstall the app. By default on Android data is cleared without reinstalling. I usually go with noReset for speed, but then each test prepares its own state: clears that app's data, logs in through the API, opens the screen with a deep link. I keep fullReset for onboarding. If tests pass alone but fail together, the first thing I check is state leaking from the previous test.
14 Як влаштований життєвий цикл Appium-сесії: newCommandTimeout, quit і сесія на тест чи на клас? How does the Appium session lifecycle work: newCommandTimeout, quit, and a session per test or per class?
Capabilities Capabilities Middle ▶

Що це. Сесія живе від POST /session до DELETE /session/{id} (driver.quit()). Під час старту драйвер готує пристрій: встановлює свій сервер, застосунок, чекає Activity — це 5–30 секунд. appium:newCommandTimeout (60 с за замовчуванням) — скільки сервер чекає на наступну команду, перш ніж сам закрити сесію.

What it is. A session lives from POST /session to DELETE /session/{id} (driver.quit()). During start-up the driver prepares the device: installs its server and the app and waits for the Activity — that takes 5–30 seconds. appium:newCommandTimeout (60 s by default) is how long the server waits for the next command before closing the session itself.

Навіщо розуміти. Від моделі сесій залежать і швидкість, і ізоляція. Сесія на кожен тест — чисто, але старт множиться на сотні тестів. Сесія на клас чи потік — швидко, проте тести ділять стан застосунку. Компроміс: одна сесія на потік, а між тестами — terminateApp/activateApp або mobile: clearApp.

Why it matters. The session model drives both speed and isolation. A session per test is clean, but start-up is multiplied by hundreds of tests. A session per class or thread is fast, but tests share app state. The compromise: one session per thread, with terminateApp/activateApp or mobile: clearApp between tests.

Приклад. У TestNG драйвер тримають у ThreadLocal, створюють у @BeforeClass, а в @AfterMethod перезапускають застосунок. quit() — завжди у finally або @AfterClass(alwaysRun = true), інакше впалий тест лишає пристрій зайнятим.

Example. In TestNG the driver is kept in a ThreadLocal, created in @BeforeClass, and the app is restarted in @AfterMethod. quit() always goes in finally or @AfterClass(alwaysRun = true), otherwise a failed test leaves the device occupied.

Підводні камені. Дебаг із брейкпоінтом довше 60 секунд «вбиває» сесію — наступна команда отримує «session is either terminated or not started». Для локального дебагу таймаут піднімають до 300–600 с, але не на CI, де зависла сесія має звільняти пристрій швидко. driver.close() у мобільному контексті не закриває сесію — потрібен саме quit().

Pitfalls. Debugging with a breakpoint longer than 60 seconds "kills" the session — the next command gets "session is either terminated or not started". For local debugging the timeout is raised to 300–600 s, but not in CI, where a hung session should free the device quickly. driver.close() in a mobile context does not end the session — you need quit().

Уточнююче питання. «Як зрозуміти, що сесія впала на старті?» — у лозі сервера шукати «Encountered internal error running command» поруч із createSession і дивитися першу причину, а не останній стек.

Follow-up question. "How can you tell a session died at start-up?" Look in the server log for "Encountered internal error running command" next to createSession, and read the first cause, not the last stack trace.

Сесія на потік у TestNG
A session per thread in TestNG
public abstract class BaseMobileTest {
    private static final ThreadLocal<AndroidDriver> DRIVER = new ThreadLocal<>();

    @BeforeClass
    public void startSession() throws Exception {
        UiAutomator2Options o = Caps.forThread()          // udid і порти потоку
            .setNewCommandTimeout(Duration.ofSeconds(90));
        DRIVER.set(new AndroidDriver(Caps.serverUrl(), o));
    }

    @AfterMethod(alwaysRun = true)
    public void restartApp() {
        driver().terminateApp("com.example.shop");
        driver().activateApp("com.example.shop");
    }

    @AfterClass(alwaysRun = true)
    public void stopSession() {
        if (DRIVER.get() != null) { DRIVER.get().quit(); DRIVER.remove(); }
    }

    protected AndroidDriver driver() { return DRIVER.get(); }
}
public abstract class BaseMobileTest {
    private static final ThreadLocal<AndroidDriver> DRIVER = new ThreadLocal<>();

    @BeforeClass
    public void startSession() throws Exception {
        UiAutomator2Options o = Caps.forThread()          // the thread's udid and ports
            .setNewCommandTimeout(Duration.ofSeconds(90));
        DRIVER.set(new AndroidDriver(Caps.serverUrl(), o));
    }

    @AfterMethod(alwaysRun = true)
    public void restartApp() {
        driver().terminateApp("com.example.shop");
        driver().activateApp("com.example.shop");
    }

    @AfterClass(alwaysRun = true)
    public void stopSession() {
        if (DRIVER.get() != null) { DRIVER.get().quit(); DRIVER.remove(); }
    }

    protected AndroidDriver driver() { return DRIVER.get(); }
}
🔧 Розбір синтаксису: ThreadLocal дає кожному потоку TestNG власний драйвер без гонок. alwaysRun = true виконує метод навіть після падіння тесту чи конфігурації. Caps.forThread() — власний хелпер проєкту, що повертає опції з udid і портами саме цього потоку. DRIVER.remove() прибирає посилання, щоб потік із пулу не отримав мертвий драйвер.
🔧 Syntax breakdown: ThreadLocal gives every TestNG thread its own driver without races. alwaysRun = true runs the method even after a test or configuration failure. Caps.forThread() is a project helper returning options with that thread's udid and ports. DRIVER.remove() clears the reference so a pooled thread never gets a dead driver.
💡 Простими словами: Сесія — оренда телефону. Не повернеш ключі через quit() — телефон простоїть, доки не спрацює таймер.
💡 In plain words: A session is renting a phone. If you don't hand back the keys with quit(), the phone sits idle until the timer runs out.
🗣 Як читати вголос Старт сесії на мобільному дорогий, від кількох секунд до пів хвилини, тому я не створюю сесію на кожен тест. Зазвичай одна сесія на потік, а між тестами я перезапускаю застосунок або чищу його дані. Драйвер тримаю в ThreadLocal, а quit ставлю в блок, який виконується завжди. newCommandTimeout на CI лишаю невеликим, щоб зависла сесія швидко звільнила пристрій, а локально для дебагу збільшую, інакше брейкпоінт убиває сесію.
🗣 How to say it out loud Starting a mobile session is expensive, from a few seconds to half a minute, so I don't create one per test. Usually it's one session per thread, and between tests I restart the app or clear its data. I keep the driver in a ThreadLocal and put quit in a block that always runs. In CI I keep newCommandTimeout small so a hung session frees the device quickly, and locally I raise it for debugging, otherwise a breakpoint kills the session.
15 Що дає тестування на реальному пристрої порівняно з емулятором чи симулятором? What does testing on a real device give you compared with an emulator or simulator?
Capabilities Capabilities Middle ▶

Що це. Android-емулятор — віртуальна машина з повноцінним Android (часто x86_64 образ), iOS-симулятор — застосунок, зібраний під macOS, який лише імітує iOS-середовище. Реальний пристрій — справжнє залізо з прошивкою виробника, сенсорами, модемом і обмеженнями пам'яті та батареї.

What it is. An Android emulator is a virtual machine running full Android (often an x86_64 image); an iOS simulator is an app built for macOS that only imitates the iOS environment. A real device is actual hardware with vendor firmware, sensors, a modem and real memory and battery limits.

Навіщо обидва. Емулятори дешеві, швидко стартують зі знімка, легко скидаються й масштабуються в CI — ідеальні для щоденної регресії. Але вони не показують: оболонки виробників (One UI, MIUI) з агресивним вбиванням фонових процесів, реальну продуктивність і нагрів, камеру, NFC, біометрію, поведінку мережі оператора, push через APNs на iOS-симуляторах до недавнього часу, а також ARM-специфічні креші нативних бібліотек.

Why both. Emulators are cheap, start quickly from a snapshot, reset easily and scale in CI — ideal for daily regression. But they do not show: vendor skins (One UI, MIUI) that aggressively kill background processes, real performance and heat, the camera, NFC, biometrics, carrier network behaviour, push over APNs on older iOS simulators, or ARM-specific crashes in native libraries.

Приклад. Застосунок доставки проходить усі тести на Pixel-емуляторі, але на Xiaomi фоновий сервіс трекінгу зупиняється через 10 хвилин. Такий баг ловиться лише на реальних пристроях популярних моделей — тому матрицю будують за аналітикою аудиторії: топ-5 моделей і мінімальна підтримувана версія ОС.

Example. A delivery app passes every test on a Pixel emulator, but on a Xiaomi the background tracking service stops after 10 minutes. A bug like that is only caught on real devices of popular models — which is why the device matrix is built from audience analytics: the top 5 models plus the lowest supported OS version.

Підводні камені. Реальні пристрої потребують догляду: оновлення ОС, що з'являються без попередження, повідомлення «низький заряд», блокування екрана, ліміт на розмір логів. iOS-симулятор працює на процесорі Mac і часто швидший за телефон, тож таймінгові баги на ньому ховаються. Не варто вважати тест на емуляторі доказом продуктивності.

Pitfalls. Real devices need care: OS updates that arrive without warning, "low battery" pop-ups, screen lock, limits on log size. The iOS simulator runs on the Mac's CPU and is often faster than a phone, so timing bugs hide there. A test on an emulator should not be taken as proof of performance.

Уточнююче питання. «Як скласти матрицю пристроїв?» — з аналітики: частки моделей, версій ОС і розмірів екрана, плюс один слабкий пристрій для перевірки продуктивності.

Follow-up question. "How do you build a device matrix?" From analytics: shares of models, OS versions and screen sizes, plus one low-end device for performance checks.

Підготовка реального Android до прогону
Preparing a real Android device for a run
adb -s R58N123ABC shell settings put global stay_on_while_plugged_in 3
adb -s R58N123ABC shell settings put system screen_off_timeout 1800000
adb -s R58N123ABC shell dumpsys battery | grep level
adb -s R58N123ABC shell getprop ro.build.version.release
adb -s R58N123ABC shell getprop ro.product.model
Матриця у WebdriverIO
A matrix in WebdriverIO
// wdio.conf.js
capabilities: [
  { platformName: "Android", "appium:udid": "emulator-5554" },      // every PR
  { platformName: "Android", "appium:udid": "R58N123ABC" },         // Galaxy S23, nightly
  { platformName: "iOS", "appium:udid": "00008110-001A2B3C4D5E801E" }, // iPhone 14, nightly
],
🔧 Розбір синтаксису: stay_on_while_plugged_in 3 тримає екран увімкненим при живленні від USB і AC, screen_off_timeout задається в мілісекундах. getprop читає версію ОС і модель — їх варто прикріпити до звіту. У конфігу WebdriverIO кожен елемент масиву capabilities — окремий воркер зі своїм пристроєм.
🔧 Syntax breakdown: stay_on_while_plugged_in 3 keeps the screen on while powered by USB and AC, and screen_off_timeout is in milliseconds. getprop reads the OS version and model — worth attaching to the report. In the WebdriverIO config each item of the capabilities array is a separate worker with its own device.
💡 Простими словами: Емулятор — тренажер, реальний телефон — справжня дорога з ямами. Тренуватися дешевше на тренажері, а права видають на дорозі.
💡 In plain words: An emulator is a driving simulator, a real phone is the actual road with potholes. Practice is cheaper in the simulator, but the licence is earned on the road.
🗣 Як читати вголос Емулятори й симулятори я використовую для щоденної регресії: вони дешеві, швидко скидаються і добре масштабуються в CI. Але реальні пристрої ловлять те, чого віртуальні не бачать: оболонки виробників, які вбивають фонові процеси, реальну продуктивність, камеру, біометрію, мережу оператора. Тому я будую матрицю з аналітики — топові моделі й мінімальна версія ОС — і ганяю на ній нічний прогін. І не роблю висновків про швидкість за симулятором, бо він працює на процесорі Мака.
🗣 How to say it out loud I use emulators and simulators for daily regression: they're cheap, reset quickly and scale well in CI. But real devices catch what virtual ones don't see: vendor skins that kill background processes, real performance, the camera, biometrics, the carrier network. So I build a matrix from analytics — top models plus the lowest OS version — and run a nightly suite on it. And I don't draw conclusions about speed from a simulator, because it runs on the Mac's CPU.
▶ 3 9
16 Чому accessibility id вважають найстабільнішим мобільним локатором і на що він мапиться в Android та iOS? Why is accessibility id considered the most stable mobile locator and what does it map to on Android and iOS?
Локатори Locators Junior ▶

Що це. AppiumBy.accessibilityId("checkout") — крос-платформна стратегія пошуку. На Android вона шукає за атрибутом content-desc (властивість contentDescription у View), на iOS — за name, який береться з accessibilityIdentifier. Один і той самий рядок у page object працює на обох платформах, якщо розробники проставили однакові ідентифікатори.

What it is. AppiumBy.accessibilityId("checkout") is a cross-platform lookup strategy. On Android it matches the content-desc attribute (the View's contentDescription); on iOS it matches name, which comes from accessibilityIdentifier. The same string in a page object works on both platforms if developers set identical identifiers.

Навіщо. Це прямий пошук за атрибутом, без обходу всього дерева, тому він швидкий. Він не залежить від ієрархії, тексту, мови інтерфейсу чи порядку елементів: редизайн картки товару не ламає локатор, поки ідентифікатор той самий. І це явний контракт між розробкою та QA — ідентифікатор додають свідомо.

Why. It is a direct attribute lookup with no full-tree walk, so it is fast. It does not depend on hierarchy, text, UI language or element order: a redesign of the product card does not break the locator as long as the identifier stays. And it is an explicit contract between development and QA — the identifier is added on purpose.

Приклад. Розробник на iOS пише payButton.accessibilityIdentifier = "checkout", на Android — android:contentDescription="@string/checkout_cd" або для Compose Modifier.semantics { contentDescription = "..." }. Тест викликає AppiumBy.accessibilityId("checkout") на обох платформах.

Example. On iOS a developer writes payButton.accessibilityIdentifier = "checkout"; on Android android:contentDescription="@string/checkout_cd", or for Compose Modifier.semantics { contentDescription = "..." }. The test calls AppiumBy.accessibilityId("checkout") on both platforms.

Підводні камені. На Android contentDescription читає TalkBack уголос — технічні рядки на кшталт btn_checkout_v2 зіпсують досвід незрячих користувачів, тож для Android часто краще resource-id. На iOS accessibilityIdentifier не озвучується, тому там цієї проблеми немає. Ще пастка: локалізований contentDescription змінюється з мовою, і тест на німецькій локалі не знайде «Checkout».

Pitfalls. On Android TalkBack reads contentDescription aloud — technical strings like btn_checkout_v2 ruin the experience for blind users, so on Android a resource-id is often the better choice. On iOS accessibilityIdentifier is not announced, so that problem does not exist there. Another trap: a localised contentDescription changes with the language, and a test on a German locale will not find "Checkout".

Уточнююче питання. «А якщо ідентифікаторів немає?» — домовитися з розробниками про їх додавання як частину Definition of Done, а тимчасово використати resource-id чи predicate, але не абсолютний XPath.

Follow-up question. "What if there are no identifiers?" Agree with developers to add them as part of the Definition of Done, and meanwhile use resource-id or a predicate, but not an absolute XPath.

Один локатор для двох платформ
One locator for two platforms
WebElement checkout = driver.findElement(AppiumBy.accessibilityId("checkout"));
checkout.click();

// Android: content-desc="checkout"   iOS: name="checkout"
WebElement checkout = driver.findElement(AppiumBy.accessibilityId("checkout"));
checkout.click();

// Android: content-desc="checkout"   iOS: name="checkout"
WebdriverIO: префікс ~
WebdriverIO: the ~ prefix
const checkout = await $("~checkout");
await checkout.waitForDisplayed({ timeout: 10000 });
await checkout.click();
🔧 Розбір синтаксису: AppiumBy.accessibilityId — стратегія accessibility id протоколу Appium. У WebdriverIO тильда ~ на початку селектора означає ту саму стратегію. waitForDisplayed чекає, доки елемент стане видимим, з таймаутом у мілісекундах.
🔧 Syntax breakdown: AppiumBy.accessibilityId is Appium's accessibility id strategy. In WebdriverIO a leading tilde ~ in the selector means the same strategy. waitForDisplayed waits until the element is visible, with a timeout in milliseconds.
💡 Простими словами: Accessibility id — бейдж з іменем на елементі. Хоч куди його пересадять, бейдж той самий.
💡 In plain words: An accessibility id is a name badge on the element. Wherever it gets moved, the badge stays the same.
🗣 Як читати вголос Accessibility id — мій перший вибір, бо це прямий пошук за атрибутом: швидкий, не залежить від ієрархії чи тексту і однаковий на двох платформах. На Android він читає content-desc, на iOS — accessibilityIdentifier. Але я пам'ятаю про нюанс: на Android content-desc озвучує TalkBack, тож технічні назви туди пхати не можна, і там я часто беру resource-id. А якщо ідентифікаторів немає, я домовляюся з розробниками, щоб їх додавали разом із фічею.
🗣 How to say it out loud Accessibility id is my first choice because it's a direct attribute lookup: fast, independent of hierarchy or text, and the same on both platforms. On Android it reads content-desc, on iOS accessibilityIdentifier. But I keep one nuance in mind: on Android TalkBack reads content-desc aloud, so technical names mustn't go there, and I often use resource-id instead. And if there are no identifiers, I agree with developers that they're added along with the feature.
17 Як accessibility-атрибути допомагають одночасно тестам і людям з допоміжними технологіями, і де тут конфлікт? How do accessibility attributes help both tests and people using assistive technology, and where is the conflict?
Локатори Locators Middle ▶

Що це. Мобільні платформи будують дерево доступності для TalkBack і VoiceOver. Appium читає те саме дерево. Тому елемент, у якого є зрозуміла мітка й роль, одночасно доступний і для незрячого користувача, і для тесту. Кнопка-іконка без мітки невидима для обох: скрінрідер скаже «кнопка», а тест муситиме шукати її XPath за позицією.

What it is. Mobile platforms build an accessibility tree for TalkBack and VoiceOver. Appium reads the same tree. So an element with a clear label and role is available both to a blind user and to a test at once. An icon button without a label is invisible to both: the screen reader says "button", and the test has to find it by an XPath position.

Навіщо. Автотести стають дешевим сигналом доступності: якщо тестувальник не може знайти елемент стабільним локатором, найімовірніше, і скрінрідер не має що сказати. Команда, яка просить accessibility id для тестів, заодно покращує продукт для реальних людей.

Why. Automated tests become a cheap accessibility signal: if a tester cannot find an element with a stable locator, the screen reader most likely has nothing to say either. A team that asks for accessibility ids for tests also improves the product for real people.

Приклад. Правильне розділення на Android: contentDescription = "Додати в кошик" (для людей, локалізоване), resource-id чи Compose testTag з testTagsAsResourceId — для тестів. На iOS: accessibilityLabel = "Додати в кошик" для VoiceOver і окремий accessibilityIdentifier = "addToCart" для тестів — ці властивості незалежні.

Example. The right split on Android: contentDescription = "Add to cart" (for people, localised), and resource-id or a Compose testTag with testTagsAsResourceId for tests. On iOS: accessibilityLabel = "Add to cart" for VoiceOver and a separate accessibilityIdentifier = "addToCart" for tests — those properties are independent.

Підводні камені. Конфлікт виникає на Android, коли тест вимагає content-desc: розробник ставить туди add_to_cart_btn, і TalkBack читає це вголос. Інший крайній випадок — мітка на контейнері, яка «ковтає» дочірні елементи, і тест уже не бачить окремих кнопок усередині картки (на iOS це isAccessibilityElement = true на батьківському View).

Pitfalls. The conflict shows up on Android when tests demand content-desc: the developer puts add_to_cart_btn there and TalkBack reads it aloud. The other extreme is a label on a container that "swallows" its children, so the test no longer sees individual buttons inside the card (on iOS that is isAccessibilityElement = true on the parent View).

Уточнююче питання. «Чи може Appium перевірити доступність?» — частково: можна пройти всі клікабельні елементи й перевірити наявність мітки; повний аудит роблять Accessibility Scanner та Accessibility Inspector.

Follow-up question. "Can Appium check accessibility?" Partly: you can walk all clickable elements and check that each has a label; a full audit is done with Accessibility Scanner and Accessibility Inspector.

Простий a11y-смоук: іконки без мітки
A simple a11y smoke: icons without a label
List<WebElement> icons = driver.findElements(
    AppiumBy.androidUIAutomator("new UiSelector().className(\"android.widget.ImageButton\")"));

List<String> unlabeled = icons.stream()
    .filter(e -> {
        String cd = e.getAttribute("content-desc");
        return cd == null || cd.isBlank();
    })
    .map(e -> e.getAttribute("resource-id"))
    .toList();

// кожна іконка-кнопка має мати мітку для TalkBack
assertTrue(unlabeled.isEmpty(), "ImageButtons without content-desc: " + unlabeled);
List<WebElement> icons = driver.findElements(
    AppiumBy.androidUIAutomator("new UiSelector().className(\"android.widget.ImageButton\")"));

List<String> unlabeled = icons.stream()
    .filter(e -> {
        String cd = e.getAttribute("content-desc");
        return cd == null || cd.isBlank();
    })
    .map(e -> e.getAttribute("resource-id"))
    .toList();

// every icon button needs a label for TalkBack
assertTrue(unlabeled.isEmpty(), "ImageButtons without content-desc: " + unlabeled);
🔧 Розбір синтаксису: UiSelector().className(...) знаходить усі ImageButton на екрані. getAttribute("content-desc") у UiAutomator2 повертає мітку або порожнє значення. Стрім збирає resource-id проблемних кнопок, щоб у звіті було видно, які саме елементи без мітки.
🔧 Syntax breakdown: UiSelector().className(...) finds every ImageButton on the screen. getAttribute("content-desc") in UiAutomator2 returns the label or an empty value. The stream collects the resource-ids of the offending buttons so the report shows exactly which elements lack a label.
💡 Простими словами: Тест і скрінрідер читають одну й ту саму «табличку» на кнопці. Якщо табличка порожня, обидва губляться.
💡 In plain words: The test and the screen reader read the same sign on the button. If the sign is blank, both get lost.
🗣 Як читати вголос Для мене тест і скрінрідер — сусіди, бо обидва читають дерево доступності. Якщо в іконки немає мітки, VoiceOver скаже просто «кнопка», а я буду змушений шукати її за позицією. Тому прохання додати ідентифікатори корисне і для людей. Але я розділяю ролі: на iOS є окремі label для людей та identifier для тестів, а на Android не варто засмічувати content-desc технічними назвами — для тестів краще resource-id або testTag. Іноді додаю простий смоук, що всі іконки мають мітки.
🗣 How to say it out loud To me the test and the screen reader are neighbours, because both read the accessibility tree. If an icon has no label, VoiceOver just says "button" and I'm forced to find it by position. So asking for identifiers helps people too. But I separate the roles: iOS has a label for people and an identifier for tests, while on Android you shouldn't clutter content-desc with technical names — resource-id or testTag is better for tests. Sometimes I add a simple smoke check that every icon has a label.
18 Чим ризиковий XPath у мобільних тестах і як виглядає поганий та добрий локатор? What is risky about XPath in mobile tests and what do a bad and a good locator look like?
Локатори Locators Junior ▶

Що це. XPath в Appium працює не з HTML, а з XML-знімком UI, який драйвер будує на кожен запит. Щоб виконати //android.widget.Button[@text='Pay'], UiAutomator2 чи WebDriverAgent мусить серіалізувати все дерево екрана в XML, а потім пройтися по ньому — на складному екрані це сотні мілісекунд або секунди на кожен пошук.

What it is. XPath in Appium works not on HTML but on an XML snapshot of the UI that the driver builds for every request. To evaluate //android.widget.Button[@text='Pay'], UiAutomator2 or WebDriverAgent must serialise the whole screen tree to XML and then walk it — on a complex screen that is hundreds of milliseconds or seconds per lookup.

Навіщо знати. Крім швидкості, XPath крихкий: абсолютний шлях з індексами прив'язаний до поточної ієрархії, і обгортка LinearLayout від розробника ламає десятки тестів. На iOS різниця ще більша: побудова дерева через XCTest повільна, і XPath-тести відчутно гальмують. Тому XPath — останній варіант, коли інших атрибутів немає.

Why it matters. Beyond speed, XPath is brittle: an absolute path with indexes is tied to the current hierarchy, and one extra LinearLayout wrapper from a developer breaks dozens of tests. On iOS the gap is even bigger: building the tree through XCTest is slow, and XPath tests drag noticeably. So XPath is the last resort when there are no other attributes.

Приклад. Поганий: /hierarchy/android.widget.FrameLayout/.../android.widget.LinearLayout[2]/android.widget.Button[1]. Кращий, якщо без XPath не обійтись: відносний за стабільним атрибутом //*[@resource-id='com.example.shop:id/pay']. Добрий: AppiumBy.accessibilityId("pay") або AppiumBy.id("com.example.shop:id/pay").

Example. Bad: /hierarchy/android.widget.FrameLayout/.../android.widget.LinearLayout[2]/android.widget.Button[1]. Better, if XPath cannot be avoided: relative by a stable attribute, //*[@resource-id='com.example.shop:id/pay']. Good: AppiumBy.accessibilityId("pay") or AppiumBy.id("com.example.shop:id/pay").

Підводні камені. Локатори, скопійовані з Appium Inspector «як є», часто абсолютні — їх треба переписувати. XPath за текстом ламається при зміні локалі. findElements з XPath у циклі по списку множить повільність на кількість ітерацій. Там, де потрібні умови, на Android краще UiSelector, на iOS — predicate string.

Pitfalls. Locators copied from Appium Inspector "as is" are often absolute and need rewriting. XPath by text breaks when the locale changes. findElements with XPath inside a loop over a list multiplies the slowness by the number of iterations. Where conditions are needed, UiSelector is better on Android and predicate string on iOS.

Уточнююче питання. «Коли XPath прийнятний?» — для пошуку за зв'язком (кнопка всередині картки з певним заголовком), коли інших засобів немає, і лише відносний, з коротким шляхом.

Follow-up question. "When is XPath acceptable?" For finding by relationship (a button inside a card with a given title) when nothing else works, and only relative, with a short path.

Поганий, кращий і добрий локатор
Bad, better and good locators
// погано: абсолютний шлях, ламається від будь-якої обгортки
By bad = AppiumBy.xpath(
    "/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout[2]/android.widget.Button[1]");

// краще: відносний XPath за стабільним атрибутом
By better = AppiumBy.xpath("//*[@resource-id='com.example.shop:id/pay']");

// добре: прямий пошук без побудови XML-дерева
By good = AppiumBy.accessibilityId("pay");
By alsoGood = AppiumBy.id("com.example.shop:id/pay");
// bad: absolute path, breaks with any wrapper
By bad = AppiumBy.xpath(
    "/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout[2]/android.widget.Button[1]");

// better: relative XPath by a stable attribute
By better = AppiumBy.xpath("//*[@resource-id='com.example.shop:id/pay']");

// good: direct lookup without building the XML tree
By good = AppiumBy.accessibilityId("pay");
By alsoGood = AppiumBy.id("com.example.shop:id/pay");
🔧 Розбір синтаксису: AppiumBy.xpath змушує драйвер отримати і розібрати весь page source. AppiumBy.id на Android шукає за resource-id у форматі пакет:id/назва, на iOS — за name. Одинарні лапки всередині XPath дозволяють не екранувати подвійні в Java-рядку.
🔧 Syntax breakdown: AppiumBy.xpath forces the driver to fetch and parse the whole page source. AppiumBy.id on Android matches resource-id in the package:id/name format, and on iOS it matches name. Single quotes inside the XPath avoid escaping double quotes in the Java string.
💡 Простими словами: XPath — як пояснювати дорогу «третій будинок після другого повороту». Accessibility id — точна адреса.
💡 In plain words: XPath is like giving directions as "the third house after the second turn". An accessibility id is the exact address.
🗣 Як читати вголос З XPath у мобільних тестах дві проблеми. Перша — швидкість: драйвер мусить зібрати все дерево екрана в XML на кожен пошук, і на iOS це особливо помітно. Друга — крихкість: абсолютний шлях з індексами ламається від будь-якої нової обгортки в розмітці. Тому мій порядок такий: accessibility id, потім resource-id, потім UiSelector або predicate, і лише в кінці відносний XPath за стабільним атрибутом. Локатори з Inspector я ніколи не копіюю як є.
🗣 How to say it out loud XPath in mobile tests has two problems. The first is speed: the driver has to build the whole screen tree as XML for every lookup, and on iOS it's especially noticeable. The second is brittleness: an absolute path with indexes breaks with any new wrapper in the layout. So my order is accessibility id, then resource-id, then UiSelector or predicate, and only at the end a relative XPath by a stable attribute. I never copy locators from Inspector as they are.
19 Коли на Android використовувати resource-id, а коли -android uiautomator з UiSelector? On Android, when do you use resource-id and when -android uiautomator with UiSelector?
Локатори Locators Middle ▶

Що це. AppiumBy.id(...) шукає за resource-id — ідентифікатором View з розмітки (android:id="@+id/pay"). AppiumBy.androidUIAutomator(...) передає рядок із Java-виразом UiSelector, який виконується прямо на пристрої всередині UiAutomator: можна комбінувати клас, текст, опис, індекс, стан (checked, enabled) і навіть скролити до елемента.

What it is. AppiumBy.id(...) matches resource-id, the View identifier from the layout (android:id="@+id/pay"). AppiumBy.androidUIAutomator(...) passes a string with a UiSelector Java expression that runs right on the device inside UiAutomator: you can combine class, text, description, index and state (checked, enabled), and even scroll to the element.

Навіщо. resource-id — найкращий нативний Android-локатор: стабільний, не озвучується TalkBack, не залежить від мови. UiSelector потрібен, коли одного атрибута мало: «кнопка з текстом, що починається з Pay, яка ввімкнена», «третій елемент списку з цим id», або скрол до елемента через UiScrollable. Він виконується нативно і значно швидший за XPath з тими самими умовами.

Why. resource-id is the best native Android locator: stable, not announced by TalkBack, independent of language. UiSelector is needed when one attribute is not enough: "a button whose text starts with Pay and is enabled", "the third list item with this id", or scrolling to an element with UiScrollable. It runs natively and is much faster than an XPath with the same conditions.

Приклад. У списку замовлень усі рядки мають однаковий resource-id order_title. Потрібен рядок «Замовлення №1042» — пишемо UiSelector із resourceId і textContains. Для Compose, щоб з'явився resource-id, розробник вмикає testTagsAsResourceId = true на корені, і тоді Modifier.testTag("pay") видно як resource-id="pay" без префікса пакета.

Example. In the order list every row has the same resource-id order_title. We need the row "Order #1042", so we write a UiSelector with resourceId and textContains. For Compose, to get a resource-id at all, the developer enables testTagsAsResourceId = true on the root, and then Modifier.testTag("pay") shows up as resource-id="pay" with no package prefix.

Підводні камені. UiSelector — рядок, тож помилку в синтаксисі видно лише під час виконання («UiSelector[...] could not be parsed»). Лапки всередині треба екранувати в Java. Для AppiumBy.id UiAutomator2 підставляє пакет застосунку, якщо передати коротке ім'я, але для елементів іншого пакета (системний діалог) потрібен повний id.

Pitfalls. UiSelector is a string, so a syntax mistake only shows at run time ("UiSelector[...] could not be parsed"). Quotes inside must be escaped in Java. For AppiumBy.id UiAutomator2 prepends the app package when you pass a short name, but elements of another package (a system dialog) need the full id.

Уточнююче питання. «Чи працює UiSelector на iOS?» — ні, це суто Android; на iOS аналог — predicate string і class chain.

Follow-up question. "Does UiSelector work on iOS?" No, it is Android-only; the iOS equivalents are predicate string and class chain.

resource-id і UiSelector
resource-id and UiSelector
By pay = AppiumBy.id("com.example.shop:id/pay");

By order1042 = AppiumBy.androidUIAutomator(
    "new UiSelector().resourceId(\"com.example.shop:id/order_title\").textContains(\"1042\")");

By enabledSubmit = AppiumBy.androidUIAutomator(
    "new UiSelector().className(\"android.widget.Button\").textStartsWith(\"Pay\").enabled(true)");

// системний діалог — інший пакет, тому повний id
By allow = AppiumBy.id("com.android.permissioncontroller:id/permission_allow_button");
By pay = AppiumBy.id("com.example.shop:id/pay");

By order1042 = AppiumBy.androidUIAutomator(
    "new UiSelector().resourceId(\"com.example.shop:id/order_title\").textContains(\"1042\")");

By enabledSubmit = AppiumBy.androidUIAutomator(
    "new UiSelector().className(\"android.widget.Button\").textStartsWith(\"Pay\").enabled(true)");

// system dialog is another package, so the full id
By allow = AppiumBy.id("com.android.permissioncontroller:id/permission_allow_button");
🔧 Розбір синтаксису: Методи UiSelector ланцюжаться, і всі умови мають виконатися одночасно. textContains і textStartsWith порівнюють видимий текст, enabled(true) відсікає неактивні кнопки. Зворотна коса риска перед лапками — екранування в Java-рядку, сам UiSelector бачить звичайні лапки.
🔧 Syntax breakdown: UiSelector methods chain, and all conditions must hold at once. textContains and textStartsWith compare the visible text, and enabled(true) filters out disabled buttons. The backslash before the quotes is Java string escaping; UiSelector itself sees plain quotes.
💡 Простими словами: resource-id — табличка на дверях, UiSelector — пошуковий запит із кількома умовами, який виконує сам телефон.
💡 In plain words: resource-id is the sign on the door; UiSelector is a search query with several conditions that the phone runs itself.
🗣 Як читати вголос На Android мій базовий вибір — resource-id: він стабільний, не залежить від мови і не заважає TalkBack. Якщо одного атрибута мало, наприклад у списку всі рядки з однаковим id і мені потрібен конкретний номер замовлення, я беру UiSelector. Він виконується прямо на телефоні, тому набагато швидший за XPath з тими ж умовами. Мінус — це рядок, і помилку в ньому побачиш лише під час запуску. Для Compose прошу розробників увімкнути testTagsAsResourceId, інакше тегів не видно.
🗣 How to say it out loud On Android my default is resource-id: it's stable, language-independent and doesn't get in TalkBack's way. If one attribute isn't enough, say all list rows share the same id and I need a specific order number, I use UiSelector. It runs right on the phone, so it's much faster than XPath with the same conditions. The downside is it's a string, and you only see a mistake at run time. For Compose I ask developers to enable testTagsAsResourceId, otherwise tags aren't visible.
20 Що таке iOS predicate string і class chain і чим вони кращі за XPath на iOS? What are iOS predicate string and class chain, and why are they better than XPath on iOS?
Локатори Locators Middle ▶

Що це. Обидві стратегії — нативні для XCUITest-драйвера. -ios predicate string передає вираз NSPredicate, який XCTest виконує сам: умови за атрибутами type, name, label, value, enabled, visible з операторами ==, BEGINSWITH, CONTAINS, MATCHES, AND, OR. -ios class chain — спрощений шлях по ієрархії від Appium, на кшталт **/XCUIElementTypeCell[3], де можна додавати умови-предикати.

What it is. Both strategies are native to the XCUITest driver. -ios predicate string passes an NSPredicate expression that XCTest evaluates itself: conditions on the type, name, label, value, enabled and visible attributes with ==, BEGINSWITH, CONTAINS, MATCHES, AND, OR. -ios class chain is a simplified hierarchy path invented by Appium, like **/XCUIElementTypeCell[3], where predicate conditions can be added.

Навіщо. XPath на iOS змушує WebDriverAgent побудувати повний XML усього дерева, і це повільно. Predicate і class chain перекладаються в нативні запити XCTest (XCUIElementQuery), тому швидші в рази. Predicate зручний для пошуку за кількома атрибутами, class chain — коли важлива структура: «кнопка всередині третьої комірки таблиці».

Why. XPath on iOS forces WebDriverAgent to build a full XML of the whole tree, which is slow. Predicate and class chain are translated into native XCTest queries (XCUIElementQuery), so they are many times faster. Predicate is handy for matching several attributes; class chain when structure matters: "the button inside the third table cell".

Приклад. Кнопка оплати, що починається з «Pay» і доступна: predicate type == 'XCUIElementTypeButton' AND label BEGINSWITH 'Pay' AND enabled == 1. Друга кнопка в третій комірці: class chain **/XCUIElementTypeCell[3]/XCUIElementTypeButton[2].

Example. A pay button whose label starts with "Pay" and is enabled: predicate type == 'XCUIElementTypeButton' AND label BEGINSWITH 'Pay' AND enabled == 1. The second button in the third cell: class chain **/XCUIElementTypeCell[3]/XCUIElementTypeButton[2].

Підводні камені. Оператори чутливі до регістру; для нечутливого порівняння додають [c]: label CONTAINS[c] 'pay'. Рядки в predicate беруться в одинарні лапки. В class chain умови-предикати записуються всередині квадратних дужок у зворотних лапках, що легко зламати при копіюванні, тож їх варто тримати в константах. label локалізований, тому для стабільності краще name (accessibilityIdentifier).

Pitfalls. Operators are case-sensitive; for a case-insensitive match add [c]: label CONTAINS[c] 'pay'. Strings in a predicate go in single quotes. In class chain, predicate conditions are written inside square brackets wrapped in backquote characters, which is easy to break when copying, so keep them in constants. label is localised, so name (accessibilityIdentifier) is more stable.

Уточнююче питання. «Як перевірити вираз до запуску тесту?» — в Appium Inspector є пошук елементів із вибором стратегії predicate чи class chain.

Follow-up question. "How do you check an expression before running the test?" Appium Inspector has element search where you choose the predicate or class chain strategy.

Predicate і class chain у Java
Predicate and class chain in Java
By pay = AppiumBy.iOSNsPredicateString(
    "type == 'XCUIElementTypeButton' AND label BEGINSWITH 'Pay' AND enabled == 1");

By search = AppiumBy.iOSNsPredicateString("name == 'searchField'");

By secondButtonInThirdCell = AppiumBy.iOSClassChain(
    "**/XCUIElementTypeCell[3]/XCUIElementTypeButton[2]");
Те саме у WebdriverIO
The same in WebdriverIO
const pay = await $("-ios predicate string:type == 'XCUIElementTypeButton' AND label BEGINSWITH 'Pay'");
const cellButton = await $("-ios class chain:**/XCUIElementTypeCell[3]/XCUIElementTypeButton[2]");
🔧 Розбір синтаксису: AppiumBy.iOSNsPredicateString і AppiumBy.iOSClassChain відповідають стратегіям -ios predicate string і -ios class chain. **/ у class chain означає «будь-який нащадок на будь-якій глибині», індекс у дужках рахується з одиниці. У WebdriverIO стратегію пишуть префіксом із двокрапкою прямо в селекторі.
🔧 Syntax breakdown: AppiumBy.iOSNsPredicateString and AppiumBy.iOSClassChain map to the -ios predicate string and -ios class chain strategies. **/ in a class chain means "any descendant at any depth", and the index in brackets is 1-based. In WebdriverIO the strategy is written as a prefix with a colon right in the selector.
💡 Простими словами: Predicate — це фільтр «покажи всі кнопки, що починаються з Pay», class chain — адреса «третя клітинка, друга кнопка».
💡 In plain words: A predicate is a filter like "show all buttons starting with Pay"; a class chain is an address like "third cell, second button".
🗣 Як читати вголос На iOS я майже не використовую XPath, бо WebDriverAgent мусить будувати для нього повне XML-дерево, і це повільно. Замість нього є дві нативні стратегії. Predicate string — це вираз NSPredicate за атрибутами: тип, name, label, enabled, з операторами на кшталт BEGINSWITH чи CONTAINS. Class chain — короткий шлях по ієрархії, коли важлива структура, наприклад друга кнопка в третій клітинці. Обидві XCTest виконує сам, тож вони в рази швидші. Для стабільності опираюсь на name, а не на локалізований label.
🗣 How to say it out loud On iOS I hardly use XPath, because WebDriverAgent has to build a full XML tree for it, and that's slow. Instead there are two native strategies. Predicate string is an NSPredicate expression over attributes: type, name, label, enabled, with operators like BEGINSWITH or CONTAINS. Class chain is a short hierarchy path when structure matters, like the second button in the third cell. XCTest evaluates both itself, so they're many times faster. For stability I rely on name, not the localised label.
21 Навіщо мобільним тестам явні очікування і чому Thread.sleep та великий implicit wait — погана ідея? Why do mobile tests need explicit waits, and why are Thread.sleep and a large implicit wait a bad idea?
Локатори Locators Junior ▶

Що це. Явне очікування (WebDriverWait у Java, waitForDisplayed/waitUntil у WebdriverIO) повторює перевірку умови, доки вона не стане істинною або не мине таймаут. Implicit wait — глобальне налаштування сесії: кожен findElement чекає до N секунд, поки елемент з'явиться.

What it is. An explicit wait (WebDriverWait in Java, waitForDisplayed/waitUntil in WebdriverIO) re-checks a condition until it becomes true or the timeout expires. Implicit wait is a global session setting: every findElement waits up to N seconds for the element to appear.

Навіщо. Мобільний UI постійно в русі: переходи між екранами, анімації, завантаження даних із мережі, клавіатура, що виїжджає. Елемент може бути в дереві, але ще не клікабельний, або з'явитися через дві секунди після відповіді бекенду. Очікування на конкретну умову робить тест і стабільним, і швидким — він іде далі, щойно умова виконалась.

Why. Mobile UI is always moving: screen transitions, animations, data loading over the network, the keyboard sliding in. An element may be in the tree but not yet clickable, or appear two seconds after the backend responds. Waiting for a specific condition makes the test both stable and fast — it moves on as soon as the condition holds.

Приклад. Після натискання «Увійти» чекаємо visibilityOfElementLocated для заголовка головного екрана до 15 секунд. Якщо бекенд відповів за 1,2 с, тест продовжиться через 1,2 с, а не через фіксовані 5.

Example. After tapping "Log in" we wait up to 15 seconds for visibilityOfElementLocated on the home screen title. If the backend answered in 1.2 s, the test continues after 1.2 s, not after a fixed 5.

Підводні камені. Thread.sleep(5000) одночасно повільний (на швидкому пристрої) і нестабільний (на повільному). Великий implicit wait разом з явними очікуваннями дає непередбачувані таймаути, а перевірка «елемента немає» чекає повний implicit wait на кожен виклик. Тому implicit ставлять у нуль або дуже малим, а чекають явно. Ще одна пастка — чекати лише «присутності» в дереві: елемент під анімацією вже є, але клік потрапить не туди.

Pitfalls. Thread.sleep(5000) is at once slow (on a fast device) and flaky (on a slow one). A large implicit wait combined with explicit waits gives unpredictable timeouts, and an "element is absent" check waits the full implicit timeout on every call. So implicit is set to zero or very small, and waiting is done explicitly. Another trap is waiting only for "presence" in the tree: an element mid-animation is already there, but the tap lands in the wrong place.

Уточнююче питання. «Як дочекатися зникнення лоадера?» — invisibilityOfElementLocated для спінера, а не пауза.

Follow-up question. "How do you wait for a loader to go away?" invisibilityOfElementLocated on the spinner, not a pause.

Явні очікування в Java
Explicit waits in Java
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));

driver.findElement(AppiumBy.accessibilityId("login")).click();
wait.until(ExpectedConditions.invisibilityOfElementLocated(AppiumBy.id("com.example.shop:id/spinner")));
WebElement title = wait.until(
    ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("homeTitle")));
І у WebdriverIO
And in WebdriverIO
await $("~login").click();
await $("id=com.example.shop:id/spinner").waitForDisplayed({ reverse: true, timeout: 15000 });
await $("~homeTitle").waitForDisplayed({ timeout: 15000 });
🔧 Розбір синтаксису: implicitlyWait(Duration.ZERO) вимикає глобальне очікування, щоб не змішувати два механізми. ExpectedConditions з Selenium працюють і з мобільним драйвером. У WebdriverIO reverse: true перетворює очікування появи на очікування зникнення, а префікс id= задає пошук за resource-id.
🔧 Syntax breakdown: implicitlyWait(Duration.ZERO) turns off the global wait so the two mechanisms do not mix. Selenium's ExpectedConditions work with the mobile driver too. In WebdriverIO reverse: true turns "wait to appear" into "wait to disappear", and the id= prefix searches by resource-id.
💡 Простими словами: Не чекай автобус 10 хвилин за годинником — дивись на дорогу і сідай, щойно він під'їхав.
💡 In plain words: Don't wait for the bus for 10 minutes by the clock — watch the road and get on as soon as it arrives.
🗣 Як читати вголос У мобільних тестах очікування — це основа стабільності, бо інтерфейс постійно рухається: анімації, переходи, дані з мережі. Я чекаю конкретну умову, наприклад зникнення спінера й видимість заголовка, з розумним таймаутом. Тоді тест іде далі, щойно умова виконалась. Sleep я не використовую: на швидкому пристрої він марнує час, на повільному все одно не допомагає. Implicit wait тримаю нульовим, щоб не змішувати з явними, інакше перевірки на відсутність елемента стають дуже повільними.
🗣 How to say it out loud In mobile tests waiting is the foundation of stability, because the UI is always moving: animations, transitions, network data. I wait for a specific condition, like the spinner disappearing and the title becoming visible, with a sensible timeout. Then the test moves on as soon as the condition is met. I don't use sleep: on a fast device it wastes time, on a slow one it still doesn't help. I keep implicit wait at zero so it doesn't mix with explicit waits, otherwise checks for an absent element get very slow.
22 Чому в мобільних тестах виникає StaleElementReferenceException і як з ним працювати? Why does StaleElementReferenceException happen in mobile tests and how do you deal with it?
Локатори Locators Middle ▶

Що це. findElement повертає не сам елемент, а посилання на вузол у кеші драйвера. Якщо після цього UI перебудувався — перехід між екранами, оновлення списку, скрол у RecyclerView, перемальовування Compose, — вузол, на який вказує посилання, уже не існує, і наступна дія дає StaleElementReferenceException.

What it is. findElement returns not the element itself but a reference to a node in the driver's cache. If the UI is rebuilt afterwards — a screen transition, a list refresh, a scroll in a RecyclerView, a Compose recomposition — the node the reference points to no longer exists, and the next action throws StaleElementReferenceException.

Навіщо розуміти причину. На мобільних це трапляється частіше, ніж у вебі. RecyclerView і UITableView перевикористовують комірки: під час скролу той самий View отримує дані іншого рядка. Compose перебудовує дерево при кожній зміні стану. Тому «знайти один раз і тримати в полі» — поганий патерн для динамічних екранів.

Why the cause matters. On mobile this happens more often than on the web. RecyclerView and UITableView reuse cells: during a scroll the same View receives another row's data. Compose rebuilds the tree on every state change. So "find once and keep it in a field" is a bad pattern for dynamic screens.

Приклад. Тест знаходить список товарів, скролить донизу і клікає збережений на початку елемент — отримує stale або, гірше, клік по іншому товару в перевикористаній комірці. Правильно: зберігати локатор (By), а не WebElement, і шукати елемент заново безпосередньо перед дією. Page objects з AppiumFieldDecorator роблять це автоматично — поле є проксі, яке шукає елемент при кожному зверненні.

Example. A test finds a product list, scrolls to the bottom and taps an element it stored at the start — it gets stale or, worse, taps a different product in a reused cell. The right way: keep the locator (By), not the WebElement, and look the element up again right before the action. Page objects with AppiumFieldDecorator do this automatically — the field is a proxy that finds the element on every access.

Підводні камені. Обгортати кожен клік у try/catch із повтором — маскування, а не рішення: stale може означати, що екран неочікувано перезавантажився, і це баг. Повтор доречний лише для відомих перерендерів, і обмежений однією–двома спробами. Для списків краще шукати рядок за вмістом (номером замовлення), а не за індексом.

Pitfalls. Wrapping every click in try/catch with a retry is masking, not a fix: stale may mean the screen reloaded unexpectedly, which is a bug. A retry is only fine for known re-renders and limited to one or two attempts. For lists, find a row by its content (the order number), not by index.

Уточнююче питання. «Чим stale відрізняється від NoSuchElement?» — NoSuchElement означає, що елемент не знайдено зараз; stale — що знайдений раніше елемент зник або замінився.

Follow-up question. "How is stale different from NoSuchElement?" NoSuchElement means the element is not found now; stale means an element found earlier has disappeared or been replaced.

Зберігай локатор, а не елемент
Keep the locator, not the element
// погано: елемент знайдено до скролу
WebElement item = driver.findElement(AppiumBy.accessibilityId("product-42"));
scrollDown();
item.click();                       // stale або клік по іншій комірці

// добре: шукаємо в момент дії, за вмістом
By product42 = AppiumBy.androidUIAutomator(
    "new UiSelector().resourceId(\"com.example.shop:id/title\").text(\"Coffee grinder\")");
scrollDown();
wait.until(ExpectedConditions.elementToBeClickable(product42)).click();
// bad: element found before the scroll
WebElement item = driver.findElement(AppiumBy.accessibilityId("product-42"));
scrollDown();
item.click();                       // stale or a tap on another cell

// good: look up at the moment of the action, by content
By product42 = AppiumBy.androidUIAutomator(
    "new UiSelector().resourceId(\"com.example.shop:id/title\").text(\"Coffee grinder\")");
scrollDown();
wait.until(ExpectedConditions.elementToBeClickable(product42)).click();
🔧 Розбір синтаксису: By — лише опис пошуку, він не прив'язаний до конкретного вузла, тому переживає будь-який перерендер. elementToBeClickable щоразу шукає елемент заново і повертає свіже посилання. scrollDown() тут — хелпер проєкту зі скролом.
🔧 Syntax breakdown: By is only a description of the lookup, not tied to a specific node, so it survives any re-render. elementToBeClickable finds the element afresh each time and returns a fresh reference. scrollDown() here is a project helper that scrolls.
💡 Простими словами: Посилання на елемент — фото кімнати. Якщо меблі переставили, по фото вже не орієнтуєшся — треба глянути ще раз.
💡 In plain words: An element reference is a photo of a room. Once the furniture is moved, the photo is useless — you have to look again.
🗣 Як читати вголос Stale означає, що я тримаю посилання на вузол, якого вже немає: екран перебудувався, список оновився або комірку перевикористали під час скролу. На мобільних це часто, бо RecyclerView і Compose постійно перемальовують дерево. Тому я зберігаю в page object локатори, а не елементи, і шукаю елемент безпосередньо перед дією, в рядках списку — за вмістом, а не за індексом. Масові try-catch із повторами не ставлю: вони можуть сховати справжній баг з несподіваним перезавантаженням.
🗣 How to say it out loud Stale means I'm holding a reference to a node that no longer exists: the screen was rebuilt, the list refreshed, or a cell got reused during a scroll. On mobile it's common, because RecyclerView and Compose constantly redraw the tree. So in page objects I keep locators, not elements, and find the element right before the action — in list rows by content, not by index. I don't add blanket try-catch retries: they can hide a real bug with an unexpected reload.
23 Як за допомогою Appium Inspector і page source знайти добрий локатор? How do you find a good locator using Appium Inspector and the page source?
Локатори Locators Junior ▶

Що це. Page source — XML-знімок поточного дерева UI, який повертає driver.getPageSource(). На Android у ньому видно class, resource-id, content-desc, text, bounds, clickable; на iOS — type, name, label, value, visible, x/y/width/height. Appium Inspector — графічний клієнт, який відкриває сесію, показує скриншот поруч із цим деревом і дозволяє клікнути елемент, щоб побачити атрибути.

What it is. The page source is an XML snapshot of the current UI tree returned by driver.getPageSource(). On Android it shows class, resource-id, content-desc, text, bounds and clickable; on iOS type, name, label, value, visible and x/y/width/height. Appium Inspector is a GUI client that opens a session, shows a screenshot next to that tree and lets you click an element to see its attributes.

Навіщо. Inspector — основний інструмент для підбору локатора і дебагу «чому не знаходить». Він підказує готові селектори, має пошук з вибором стратегії (accessibility id, id, predicate, UiSelector, XPath), дозволяє виконати tap, swipe чи sendKeys вручну і підключитися до вже запущеної сесії тесту, щоб подивитися екран у момент зупинки.

Why. Inspector is the main tool for choosing a locator and debugging "why isn't it found". It suggests ready selectors, has a search with a strategy picker (accessibility id, id, predicate, UiSelector, XPath), lets you perform tap, swipe or sendKeys by hand, and can attach to a running test session to look at the screen at the moment it stopped.

Приклад. Запускаємо Inspector як плагін сервера (appium plugin install inspector, потім appium --use-plugins=inspector) або як десктоп-застосунок, вводимо capabilities і на екрані логіну клікаємо кнопку. Бачимо content-desc="login" — беремо accessibility id, перевіряємо його через «Search for element» і лише тоді переносимо в код.

Example. We run Inspector as a server plugin (appium plugin install inspector, then appium --use-plugins=inspector) or as the desktop app, enter capabilities, and click the button on the login screen. We see content-desc="login" — take the accessibility id, check it with "Search for element", and only then move it into code.

Підводні камені. Inspector пропонує XPath першим, коли інших атрибутів немає, — це сигнал попросити розробників про ідентифікатор, а не копіювати абсолютний шлях. Знімок статичний: після анімації дерево вже інше, тож треба натиснути Refresh. Page source на iOS для складного екрана будується секундами; викликати його в циклі в тестах не можна.

Pitfalls. Inspector offers XPath first when no other attributes exist — that is a signal to ask developers for an identifier, not to copy the absolute path. The snapshot is static: after an animation the tree is already different, so press Refresh. On iOS the page source of a complex screen takes seconds to build; calling it in a loop inside tests is a no-go.

Уточнююче питання. «Як використати page source при падінні?» — зберегти його як артефакт разом зі скриншотом: видно, що було на екрані, навіть якщо скриншот не пояснює причину.

Follow-up question. "How do you use the page source on failure?" Save it as an artifact alongside the screenshot: it shows what was on screen even when the screenshot does not explain the cause.

Фрагмент page source на Android
An Android page source fragment
<android.widget.Button index="2" package="com.example.shop"
    class="android.widget.Button" text="Log in"
    resource-id="com.example.shop:id/login" content-desc="login"
    clickable="true" enabled="true" bounds="[48,1620][1032,1764]" />
Зберегти дерево і перевірити локатор з коду
Saving the tree and checking a locator from code
String xml = driver.getPageSource();
Files.writeString(Path.of("build/artifacts/login-screen.xml"), xml);

boolean found = !driver.findElements(AppiumBy.accessibilityId("login")).isEmpty();
🔧 Розбір синтаксису: У фрагменті видно три кандидати на локатор: resource-id, content-desc і text; bounds — координати лівого верхнього і правого нижнього кутів у пікселях. findElements повертає порожній список замість винятку, тому зручний для перевірки наявності без очікування.
🔧 Syntax breakdown: The fragment shows three locator candidates: resource-id, content-desc and text; bounds are the top-left and bottom-right corner coordinates in pixels. findElements returns an empty list instead of throwing, so it is handy for checking presence without waiting.
💡 Простими словами: Inspector — рентген екрана: бачиш кістки інтерфейсу і вибираєш, за що надійно вхопитися.
💡 In plain words: Inspector is an X-ray of the screen: you see the bones of the UI and pick what to grab onto reliably.
🗣 Як читати вголос Для підбору локаторів я користуюсь Appium Inspector: він показує скриншот поруч із деревом елементів, я клікаю кнопку і бачу її атрибути. Шукаю спочатку content-desc чи accessibilityIdentifier, потім resource-id, і перевіряю вибраний локатор через пошук в Inspector, перш ніж писати код. Якщо Inspector пропонує лише абсолютний XPath, це для мене сигнал піти до розробників по ідентифікатор. А page source я зберігаю як артефакт при кожному падінні — він часто пояснює більше за скриншот.
🗣 How to say it out loud To pick locators I use Appium Inspector: it shows a screenshot next to the element tree, I click a button and see its attributes. I look for content-desc or accessibilityIdentifier first, then resource-id, and I verify the chosen locator through Inspector's search before writing code. If Inspector offers only an absolute XPath, that's my signal to go to developers for an identifier. And I save the page source as an artifact on every failure — it often explains more than the screenshot.
24 Як побудувати один page object для Android та iOS в Appium на Java? How do you build one page object for both Android and iOS with Appium in Java?
Локатори Locators Middle ▶

Що це. java-client має анотації, які описують локатор окремо для кожної платформи: @AndroidFindBy і @iOSXCUITFindBy. AppiumFieldDecorator під час PageFactory.initElements читає, який драйвер активний, і підставляє потрібний локатор. Поле стає лінивим проксі: елемент шукається при кожному зверненні, а не один раз.

What it is. java-client has annotations that describe a locator separately for each platform: @AndroidFindBy and @iOSXCUITFindBy. During PageFactory.initElements, AppiumFieldDecorator reads which driver is active and plugs in the matching locator. The field becomes a lazy proxy: the element is looked up on every access, not once.

Навіщо. Бізнес-крок «увійти з email і паролем» однаковий на обох платформах, а відрізняються лише локатори. Один клас сторінки з двома наборами анотацій прибирає дублювання тестів, і сценарій читається однаково для Android та iOS. Лінивий пошук захищає від stale-посилань після перерендеру.

Why. The business step "log in with email and password" is the same on both platforms; only the locators differ. One page class with two sets of annotations removes test duplication, and the scenario reads the same for Android and iOS. The lazy lookup protects against stale references after a re-render.

Приклад. Клас LoginPage з полями email, password і submit. Для кнопки однаковий accessibility id на обох платформах, а для поля пароля на Android — resource-id, на iOS — predicate. Таймаут у декораторі задає, скільки поле чекає на появу елемента.

Example. A LoginPage class with email, password and submit fields. The button has the same accessibility id on both platforms, while the password field uses a resource-id on Android and a predicate on iOS. The decorator timeout sets how long a field waits for the element to appear.

Підводні камені. Таймаут декоратора — фактично implicit wait для полів; якщо поставити 30 секунд, перевірки відсутності елемента стануть повільними. Платформні розбіжності у флоу (на iOS є кнопка «Done» на клавіатурі, на Android — ні) не варто ховати в if усередині тестів — краще окремі методи сторінки. Анотація @AndroidFindBy без пари на iOS дає NoSuchElement лише під час виконання, тому варто мати тест-смоук на обох платформах.

Pitfalls. The decorator timeout is effectively an implicit wait for fields; set it to 30 seconds and absence checks become slow. Platform differences in the flow (iOS has a "Done" key on the keyboard, Android does not) should not be hidden in ifs inside tests — separate page methods are better. An @AndroidFindBy without an iOS counterpart only fails with NoSuchElement at run time, so keep a smoke test on both platforms.

Уточнююче питання. «Чи можна без PageFactory?» — так: поля By для кожної платформи і вибір через driver.getCapabilities().getPlatformName(); це явніше, але більше коду.

Follow-up question. "Can you do it without PageFactory?" Yes: By fields per platform chosen via driver.getCapabilities().getPlatformName(); more explicit, but more code.

LoginPage для двох платформ
LoginPage for two platforms
public class LoginPage {
    private final AppiumDriver driver;

    @AndroidFindBy(accessibility = "email")
    @iOSXCUITFindBy(accessibility = "email")
    private WebElement email;

    @AndroidFindBy(id = "com.example.shop:id/password")
    @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeSecureTextField'")
    private WebElement password;

    @AndroidFindBy(accessibility = "submit")
    @iOSXCUITFindBy(accessibility = "submit")
    private WebElement submit;

    public LoginPage(AppiumDriver driver) {
        this.driver = driver;
        PageFactory.initElements(new AppiumFieldDecorator(driver, Duration.ofSeconds(5)), this);
    }

    public HomePage logIn(String user, String pass) {
        email.sendKeys(user);
        password.sendKeys(pass);
        submit.click();
        return new HomePage(driver);
    }
}
🔧 Розбір синтаксису: Кожне поле має дві анотації, і декоратор бере ту, що відповідає платформі сесії. accessibility, id, iOSNsPredicate, uiAutomator, iOSClassChain — атрибути анотацій для різних стратегій. Duration.ofSeconds(5) — скільки проксі чекає на елемент. Метод logIn повертає наступну сторінку, щоб тест читався як ланцюжок кроків.
🔧 Syntax breakdown: Each field has two annotations, and the decorator picks the one matching the session's platform. accessibility, id, iOSNsPredicate, uiAutomator and iOSClassChain are annotation attributes for different strategies. Duration.ofSeconds(5) is how long the proxy waits for the element. logIn returns the next page so the test reads as a chain of steps.
💡 Простими словами: Одна сторінка — два словники: той самий крок «натисни Увійти», просто сказаний мовою Android або iOS.
💡 In plain words: One page, two dictionaries: the same step "tap Log in", just said in Android or in iOS.
🗣 Як читати вголос Я роблю один page object на дві платформи. Кожне поле має дві анотації, AndroidFindBy і iOSXCUITFindBy, а AppiumFieldDecorator сам обирає потрібну за типом драйвера. Бізнес-методи на кшталт logIn однакові, тож тести не дублюються. Плюс поля ліниві: елемент шукається щоразу при зверненні, і stale-посилань менше. Таймаут декоратора тримаю коротким, бо він працює як implicit wait. А якщо флоу на платформах різний, роблю окремі методи сторінки, а не if у тесті.
🗣 How to say it out loud I build one page object for both platforms. Each field has two annotations, AndroidFindBy and iOSXCUITFindBy, and AppiumFieldDecorator picks the right one based on the driver type. Business methods like logIn are the same, so tests aren't duplicated. Plus the fields are lazy: the element is looked up on each access, so there are fewer stale references. I keep the decorator timeout short because it acts like an implicit wait. And if the flow differs between platforms, I add separate page methods, not an if in the test.
▶ 4 10
25 Як зробити свайп через W3C Actions і чому TouchAction більше не варто використовувати? How do you perform a swipe with W3C Actions and why should TouchAction no longer be used?
Жести і пристрій Gestures and device Middle ▶

Що це. W3C Actions — стандартний спосіб описати низькорівневе введення: джерело (палець, миша, клавіатура) і послідовність дій — перемістити, натиснути, пауза, відпустити. У Java це PointerInput типу TOUCH і Sequence, які передаються в driver.perform(...). Драйвер перекладає послідовність у нативні жести UiAutomator чи XCTest.

What it is. W3C Actions are the standard way to describe low-level input: a source (finger, mouse, keyboard) and a sequence of actions — move, press, pause, release. In Java that is a PointerInput of kind TOUCH and a Sequence, passed to driver.perform(...). The driver translates the sequence into native UiAutomator or XCTest gestures.

Навіщо. Старі TouchAction і MultiTouchAction належали протоколу MJSONWP з Appium 1. У java-client 9 їх видалено, а сервер Appium 2 не підтримує відповідні ендпоінти. W3C Actions однаково працюють на Android та iOS і дозволяють описати будь-який жест, включно з кількома пальцями.

Why. The old TouchAction and MultiTouchAction belonged to Appium 1's MJSONWP protocol. They are removed in java-client 9, and the Appium 2 server does not support their endpoints. W3C Actions behave the same on Android and iOS and can describe any gesture, including multi-finger ones.

Приклад. Свайп угору по списку: палець стає в точку 80% висоти екрана, опускається, за 600 мс переїжджає в точку 20% і піднімається. Координати рахуються від розміру вікна, а не хардкодяться, тому свайп однаковий на телефоні і планшеті.

Example. A swipe up through a list: the finger goes to a point at 80% of screen height, presses, moves to 20% over 600 ms and lifts. Coordinates are computed from the window size rather than hard-coded, so the swipe is the same on a phone and a tablet.

Підводні камені. Занадто швидкий рух (менше ~200 мс) система сприймає як fling, і список пролітає далеко за потрібний елемент. Старт біля самого краю екрана відкриває шторку сповіщень чи жест «назад». Без початкового createPointerMove з нульовою тривалістю палець стартує з точки (0,0). Для типових жестів простіше і стабільніше взяти mobile: swipeGesture чи mobile: scrollGesture на Android — вони виконуються нативно одним викликом.

Pitfalls. A movement that is too fast (under ~200 ms) is treated as a fling, and the list flies far past the element you need. Starting right at the screen edge opens the notification shade or the back gesture. Without the initial zero-duration createPointerMove the finger starts at (0,0). For common gestures it is simpler and more stable to use mobile: swipeGesture or mobile: scrollGesture on Android — they run natively in one call.

Уточнююче питання. «Як зробити жест двома пальцями?» — дві послідовності з двома PointerInput і різними іменами в одному perform.

Follow-up question. "How do you do a two-finger gesture?" Two sequences with two PointerInputs with different names in one perform.

Свайп угору через PointerInput
Swipe up with PointerInput
Dimension size = driver.manage().window().getSize();
int x = size.width / 2;
int startY = (int) (size.height * 0.8);
int endY = (int) (size.height * 0.2);

PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence swipe = new Sequence(finger, 1)
    .addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), x, startY))
    .addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()))
    .addAction(finger.createPointerMove(Duration.ofMillis(600), PointerInput.Origin.viewport(), x, endY))
    .addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(List.of(swipe));
Той самий свайп у WebdriverIO
The same swipe in WebdriverIO
const { width, height } = await driver.getWindowSize();
await driver
  .action("pointer", { parameters: { pointerType: "touch" } })
  .move({ x: Math.round(width / 2), y: Math.round(height * 0.8) })
  .down()
  .move({ duration: 600, x: Math.round(width / 2), y: Math.round(height * 0.2) })
  .up()
  .perform();
🔧 Розбір синтаксису: PointerInput.Kind.TOUCH оголошує палець, ім'я "finger" має бути унікальним у межах perform. Origin.viewport() означає координати відносно екрана; можна передати Origin.fromElement(el) для координат відносно елемента. MouseButton.LEFT.asArg() для дотику означає «основний контакт». Тривалість другого move задає швидкість жесту.
🔧 Syntax breakdown: PointerInput.Kind.TOUCH declares a finger, and the name "finger" must be unique within a perform. Origin.viewport() means coordinates relative to the screen; Origin.fromElement(el) gives coordinates relative to an element. For touch, MouseButton.LEFT.asArg() means "primary contact". The duration of the second move sets the gesture speed.
💡 Простими словами: W3C Actions — це покроковий сценарій для пальця: куди стати, коли натиснути, як довго вести, коли відпустити.
💡 In plain words: W3C Actions are a step-by-step script for a finger: where to stand, when to press, how long to drag, when to let go.
🗣 Як читати вголос Жести я роблю через W3C Actions: оголошую PointerInput типу touch і описую послідовність — перемістити палець, натиснути, повести з певною тривалістю, відпустити. TouchAction у новому java-client видалено, бо це старий протокол. Координати завжди рахую від розміру екрана, щоб свайп працював на будь-якому пристрої, і не починаю біля краю, щоб не відкрити шторку. Тривалість тримаю близько пів секунди, інакше список пролітає. А для простих свайпів на Android часто беру mobile swipeGesture.
🗣 How to say it out loud I do gestures with W3C Actions: I declare a touch PointerInput and describe the sequence — move the finger, press, drag for a set duration, release. TouchAction was removed from the new java-client because it's the old protocol. I always compute coordinates from the screen size so the swipe works on any device, and I don't start near the edge so I don't open the shade. I keep the duration around half a second, otherwise the list flies past. For simple swipes on Android I often use mobile swipeGesture instead.
26 Як надійно проскролити до елемента: UiScrollable, mobile: scrollGesture чи mobile: scroll на iOS? How do you reliably scroll to an element: UiScrollable, mobile: scrollGesture, or mobile: scroll on iOS?
Жести і пристрій Gestures and device Middle ▶

Що це. Є три нативні способи замість ручних свайпів. На Android UiScrollable у рядку -android uiautomator скролить контейнер, доки не знайде елемент, і одразу його повертає. Там само mobile: scrollGesture робить один скрол заданої області й повертає true, якщо можна скролити далі. На iOS mobile: scroll скролить у напрямку або до елемента за predicateString чи name.

What it is. There are three native options instead of hand-made swipes. On Android, UiScrollable inside an -android uiautomator string scrolls a container until it finds the element and returns it straight away. Also on Android, mobile: scrollGesture performs one scroll of a given area and returns true if it can scroll further. On iOS, mobile: scroll scrolls in a direction or to an element by predicateString or name.

Навіщо. Скрол «до умови» надійніший за фіксовану кількість свайпів: на маленькому екрані потрібно п'ять свайпів, на планшеті — жодного. Нативні команди враховують межі контейнера і швидкість, тож не пролітають елемент, як fling від швидкого свайпу.

Why. Scrolling "until a condition" is more reliable than a fixed number of swipes: a small screen needs five swipes, a tablet none. Native commands respect container bounds and speed, so they do not overshoot the element the way a fast swipe's fling does.

Приклад. На Android знаходимо пункт налаштувань «Privacy» через UiScrollable(...).scrollIntoView(...). Для власної логіки — цикл із mobile: scrollGesture: скролимо, поки елемент не з'явився або метод не повернув false (кінець списку). На iOS — mobile: scroll з predicateString і toVisible.

Example. On Android we find the "Privacy" settings item with UiScrollable(...).scrollIntoView(...). For custom logic, a loop with mobile: scrollGesture: scroll until the element appears or the call returns false (end of list). On iOS, mobile: scroll with predicateString and toVisible.

Підводні камені. UiScrollable за замовчуванням обмежений кількістю свайпів (30), а на екрані з кількома scrollable-контейнерами без уточнення скролить перший знайдений. scrollGesture потребує область або елемент контейнера — без них повертає помилку. На iOS mobile: scroll до елемента працює, лише якщо елемент уже є в дереві (UITableView зазвичай тримає всі комірки, а ліниві списки SwiftUI — ні). І головне правило: цикл скролу завжди має межу ітерацій.

Pitfalls. UiScrollable is capped at a number of swipes by default (30), and on a screen with several scrollable containers it scrolls the first one found unless you narrow it down. scrollGesture needs an area or a container element — without them it errors. On iOS, mobile: scroll to an element only works if the element is already in the tree (UITableView usually keeps all cells, lazy SwiftUI lists do not). And the main rule: a scroll loop always has an iteration limit.

Уточнююче питання. «Як скролити горизонтальну карусель?» — setAsHorizontalList() в UiScrollable або direction: "left" у scrollGesture з областю каруселі.

Follow-up question. "How do you scroll a horizontal carousel?" setAsHorizontalList() in UiScrollable, or direction: "left" in scrollGesture with the carousel's area.

Три способи скролу
Three ways to scroll
// Android: UiScrollable знаходить і повертає елемент
WebElement privacy = driver.findElement(AppiumBy.androidUIAutomator(
    "new UiScrollable(new UiSelector().scrollable(true))"
    + ".scrollIntoView(new UiSelector().text(\"Privacy\"))"));

// Android: скрол до умови з межею ітерацій
WebElement list = driver.findElement(AppiumBy.id("com.example.shop:id/products"));
By target = AppiumBy.accessibilityId("product-42");
for (int i = 0; i < 10 && driver.findElements(target).isEmpty(); i++) {
    boolean more = (Boolean) driver.executeScript("mobile: scrollGesture", Map.of(
        "elementId", ((RemoteWebElement) list).getId(),
        "direction", "down",
        "percent", 0.75));
    if (!more) break;
}

// iOS: скрол до елемента за предикатом
driver.executeScript("mobile: scroll", Map.of(
    "predicateString", "name == 'product-42'",
    "toVisible", true));
// Android: UiScrollable finds and returns the element
WebElement privacy = driver.findElement(AppiumBy.androidUIAutomator(
    "new UiScrollable(new UiSelector().scrollable(true))"
    + ".scrollIntoView(new UiSelector().text(\"Privacy\"))"));

// Android: scroll until a condition, with an iteration limit
WebElement list = driver.findElement(AppiumBy.id("com.example.shop:id/products"));
By target = AppiumBy.accessibilityId("product-42");
for (int i = 0; i < 10 && driver.findElements(target).isEmpty(); i++) {
    boolean more = (Boolean) driver.executeScript("mobile: scrollGesture", Map.of(
        "elementId", ((RemoteWebElement) list).getId(),
        "direction", "down",
        "percent", 0.75));
    if (!more) break;
}

// iOS: scroll to an element by predicate
driver.executeScript("mobile: scroll", Map.of(
    "predicateString", "name == 'product-42'",
    "toVisible", true));
🔧 Розбір синтаксису: UiSelector().scrollable(true) обирає контейнер, scrollIntoView — ціль. percent у scrollGesture — частка розміру області, яку проходить жест. RemoteWebElement.getId() дає внутрішній id елемента, який чекають mobile:-команди. На iOS без elementId скролиться перший знайдений scroll view.
🔧 Syntax breakdown: UiSelector().scrollable(true) picks the container and scrollIntoView the target. percent in scrollGesture is the share of the area the gesture travels. RemoteWebElement.getId() gives the internal element id that mobile: commands expect. On iOS without elementId the first scroll view found is scrolled.
💡 Простими словами: Не рахуй кроки до потрібних дверей — іди коридором, доки не побачиш табличку, але не далі за кінець коридору.
💡 In plain words: Don't count steps to the door you need — walk the corridor until you see the sign, but never past the end of it.
🗣 Як читати вголос Я скролю не фіксованою кількістю свайпів, а до умови. На Android найпростіше — UiScrollable зі scrollIntoView: він сам крутить список, поки не знайде елемент. Якщо потрібна своя логіка, беру mobile scrollGesture у циклі: він повертає, чи є куди скролити далі, і я виходжу, коли елемент з'явився або список закінчився. На iOS — mobile scroll з предикатом. І завжди ставлю межу ітерацій, щоб тест не крутився нескінченно, коли елемента просто немає.
🗣 How to say it out loud I don't scroll a fixed number of swipes, I scroll until a condition. On Android the simplest is UiScrollable with scrollIntoView: it scrolls the list until it finds the element. If I need custom logic, I use mobile scrollGesture in a loop: it tells me whether there's more to scroll, and I stop when the element appears or the list ends. On iOS it's mobile scroll with a predicate. And I always cap the iterations so the test doesn't spin forever when the element simply isn't there.
27 Як виконати довге натискання, подвійний тап і pinch: W3C Actions чи mobile:-команди? How do you do a long press, double tap and pinch: W3C Actions or mobile: commands?
Жести і пристрій Gestures and device Middle ▶

Що це. Для складних жестів є два шляхи. W3C Actions описують рух пальців вручну: довге натискання — це down, пауза 1–2 секунди і up; pinch — два пальці, що рухаються назустріч чи врозбіг. Mobile:-команди — готові нативні жести драйвера: на Android mobile: longClickGesture, mobile: doubleClickGesture, mobile: pinchOpenGesture, на iOS mobile: touchAndHold, mobile: doubleTap, mobile: pinch.

What it is. Complex gestures have two routes. W3C Actions describe finger movement by hand: a long press is down, a 1–2 second pause and up; a pinch is two fingers moving towards or away from each other. Mobile: commands are the driver's ready-made native gestures: on Android mobile: longClickGesture, mobile: doubleClickGesture, mobile: pinchOpenGesture; on iOS mobile: touchAndHold, mobile: doubleTap, mobile: pinch.

Навіщо обирати. Mobile:-команди виконуються одним викликом усередині UiAutomator чи XCTest, тому точніші й стабільніші: ОС отримує саме системний жест, а не набір точок. W3C Actions потрібні, коли жест нестандартний (малювання підпису, drag по кривій) або потрібна однакова реалізація для двох платформ.

Why choose. Mobile: commands run in one call inside UiAutomator or XCTest, so they are more precise and stable: the OS receives a real system gesture, not a set of points. W3C Actions are needed when the gesture is unusual (drawing a signature, dragging along a curve) or when you want one implementation for both platforms.

Приклад. Контекстне меню повідомлення в чаті відкривається довгим натисканням. На Android — longClickGesture з elementId і duration у мілісекундах, на iOS — touchAndHold із duration у секундах. Збільшення фото на карті — pinchOpenGesture з percent на Android і pinch зі scale більше 1 на iOS.

Example. A chat message's context menu opens on long press. On Android use longClickGesture with elementId and duration in milliseconds; on iOS touchAndHold with duration in seconds. Zooming a photo on a map is pinchOpenGesture with percent on Android and pinch with a scale above 1 on iOS.

Підводні камені. Одиниці вимірювання різні: Android чекає мілісекунди, iOS — секунди, і 2000 на iOS означає 33 хвилини. Pinch на Android-емуляторі без підтримки multitouch може нічого не зробити. Подвійний тап через два окремі click() не спрацьовує: інтервал між ними завеликий. Mobile:-команди прив'язані до драйвера, тож page object має приховати платформну різницю за методом longPress(element).

Pitfalls. Units differ: Android expects milliseconds, iOS seconds, so 2000 on iOS means 33 minutes. Pinch on an Android emulator without multitouch support may do nothing. A double tap via two separate click() calls does not register: the gap between them is too long. Mobile: commands are driver-specific, so a page object should hide the platform difference behind a longPress(element) method.

Уточнююче питання. «Як зробити drag and drop?» — на Android mobile: dragGesture з endX/endY, на iOS — W3C Actions з паузою після down, щоб система розпізнала початок перетягування.

Follow-up question. "How do you drag and drop?" On Android mobile: dragGesture with endX/endY; on iOS W3C Actions with a pause after down so the system recognises the start of a drag.

Довге натискання для двох платформ
Long press on both platforms
public void longPress(WebElement el) {
    String id = ((RemoteWebElement) el).getId();
    if (isAndroid()) {
        driver.executeScript("mobile: longClickGesture",
            Map.of("elementId", id, "duration", 1500));      // мс
    } else {
        driver.executeScript("mobile: touchAndHold",
            Map.of("elementId", id, "duration", 1.5));       // секунди
    }
}

// pinch-zoom на карті
driver.executeScript("mobile: pinchOpenGesture", Map.of("elementId", mapId, "percent", 0.6)); // Android
driver.executeScript("mobile: pinch", Map.of("elementId", mapId, "scale", 2.0, "velocity", 1.0)); // iOS
public void longPress(WebElement el) {
    String id = ((RemoteWebElement) el).getId();
    if (isAndroid()) {
        driver.executeScript("mobile: longClickGesture",
            Map.of("elementId", id, "duration", 1500));      // ms
    } else {
        driver.executeScript("mobile: touchAndHold",
            Map.of("elementId", id, "duration", 1.5));       // seconds
    }
}

// pinch-zoom on a map
driver.executeScript("mobile: pinchOpenGesture", Map.of("elementId", mapId, "percent", 0.6)); // Android
driver.executeScript("mobile: pinch", Map.of("elementId", mapId, "scale", 2.0, "velocity", 1.0)); // iOS
Довге натискання через W3C Actions
Long press with W3C Actions
const el = await $("~message-17");
const { x, y, width, height } = await driver.getElementRect(el.elementId);
await driver
  .action("pointer", { parameters: { pointerType: "touch" } })
  .move({ x: Math.round(x + width / 2), y: Math.round(y + height / 2) })
  .down()
  .pause(1500)
  .up()
  .perform();
🔧 Розбір синтаксису: Обидві mobile:-команди приймають elementId, але одиниці duration різні. percent у pinchOpenGesture — наскільки розвести пальці відносно розміру елемента, scale у iOS pinch більше 1 — збільшення, менше 1 — зменшення. У JS pause(1500) між down і up і робить натискання довгим; isAndroid() — хелпер проєкту.
🔧 Syntax breakdown: Both mobile: commands take elementId, but duration units differ. percent in pinchOpenGesture is how far fingers spread relative to the element size; scale in iOS pinch above 1 zooms in, below 1 zooms out. In JS the pause(1500) between down and up is what makes the press long; isAndroid() is a project helper.
💡 Простими словами: Mobile:-команда — попросити телефон «зроби довге натискання», Actions — самому тримати палець і рахувати секунди.
💡 In plain words: A mobile: command is asking the phone to "do a long press"; Actions is holding the finger yourself and counting the seconds.
🗣 Як читати вголос Для типових жестів я беру mobile-команди драйвера: longClickGesture і pinchOpenGesture на Android, touchAndHold і pinch на iOS. Вони виконуються нативно одним викликом, тому стабільніші за набір точок. W3C Actions лишаю для нестандартних жестів, як підпис пальцем. Головна пастка, яку я запам'ятав, — одиниці: на Android тривалість у мілісекундах, на iOS у секундах. Тому все це ховаю за методом page object на кшталт longPress, щоб тести не знали про платформу.
🗣 How to say it out loud For typical gestures I use the driver's mobile commands: longClickGesture and pinchOpenGesture on Android, touchAndHold and pinch on iOS. They run natively in one call, so they're more stable than a set of points. I keep W3C Actions for unusual gestures, like a finger signature. The main trap I remember is units: on Android duration is in milliseconds, on iOS in seconds. So I hide all this behind a page object method like longPress, so tests don't know about the platform.
28 Чим ризикований тап за координатами і коли він усе ж допустимий? What is risky about tapping by coordinates, and when is it still acceptable?
Жести і пристрій Gestures and device Junior ▶

Що це. Тап за координатами — дотик у точку (x, y) екрана без пошуку елемента: через W3C Actions, mobile: clickGesture з x/y на Android чи mobile: tap на iOS. Драйверу байдуже, що там намальовано, — він просто торкається пікселя.

What it is. A coordinate tap is a touch at point (x, y) on the screen with no element lookup: through W3C Actions, mobile: clickGesture with x/y on Android, or mobile: tap on iOS. The driver does not care what is drawn there — it just touches the pixel.

Навіщо знати ризик. Координати залежать від роздільності, щільності пікселів, розміру шрифту в налаштуваннях доступності, орієнтації, висоти статус-бару і вирізу під камеру. Точка, яка на Pixel 7 влучає в кнопку «Оплатити», на Galaxy A14 потрапляє в порожнечу або в сусідню кнопку «Скасувати» — тест або падає, або, гірше, проходить неправильним шляхом.

Why the risk matters. Coordinates depend on resolution, pixel density, the accessibility font size setting, orientation, status bar height and the camera cut-out. A point that hits the "Pay" button on a Pixel 7 lands on empty space or on the neighbouring "Cancel" button on a Galaxy A14 — the test either fails or, worse, passes along the wrong path.

Приклад. Допустимий випадок — елемент, якого немає в дереві доступності: полотно гри, карта, графік, камера. Тоді рахують координати відносно видимого елемента-контейнера: беруть його getRect() і тапають у частку ширини й висоти, наприклад у маркер у правій верхній чверті карти.

Example. An acceptable case is an element that does not exist in the accessibility tree: a game canvas, a map, a chart, the camera view. Then coordinates are computed relative to a visible container element: take its getRect() and tap at a fraction of its width and height, for example on a marker in the map's top-right quarter.

Підводні камені. На Android координати в Appium — фізичні пікселі, на iOS — логічні точки; одна формула для обох платформ дає промах удвічі чи втричі. Скопійовані з Inspector абсолютні координати «працюють» лише на пристрої автора. Тап за координатами не чекає, поки елемент стане клікабельним, тож перед ним потрібне очікування стану екрана.

Pitfalls. On Android Appium coordinates are physical pixels, on iOS logical points; one formula for both platforms misses by a factor of two or three. Absolute coordinates copied from Inspector "work" only on the author's device. A coordinate tap does not wait for anything to become clickable, so it needs a wait on the screen state first.

Уточнююче питання. «Що робити з кастомним контролом без атрибутів?» — попросити розробників додати accessibility-властивості; це одночасно виправляє і тестованість, і доступність.

Follow-up question. "What about a custom control with no attributes?" Ask developers to add accessibility properties; that fixes testability and accessibility at the same time.

Тап відносно контейнера, а не екрана
Tap relative to a container, not the screen
WebElement map = wait.until(ExpectedConditions.visibilityOfElementLocated(
    AppiumBy.accessibilityId("deliveryMap")));
Rectangle r = map.getRect();
int x = r.getX() + (int) (r.getWidth() * 0.75);
int y = r.getY() + (int) (r.getHeight() * 0.25);

if (isAndroid()) {
    driver.executeScript("mobile: clickGesture", Map.of("x", x, "y", y));
} else {
    driver.executeScript("mobile: tap", Map.of("x", x, "y", y));
}
🔧 Розбір синтаксису: getRect() повертає позицію і розмір елемента в тих самих одиницях, у яких драйвер приймає координати, тому точка всередині контейнера коректна і на Android (пікселі), і на iOS (точки). Частки 0.75 і 0.25 не залежать від розміру екрана. mobile: tap без elementId на iOS тапає за абсолютними координатами.
🔧 Syntax breakdown: getRect() returns the element position and size in the same units the driver accepts for coordinates, so a point inside the container is correct on both Android (pixels) and iOS (points). The 0.75 and 0.25 fractions do not depend on screen size. mobile: tap without elementId on iOS taps at absolute coordinates.
💡 Простими словами: Координати — як «тицьни в п'ятий сантиметр від краю столу». На іншому столі там уже чашка.
💡 In plain words: Coordinates are like "poke five centimetres from the table edge". On another table there's a cup there.
🗣 Як читати вголос Тап за координатами я вважаю крайнім заходом. Координати залежать від роздільності, щільності, орієнтації, навіть розміру шрифту, тож точка, що влучає в кнопку на одному телефоні, на іншому потрапляє в сусідню. Використовую його лише для того, чого немає в дереві елементів: карта, графік, ігрове полотно. І тоді рахую точку відносно прямокутника контейнера, у частках ширини і висоти. А для кастомного контролу без атрибутів краще попросити розробників додати accessibility-властивості.
🗣 How to say it out loud I treat a coordinate tap as a last resort. Coordinates depend on resolution, density, orientation, even font size, so a point that hits a button on one phone hits the neighbouring one on another. I only use it for things that aren't in the element tree: a map, a chart, a game canvas. And then I compute the point relative to the container's rectangle, as fractions of width and height. For a custom control without attributes it's better to ask developers to add accessibility properties.
29 Які проблеми створює екранна клавіатура в мобільних тестах і як із нею працювати? What problems does the on-screen keyboard cause in mobile tests and how do you handle it?
Жести і пристрій Gestures and device Junior ▶

Що це. Після фокусу в полі введення система показує програмну клавіатуру, яка займає до половини екрана. Вона може перекрити кнопку «Далі», зсунути верстку (Android adjustResize) або залишити елементи під собою невидимими для тапа. sendKeys вводить текст, але не ховає клавіатуру після себе.

What it is. When an input field gets focus, the system shows the soft keyboard, which takes up to half the screen. It can cover the "Next" button, shift the layout (Android adjustResize) or leave elements underneath unreachable for a tap. sendKeys types text but does not hide the keyboard afterwards.

Навіщо. Типове падіння: тест заповнив пароль і клікає «Увійти», а кнопка під клавіатурою — клік потрапляє в літеру або дає «element not interactable». Розуміння поведінки клавіатури прибирає цілий клас нестабільних тестів.

Why. A typical failure: the test fills in the password and taps "Log in", but the button is under the keyboard — the tap hits a letter or gives "element not interactable". Understanding keyboard behaviour removes a whole class of flaky tests.

Приклад. Після введення перевіряємо isKeyboardShown() і ховаємо клавіатуру. На Android працює hideKeyboard(). На iOS клавіатура не завжди має кнопку згортання, тому надійніше натиснути її клавішу «Done»/«Return» через mobile: hideKeyboard зі списком keys або просто тапнути в нейтральне місце. Поле з imeOptions="actionNext" дає змогу натиснути Enter і перейти до наступного поля — як робить користувач.

Example. After typing we check isKeyboardShown() and hide the keyboard. On Android hideKeyboard() works. On iOS the keyboard does not always have a dismiss key, so it is more reliable to press its "Done"/"Return" key through mobile: hideKeyboard with a keys list, or simply tap a neutral area. A field with imeOptions="actionNext" lets you press Enter and move to the next field — the way a user does.

Підводні камені. hideKeyboard() на Android натискає «Назад», якщо не може сховати клавіатуру інакше, і на деяких екранах це закриває сам екран. Сторонні клавіатури (Gboard з підказками, Samsung Keyboard) по-різному обробляють автозаміну і можуть змінити введений текст. На iOS-симуляторі з підключеною апаратною клавіатурою програмна не з'являється взагалі — локально тест проходить, а на CI ні.

Pitfalls. On Android hideKeyboard() presses "Back" if it cannot hide the keyboard otherwise, and on some screens that closes the screen itself. Third-party keyboards (Gboard with suggestions, Samsung Keyboard) treat autocorrect differently and may change the typed text. On an iOS simulator with the hardware keyboard connected, the soft keyboard never appears — the test passes locally but not in CI.

Уточнююче питання. «Як уникнути клавіатури в тестах, де вона не важлива?» — на Android capability appium:hideKeyboard=true підміняє метод введення на службовий, і клавіатура не показується протягом сесії.

Follow-up question. "How do you avoid the keyboard in tests where it does not matter?" On Android the appium:hideKeyboard=true capability swaps in a service input method, so no keyboard is shown during the session.

Введення і приховування клавіатури
Typing and hiding the keyboard
WebElement password = driver.findElement(AppiumBy.accessibilityId("password"));
password.sendKeys("S3cret!");

if (((HasOnScreenKeyboard) driver).isKeyboardShown()) {
    if (isAndroid()) {
        ((AndroidDriver) driver).hideKeyboard();
    } else {
        driver.executeScript("mobile: hideKeyboard", Map.of("keys", List.of("Done", "Return")));
    }
}
wait.until(ExpectedConditions.elementToBeClickable(AppiumBy.accessibilityId("login"))).click();
Enter замість кліку по кнопці
Enter instead of tapping the button
((AndroidDriver) driver).pressKey(new KeyEvent(AndroidKey.ENTER));
🔧 Розбір синтаксису: isKeyboardShown() оголошено в інтерфейсі HasOnScreenKeyboard, який реалізують і AndroidDriver, і IOSDriver. mobile: hideKeyboard на iOS шукає на клавіатурі клавішу з однією з назв у keys і натискає її. pressKey(new KeyEvent(AndroidKey.ENTER)) надсилає системну клавішу Enter, яка виконує imeOptions поля, наприклад перехід до наступного.
🔧 Syntax breakdown: isKeyboardShown() is declared in the HasOnScreenKeyboard interface implemented by both AndroidDriver and IOSDriver. On iOS mobile: hideKeyboard looks for a key with one of the names in keys on the keyboard and presses it. pressKey(new KeyEvent(AndroidKey.ENTER)) sends the system Enter key, which triggers the field's imeOptions, such as moving to the next field.
💡 Простими словами: Клавіатура — як відкриті дверцята шафи: поки їх не зачиниш, до полиці за ними не дістатися.
💡 In plain words: The keyboard is like an open cupboard door: until you close it, you cannot reach the shelf behind it.
🗣 Як читати вголос Клавіатура — часте джерело флейків: після введення вона перекриває кнопку, і клік іде в літеру. Тому після sendKeys я перевіряю, чи вона показана, і ховаю її. На Android є hideKeyboard, але пам'ятаю, що він може натиснути «Назад» і закрити екран. На iOS надійніше натиснути саму клавішу Done або Return. Інколи природніше натиснути Enter і перейти до наступного поля, як людина. А на iOS-симуляторі стежу за апаратною клавіатурою, бо з нею програмна не з'являється взагалі.
🗣 How to say it out loud The keyboard is a common source of flakiness: after typing it covers the button and the tap goes to a letter. So after sendKeys I check whether it's shown and hide it. Android has hideKeyboard, but I remember it may press Back and close the screen. On iOS it's more reliable to press the Done or Return key itself. Sometimes it's more natural to press Enter and move to the next field, like a person would. And on the iOS simulator I watch for the hardware keyboard, because with it the soft one never appears.
30 Як тестувати зміну орієнтації екрана і що саме перевіряти після повороту? How do you test a screen orientation change and what exactly do you check after the rotation?
Жести і пристрій Gestures and device Middle ▶

Що це. Поворот пристрою — не просто інша геометрія. На Android за замовчуванням Activity знищується і створюється заново, тож стан, який не збережено через ViewModel чи onSaveInstanceState, втрачається. На iOS контролер лишається, але перераховується layout, і з'являються інші size classes, особливо на iPad.

What it is. Rotating a device is more than new geometry. On Android, by default, the Activity is destroyed and recreated, so any state not saved via a ViewModel or onSaveInstanceState is lost. On iOS the controller stays, but the layout is recalculated and different size classes apply, especially on iPad.

Навіщо. Типові баги повороту: очищене поле форми, скинутий крок візарда, повторний мережевий запит і дубль замовлення, діалог, що зник, відео, яке почалося спочатку. Тест має перевірити, що критичний стан користувача пережив поворот, а не лише що екран «не впав».

Why. Typical rotation bugs: a cleared form field, a wizard reset to step one, a repeated network request and a duplicate order, a dialog that disappears, a video that restarts. The test should check that the user's critical state survived the rotation, not just that the screen "didn't crash".

Приклад. Заповнюємо адресу доставки, повертаємо в альбомну орієнтацію, перевіряємо, що текст на місці, кнопка «Далі» досяжна (можливо, після скролу), повертаємо назад і знову перевіряємо. Окремо — поворот під час завантаження: лоадер має не запустити запит удруге.

Example. Fill in the delivery address, rotate to landscape, check the text is still there and the "Next" button is reachable (maybe after a scroll), rotate back and check again. Separately: rotate during loading — the loader must not fire the request a second time.

Підводні камені. Якщо в маніфесті screenOrientation="portrait", rotate може не змінити екран або дати помилку — спершу з'ясуйте, чи підтримує екран поворот. На фізичних Android-пристроях вимкнений автоповорот ігнорує частину команд. Після повороту змінюються координати і розміри, тож збережені раніше Rectangle і посилання на елементи стають недійсними. Анімація повороту займає частку секунди, тому після неї потрібне очікування.

Pitfalls. If the manifest has screenOrientation="portrait", rotate may not change the screen or may throw — first find out whether the screen supports rotation at all. On physical Android devices, disabled auto-rotate makes some commands ineffective. After rotation coordinates and sizes change, so any Rectangle or element references saved earlier become invalid. The rotation animation takes a fraction of a second, so a wait is needed afterwards.

Уточнююче питання. «Як відтворити втрату стану без повороту?» — на Android увімкнути в налаштуваннях розробника «Don't keep activities»: той самий шлях знищення Activity при виході у фон.

Follow-up question. "How do you reproduce state loss without rotating?" On Android enable "Don't keep activities" in developer options: the same Activity-destruction path when the app goes to the background.

Стан форми переживає поворот
Form state survives rotation
By address = AppiumBy.accessibilityId("deliveryAddress");
driver.findElement(address).sendKeys("12 Khreshchatyk St, Kyiv");

((SupportsRotation) driver).rotate(ScreenOrientation.LANDSCAPE);
wait.until(d -> ((SupportsRotation) d).getOrientation() == ScreenOrientation.LANDSCAPE);
assertEquals(driver.findElement(address).getText(), "12 Khreshchatyk St, Kyiv");

((SupportsRotation) driver).rotate(ScreenOrientation.PORTRAIT);
wait.until(d -> ((SupportsRotation) d).getOrientation() == ScreenOrientation.PORTRAIT);
assertEquals(driver.findElement(address).getText(), "12 Khreshchatyk St, Kyiv");
Щоб поворот працював на реальному Android
Making rotation work on a real Android device
adb shell settings put system accelerometer_rotation 1
🔧 Розбір синтаксису: SupportsRotation — інтерфейс java-client, який реалізують AndroidDriver і IOSDriver: rotate задає орієнтацію, getOrientation читає поточну. Очікування через лямбду чекає фактичного завершення повороту. Елемент після повороту шукається заново через By, бо старе посилання може бути вже недійсним.
🔧 Syntax breakdown: SupportsRotation is a java-client interface implemented by AndroidDriver and IOSDriver: rotate sets the orientation, getOrientation reads the current one. The lambda wait waits until the rotation has actually finished. After rotation the element is looked up again through By, because the old reference may already be invalid.
💡 Простими словами: Поворот на Android — як переїзд у сусідню кімнату: що не спакував, те лишилося в старій.
💡 In plain words: Rotation on Android is like moving to the next room: whatever you didn't pack stays behind.
🗣 Як читати вголос Поворот я тестую з думкою про стан, а не про геометрію. На Android Activity при повороті перестворюється, і все, що не збережено, губиться. Тому я заповнюю форму, повертаю екран, перевіряю, що текст на місці і кнопки досяжні, потім повертаю назад. Окремо дивлюсь поворот під час завантаження, щоб не було дубля запиту. Перед цим з'ясовую, чи екран взагалі підтримує альбомну орієнтацію. Після повороту заново шукаю елементи, бо старі посилання вже невалідні.
🗣 How to say it out loud I test rotation with state in mind, not geometry. On Android the Activity is recreated on rotation, and anything not saved is lost. So I fill in a form, rotate, check the text is still there and the buttons are reachable, then rotate back. Separately I check rotating during loading, so there's no duplicate request. Before that I find out whether the screen supports landscape at all. After rotation I look elements up again, because old references are no longer valid.
31 Як тестувати runtime-дозволи і системні алерти на Android та iOS? How do you test runtime permissions and system alerts on Android and iOS?
Жести і пристрій Gestures and device Middle ▶

Що це. Камера, геолокація, контакти, сповіщення (Android 13+) — дозволи, які користувач надає під час роботи. Запит показує системний діалог, що належить не вашому застосунку, а ОС (com.android.permissioncontroller на Android, SpringBoard на iOS). Тест має контролювати, чи дозвіл надано, і перевіряти поведінку застосунку в обох випадках.

What it is. Camera, location, contacts and notifications (Android 13+) are permissions a user grants at run time. The request shows a system dialog that belongs not to your app but to the OS (com.android.permissioncontroller on Android, SpringBoard on iOS). The test has to control whether a permission is granted and check the app's behaviour in both cases.

Навіщо. Відмова в дозволі — звичайний користувацький шлях, а не виняток. Застосунок має пояснити, навіщо дозвіл, не впасти при відмові і запропонувати перейти в налаштування після «Більше не питати». Тести, які завжди надають усе через autoGrantPermissions, цих гілок не бачать.

Why. Denying a permission is a normal user path, not an exception. The app should explain why it needs the permission, not crash on denial, and offer to open settings after "Don't ask again". Tests that always grant everything via autoGrantPermissions never see those branches.

Приклад. Для «щасливих» сценаріїв надаємо дозволи заздалегідь: appium:autoGrantPermissions на Android, mobile: setPermission на iOS-симуляторі. Для сценаріїв відмови відкликаємо дозвіл через mobile: changePermissions з action: "revoke" і натискаємо «Don't allow» у системному діалозі за його resource-id. На iOS системний алерт доступний через driver.switchTo().alert() або mobile: alert з назвою кнопки.

Example. For happy paths we grant permissions up front: appium:autoGrantPermissions on Android, mobile: setPermission on an iOS simulator. For denial scenarios we revoke the permission with mobile: changePermissions and action: "revoke", then tap "Don't allow" in the system dialog by its resource-id. On iOS the system alert is reachable through driver.switchTo().alert() or mobile: alert with a button label.

Підводні камені. Тексти і id кнопок діалогу змінюються між версіями Android і оболонками виробників («While using the app», «Only this time»). appium:autoAcceptAlerts на iOS приймає будь-які алерти, включно з тими, які тест мав перевірити. Після другої відмови на Android 11+ система більше не показує діалог — наступний тест, що чекає на діалог, падає, якщо стан не скинуто.

Pitfalls. Dialog texts and button ids change across Android versions and vendor skins ("While using the app", "Only this time"). appium:autoAcceptAlerts on iOS accepts every alert, including ones the test was supposed to check. After a second denial on Android 11+ the system stops showing the dialog — the next test that expects the dialog fails unless state is reset.

Уточнююче питання. «Як перевірити, які дозволи зараз надано?» — на Android mobile: getPermissions з type: "granted" або adb shell dumpsys package для пакета.

Follow-up question. "How do you check which permissions are granted right now?" On Android, mobile: getPermissions with type: "granted", or adb shell dumpsys package for the package.

Сценарій відмови в доступі до камери
Camera permission denial scenario
// Android: відкликати дозвіл перед тестом
driver.executeScript("mobile: changePermissions", Map.of(
    "permissions", List.of("android.permission.CAMERA"),
    "appPackage", "com.example.shop",
    "action", "revoke"));

driver.findElement(AppiumBy.accessibilityId("scanQr")).click();
wait.until(ExpectedConditions.elementToBeClickable(AppiumBy.id(
    "com.android.permissioncontroller:id/permission_deny_button"))).click();
assertTrue(driver.findElement(AppiumBy.accessibilityId("cameraRationale")).isDisplayed());

// iOS-симулятор: видати дозвіл заздалегідь
driver.executeScript("mobile: setPermission", Map.of(
    "bundleId", "com.example.shop.ios",
    "access", Map.of("camera", "yes")));
// iOS: відповісти на системний алерт кнопкою за назвою
driver.executeScript("mobile: alert", Map.of("action", "accept", "buttonLabel", "Allow"));
// Android: revoke the permission before the test
driver.executeScript("mobile: changePermissions", Map.of(
    "permissions", List.of("android.permission.CAMERA"),
    "appPackage", "com.example.shop",
    "action", "revoke"));

driver.findElement(AppiumBy.accessibilityId("scanQr")).click();
wait.until(ExpectedConditions.elementToBeClickable(AppiumBy.id(
    "com.android.permissioncontroller:id/permission_deny_button"))).click();
assertTrue(driver.findElement(AppiumBy.accessibilityId("cameraRationale")).isDisplayed());

// iOS simulator: grant the permission in advance
driver.executeScript("mobile: setPermission", Map.of(
    "bundleId", "com.example.shop.ios",
    "access", Map.of("camera", "yes")));
// iOS: answer a system alert with a named button
driver.executeScript("mobile: alert", Map.of("action", "accept", "buttonLabel", "Allow"));
🔧 Розбір синтаксису: mobile: changePermissions приймає список повних імен дозволів, пакет і дію grant чи revoke. Кнопка діалогу шукається за повним resource-id іншого пакета. mobile: setPermission працює лише на симуляторі, значення yes, no або unset. mobile: alert з buttonLabel натискає кнопку за назвою, що корисно, коли їх більше двох.
🔧 Syntax breakdown: mobile: changePermissions takes a list of full permission names, the package and an action, grant or revoke. The dialog button is found by the full resource-id of another package. mobile: setPermission works only on a simulator, with values yes, no or unset. mobile: alert with buttonLabel taps a button by its label, which helps when there are more than two.
💡 Простими словами: Дозвіл — це запитання від ОС, а не від застосунку. Тест має вміти відповісти і «так», і «ні», і перевірити, що застосунок не образився.
💡 In plain words: A permission prompt is a question from the OS, not from the app. The test must be able to answer both yes and no and check that the app copes either way.
🗣 Як читати вголос Дозволи я тестую в обидва боки. Для звичайних сценаріїв видаю їх заздалегідь, щоб діалоги не заважали: autoGrantPermissions на Android, setPermission на iOS-симуляторі. А для негативних — відкликаю дозвіл, натискаю «Не дозволяти» в системному діалозі і перевіряю, що застосунок пояснює, навіщо доступ, і не падає. Пам'ятаю, що діалог належить системі, тому шукаю його за id іншого пакета, і що autoAcceptAlerts приймає взагалі все, навіть те, що я хотів перевірити.
🗣 How to say it out loud I test permissions both ways. For regular scenarios I grant them in advance so dialogs don't get in the way: autoGrantPermissions on Android, setPermission on the iOS simulator. For negative ones I revoke the permission, tap "Don't allow" in the system dialog and check the app explains why it needs access and doesn't crash. I remember the dialog belongs to the system, so I find it by another package's id, and that autoAcceptAlerts accepts literally everything, even what I wanted to check.
32 Як відкрити потрібний екран через deep link у тесті і які з цим пастки? How do you open a specific screen through a deep link in a test, and what are the traps?
Жести і пристрій Gestures and device Middle ▶

Що це. Deep link — URL, який ОС передає застосунку, щоб той одразу відкрив конкретний екран: власна схема (shop://product/42) або Android App Links / iOS Universal Links на домені (https://shop.example.com/product/42). Appium може відкрити такий URL командою mobile: deepLink на обох платформах.

What it is. A deep link is a URL the OS hands to an app so it opens a specific screen right away: a custom scheme (shop://product/42), or Android App Links / iOS Universal Links on a domain (https://shop.example.com/product/42). Appium can open such a URL with the mobile: deepLink command on both platforms.

Навіщо. Два сценарії. Перший — прискорення: замість проходу через п'ять екранів тест відкриває кошик чи замовлення напряму, і довгий флоу перевіряється лише в одному тесті. Другий — тестування самої функції: посилання з email, реклами чи push має відкривати правильний екран, навіть коли застосунок закритий або користувач не залогінений.

Why. Two scenarios. The first is speed: instead of going through five screens, the test opens the cart or an order directly, and the long flow is covered by a single test. The second is testing the feature itself: a link from an email, an ad or a push must open the right screen, even when the app is closed or the user is not logged in.

Приклад. Тест кошика: логін через API, потім mobile: deepLink з shop://cart і пакетом застосунку — і за секунду тест уже на потрібному екрані. Для перевірки маршрутизації — посилання на неіснуючий товар має показати «товар не знайдено», а не білий екран.

Example. A cart test: log in through the API, then mobile: deepLink with shop://cart and the app package — and within a second the test is on the right screen. For routing checks, a link to a non-existent product must show "product not found", not a blank screen.

Підводні камені. На реальному iPhone відкриття власної схеми може показати системне «Відкрити в застосунку?», а Universal Link з Safari відкривається в браузері, якщо не налаштовано файл apple-app-site-association. На Android посилання без вказаного пакета може відкрити діалог вибору застосунку. Deep link у тесті обходить UI навігації, тож хоча б один тест мусить пройти шлях як користувач. Холодний і теплий старт — різні гілки коду, їх перевіряють окремо.

Pitfalls. On a real iPhone opening a custom scheme may show the system "Open in app?" prompt, and a Universal Link from Safari opens in the browser unless the apple-app-site-association file is set up. On Android a link without a package may open an app chooser dialog. A deep link in a test bypasses navigation UI, so at least one test must still walk the path like a user. Cold and warm start are different code paths and are checked separately.

Уточнююче питання. «Як відкрити посилання без Appium?» — adb shell am start -a android.intent.action.VIEW -d "shop://cart" або xcrun simctl openurl booted "shop://cart".

Follow-up question. "How do you open a link without Appium?" adb shell am start -a android.intent.action.VIEW -d "shop://cart" or xcrun simctl openurl booted "shop://cart".

Deep link у Java і WebdriverIO
A deep link in Java and WebdriverIO
// Android
driver.executeScript("mobile: deepLink", Map.of(
    "url", "shop://cart",
    "package", "com.example.shop"));

// iOS
driver.executeScript("mobile: deepLink", Map.of(
    "url", "shop://cart",
    "bundleId", "com.example.shop.ios"));

wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("cartTitle")));
// Android
driver.executeScript("mobile: deepLink", Map.of(
    "url", "shop://cart",
    "package", "com.example.shop"));

// iOS
driver.executeScript("mobile: deepLink", Map.of(
    "url", "shop://cart",
    "bundleId", "com.example.shop.ios"));

wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("cartTitle")));
WebdriverIO
WebdriverIO
await driver.execute("mobile: deepLink", { url: "shop://product/999999", package: "com.example.shop" });
await expect($("~productNotFound")).toBeDisplayed();
🔧 Розбір синтаксису: Параметр package на Android прив'язує посилання до вашого застосунку і прибирає діалог вибору; на iOS аналогічну роль грає bundleId. У WebdriverIO driver.execute — аналог executeScript з об'єктом параметрів, а expect(...).toBeDisplayed() з expect-webdriverio сам повторює перевірку до таймауту.
🔧 Syntax breakdown: The package parameter on Android binds the link to your app and removes the chooser dialog; on iOS bundleId plays the same role. In WebdriverIO driver.execute is the counterpart of executeScript with a parameters object, and expect(...).toBeDisplayed() from expect-webdriverio retries the check until the timeout.
💡 Простими словами: Deep link — телепорт одразу в потрібну кімнату. Зручно, але хоч раз треба пройти коридором, щоб знати, що двері відчиняються.
💡 In plain words: A deep link is a teleport straight into the room you need. Handy, but at least once you should walk the corridor to know the doors open.
🗣 Як читати вголос Deep link у тестах мені потрібен для двох речей. По-перше, щоб швидко стати на потрібний екран без проходу через п'ять інших: логін через API, потім mobile deepLink — і я вже в кошику. По-друге, щоб перевірити саму маршрутизацію: посилання з пошти чи пушу відкриває правильний екран і коли застосунок закритий, і коли відкритий. Пастки знаю: на реальному айфоні може вискочити підтвердження, на Android без пакета — діалог вибору. І хоч один тест у мене завжди проходить шлях як користувач.
🗣 How to say it out loud I need deep links in tests for two things. First, to land on the right screen fast without going through five others: log in through the API, then mobile deepLink, and I'm already in the cart. Second, to test routing itself: a link from an email or a push opens the right screen whether the app is closed or open. I know the traps: a confirmation can pop up on a real iPhone, and on Android without a package you get a chooser dialog. And at least one of my tests always walks the path like a user.
33 Як побудувати автоматизоване тестування push-сповіщень з Appium, і що реально перевіряти end-to-end? How do you build automated testing of push notifications with Appium, and what is really worth checking end to end?
Жести і пристрій Gestures and device Senior ▶

Що це. Push проходить довгий ланцюжок: бекенд → FCM чи APNs → ОС пристрою → обробник застосунку → системне сповіщення → тап користувача → deep link на екран. Кожна ланка може зламатися окремо, і лише частина з них під контролем Appium. Потрібні також дозвіл на сповіщення (iOS, Android 13+), валідний токен пристрою і правильний стан застосунку (закритий, у фоні, відкритий).

What it is. A push travels a long chain: backend → FCM or APNs → the device OS → the app's handler → a system notification → the user's tap → a deep link to a screen. Each link can break on its own, and only some of them are under Appium's control. You also need notification permission (iOS, Android 13+), a valid device token and the right app state (killed, background, foreground).

Навіщо розкладати / трейдофи. Повний end-to-end через реальний FCM/APNs повільний і нестабільний: доставка може зайняти від секунди до хвилин, а APNs взагалі не гарантує доставку. Тому стратегію ділять на рівні. Контракт бекенду — API-тести без пристрою. Обробку payload і навігацію — на симуляторі через mobile: pushNotification (iOS) або тестовий ендпоінт бекенду, що шле в FCM з токеном тестового пристрою (Android). Один-два справжні end-to-end прогони — на реальних пристроях перед релізом.

Why split it / trade-offs. A full end-to-end through real FCM/APNs is slow and flaky: delivery may take a second or minutes, and APNs does not guarantee delivery at all. So the strategy is layered. The backend contract is covered by API tests with no device. Payload handling and navigation run on a simulator via mobile: pushNotification (iOS) or a backend test endpoint that sends to FCM with the test device token (Android). One or two genuine end-to-end runs happen on real devices before release.

Приклад. Android: тест логіниться, бере FCM-токен через тестове API, викликає службовий ендпоінт «надіслати push», відкриває шторку openNotifications(), чекає сповіщення за текстом до 30 с, тапає і перевіряє екран замовлення. iOS-симулятор: mobile: pushNotification з bundleId і APNs-payload, далі тап по банеру і перевірка екрана.

Example. Android: the test logs in, gets the FCM token through a test API, calls a service endpoint "send push", opens the shade with openNotifications(), waits up to 30 s for the notification by text, taps it and checks the order screen. iOS simulator: mobile: pushNotification with the bundleId and an APNs payload, then tap the banner and check the screen.

Підводні камені. Сповіщення з однаковим текстом від попередніх тестів дають хибний збіг — у payload варто класти унікальний id прогону. Режим «Не турбувати» і енергозбереження на реальних пристроях затримують доставку. Поведінка при закритому застосунку (холодний старт із payload) відрізняється від відкритого, де сповіщення може не показуватися взагалі.

Pitfalls. Notifications with the same text from earlier tests produce false matches — put a unique run id into the payload. Do Not Disturb and battery saving on real devices delay delivery. Behaviour with the app killed (cold start with a payload) differs from the foreground case, where the notification may not be shown at all.

Уточнююче питання. «Що робити, якщо push приходить нестабільно на CI?» — не піднімати таймаути до хвилин, а перевести більшість перевірок на симульований push, залишивши реальну доставку в окремому моніторингу.

Follow-up question. "What if push arrives unreliably in CI?" Do not raise timeouts to minutes; move most checks to simulated push and keep real delivery in separate monitoring.

Android: реальний FCM через тестовий ендпоінт
Android: real FCM through a test endpoint
String runId = UUID.randomUUID().toString().substring(0, 8);
testApi.sendPush(userToken, Map.of("title", "Order shipped " + runId, "orderId", "1042"));

AndroidDriver android = (AndroidDriver) driver;
android.openNotifications();
By notification = AppiumBy.androidUIAutomator(
    "new UiSelector().textContains(\"Order shipped " + runId + "\")");
new WebDriverWait(android, Duration.ofSeconds(30))
    .until(ExpectedConditions.elementToBeClickable(notification)).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("order-1042")));
iOS-симулятор: push без APNs
iOS simulator: push without APNs
driver.executeScript("mobile: pushNotification", Map.of(
    "bundleId", "com.example.shop.ios",
    "payload", Map.of(
        "aps", Map.of("alert", Map.of("title", "Order shipped", "body", "Tap to track")),
        "orderId", "1042")));
🔧 Розбір синтаксису: runId робить текст сповіщення унікальним для прогону, щоб не зачепити старе. openNotifications() — метод AndroidDriver, який відкриває шторку. testApi — клієнт службового API проєкту. mobile: pushNotification доступний лише на iOS-симуляторі: payload повторює формат APNs, де aps.alert описує текст, а власні ключі (orderId) читає застосунок.
🔧 Syntax breakdown: runId makes the notification text unique to this run so an old one is not matched. openNotifications() is an AndroidDriver method that opens the shade. testApi is the project's service API client. mobile: pushNotification is available only on the iOS simulator: the payload follows the APNs format, where aps.alert holds the text and custom keys (orderId) are read by the app.
💡 Простими словами: Push — це пошта з кількома сортувальними центрами. Більшість перевірок робиш у своєму відділенні, і лише зрідка шлеш справжній лист через усю країну.
💡 In plain words: A push is mail through several sorting centres. Most checks happen at your own post office, and only occasionally do you send a real letter across the country.
🗣 Як читати вголос Push я розкладаю на ланки: бекенд, FCM або APNs, ОС, обробник у застосунку, сповіщення і перехід на екран. Повний шлях через справжні сервіси повільний і нестабільний, тому більшість перевірок роблю дешевше: контракт бекенду — API-тестами, обробку payload і навігацію — симульованим пушем на iOS-симуляторі або через тестовий ендпоінт на Android. Реальну доставку перевіряю кількома тестами перед релізом. У payload кладу унікальний id прогону, а ще окремо перевіряю холодний старт застосунку з пушу.
🗣 How to say it out loud I split push into links: backend, FCM or APNs, the OS, the app's handler, the notification and navigation to a screen. The full path through real services is slow and flaky, so I do most checks more cheaply: the backend contract with API tests, payload handling and navigation with a simulated push on the iOS simulator or a test endpoint on Android. I check real delivery with a few tests before release. I put a unique run id into the payload, and I separately check a cold start of the app from a push.
34 Як тестувати біометричну автентифікацію (Face ID, Touch ID, відбиток) і що робити на реальних пристроях? How do you test biometric authentication (Face ID, Touch ID, fingerprint), and what do you do on real devices?
Жести і пристрій Gestures and device Senior ▶

Що це. Біометрія — системний діалог ОС, результат якого застосунок отримує через BiometricPrompt на Android чи LocalAuthentication на iOS. Appium не може «прикласти палець», але на віртуальних пристроях драйвери вміють імітувати подію: на iOS-симуляторі mobile: enrollBiometric і mobile: sendBiometricMatch, на Android-емуляторі fingerPrint(id) (аналог mobile: fingerprint) після реєстрації відбитка в налаштуваннях емулятора.

What it is. Biometrics is an OS system dialog whose result the app receives through BiometricPrompt on Android or LocalAuthentication on iOS. Appium cannot "place a finger", but on virtual devices the drivers can simulate the event: on the iOS simulator mobile: enrollBiometric and mobile: sendBiometricMatch; on the Android emulator fingerPrint(id) (the counterpart of mobile: fingerprint) after a fingerprint is enrolled in the emulator settings.

Навіщо / трейдофи. Потрібно перевірити всі гілки: успіх, невдача, скасування, кілька невдач поспіль з переходом на PIN, біометрію не налаштовано, біометрію змінили після входу (ключ у Keystore/Keychain інвалідовано). Симулятори покривають більшість гілок швидко. На реальних пристроях імітація неможлива, тому є два варіанти: тестова збірка з підміною біометричного модуля через feature flag або ручна перевірка на кількох моделях перед релізом.

Why / trade-offs. Every branch needs checking: success, failure, cancel, several failures in a row falling back to a PIN, biometrics not set up, biometrics changed after login (the key in Keystore/Keychain invalidated). Simulators cover most branches quickly. On real devices simulation is impossible, so there are two options: a test build that swaps the biometric module via a feature flag, or a manual check on a few models before release.

Приклад. iOS: реєструємо біометрію, відкриваємо екран входу, застосунок показує Face ID, тест надсилає match: false двічі — перевіряємо пропозицію ввести пароль, потім match: true — перевіряємо вхід. Android: заздалегідь реєструємо відбиток на емуляторі, тест викликає fingerPrint(1), коли з'явився BiometricPrompt.

Example. iOS: enroll biometrics, open the login screen, the app shows Face ID, the test sends match: false twice — we check the offer to enter a password — then match: true and we check the login. Android: enroll a fingerprint on the emulator in advance; the test calls fingerPrint(1) once BiometricPrompt appears.

Підводні камені. На iOS перше звернення до Face ID показує системний запит дозволу (потрібен NSFaceIDUsageDescription), і його теж треба обробити. На Android без блокування екрана PIN-кодом зареєструвати відбиток не можна. Підміна біометрії в тестовій збірці не повинна потрапити в release — це має контролювати збірка, а не домовленість. Тест, що проходить лише «успіх», дає хибне відчуття безпеки.

Pitfalls. On iOS the first Face ID use triggers a system permission prompt (it needs NSFaceIDUsageDescription), which also has to be handled. On Android you cannot enroll a fingerprint without a PIN screen lock. The biometric stub in a test build must never reach release — the build should enforce that, not a verbal agreement. A test that covers only "success" gives a false sense of security.

Уточнююче питання. «Як перевірити інвалідацію ключа?» — на симуляторі перереєструвати біометрію між сесіями і переконатися, що застосунок просить пароль, а не входить мовчки.

Follow-up question. "How do you check key invalidation?" On a simulator re-enroll biometrics between sessions and make sure the app asks for the password instead of logging in silently.

iOS-симулятор: Face ID успіх і невдача
iOS simulator: Face ID success and failure
driver.executeScript("mobile: enrollBiometric", Map.of("isEnabled", true));
driver.findElement(AppiumBy.accessibilityId("loginWithFaceId")).click();

driver.executeScript("mobile: sendBiometricMatch", Map.of("type", "faceId", "match", false));
driver.executeScript("mobile: sendBiometricMatch", Map.of("type", "faceId", "match", false));
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("passwordFallback")));
Android-емулятор: відбиток
Android emulator: fingerprint
# один раз: PIN-блокування і реєстрація відбитка в Settings, потім
adb -e emu finger touch 1
# once: PIN lock and fingerprint enrolment in Settings, then
adb -e emu finger touch 1
Відбиток із тесту
Fingerprint from the test
wait.until(ExpectedConditions.visibilityOfElementLocated(
    AppiumBy.androidUIAutomator("new UiSelector().textContains(\"fingerprint\")")));
((AndroidDriver) driver).fingerPrint(1);
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("homeTitle")));
🔧 Розбір синтаксису: enrollBiometric вмикає або вимикає зареєстровану біометрію на симуляторі, sendBiometricMatch надсилає подію з типом faceId чи touchId і результатом match. adb -e emu finger touch 1 імітує дотик відбитка з id 1 на емуляторі — той самий, що реєструють у налаштуваннях. fingerPrint(1) робить те саме з тесту.
🔧 Syntax breakdown: enrollBiometric turns enrolled biometrics on or off on the simulator, and sendBiometricMatch sends an event of type faceId or touchId with a match result. adb -e emu finger touch 1 simulates a touch with fingerprint id 1 on the emulator — the same id enrolled in settings. fingerPrint(1) does the same from the test.
💡 Простими словами: На симуляторі можна «підробити» палець. На справжньому телефоні — ні, тому там або тестова заглушка, або жива людина.
💡 In plain words: On a simulator you can "fake" a finger. On a real phone you can't, so there it is either a test stub or a real person.
🗣 Як читати вголос Біометрію я тестую на віртуальних пристроях, бо лише там її можна імітувати: на iOS-симуляторі реєструю біометрію і надсилаю збіг або незбіг, на Android-емуляторі реєструю відбиток і викликаю fingerPrint. Головне — покрити всі гілки, а не лише успіх: невдачі, перехід на пароль, скасування, зміну біометрії після входу. На реальних пристроях імітувати не можна, тож там або тестова збірка з підміною модуля за прапорцем, яка гарантовано не потрапляє в реліз, або ручна перевірка на кількох моделях.
🗣 How to say it out loud I test biometrics on virtual devices, because only there can it be simulated: on the iOS simulator I enroll biometrics and send a match or non-match, on the Android emulator I enroll a fingerprint and call fingerPrint. The key is covering every branch, not just success: failures, fallback to password, cancel, biometrics changed after login. On real devices you can't simulate it, so it's either a test build with the module swapped behind a flag that's guaranteed not to ship, or a manual check on a few models.
▶ 5 3
35 Що таке контексти в гібридному застосунку і як перемкнутися з NATIVE_APP у WEBVIEW? What are contexts in a hybrid app and how do you switch from NATIVE_APP to WEBVIEW?
Hybrid і WebView Hybrid and WebView Middle ▶

Що це. Гібридний застосунок поєднує нативні екрани і WebView, у якому рендериться HTML: оплата, правила, особистий кабінет на React. Appium бачить їх як окремі контексти. NATIVE_APP — дерево нативних елементів, WEBVIEW_... — DOM сторінки всередині WebView. Команди йдуть у той контекст, який зараз активний.

What it is. A hybrid app combines native screens with a WebView that renders HTML: checkout, terms, a React account area. Appium sees them as separate contexts. NATIVE_APP is the tree of native elements, WEBVIEW_... is the page DOM inside the WebView. Commands go to whichever context is active.

Навіщо. У нативному контексті вміст WebView виглядає як набір безіменних android.view.View, і з HTML-атрибутами працювати неможливо. Після перемикання драйвер проксує команди в chromedriver (Android) або у Web Inspector через remote debugger (iOS), і стають доступні CSS-селектори, executeScript у сторінці, URL і cookies.

Why. In the native context the WebView content looks like a set of anonymous android.view.View nodes, and HTML attributes are out of reach. After switching, the driver proxies commands to chromedriver (Android) or to Web Inspector through the remote debugger (iOS), and CSS selectors, in-page executeScript, the URL and cookies become available.

Приклад. Користувач натискає «Оплатити» (нативна кнопка), відкривається форма банку у WebView. Тест чекає, доки в getContextHandles() з'явиться WEBVIEW-контекст, перемикається через driver.context(...), заповнює поля за CSS, повертається в NATIVE_APP і перевіряє нативний екран «Оплачено».

Example. The user taps "Pay" (a native button) and the bank form opens in a WebView. The test waits until a WEBVIEW context appears in getContextHandles(), switches with driver.context(...), fills fields by CSS, returns to NATIVE_APP and checks the native "Paid" screen.

Підводні камені. WebView має бути налагоджуваним: на Android — WebView.setWebContentsDebuggingEnabled(true) у debug-збірці, на iOS 16.4+ — isInspectable = true для WKWebView; без цього контексту просто немає. Версія chromedriver мусить відповідати версії Chrome/Android System WebView на пристрої, інакше сесія падає з «No Chromedriver found that can automate Chrome»; допомагають appium:chromedriverExecutableDir з файлом відповідностей або автоматичне завантаження драйвера. Контекст з'являється не миттєво, тому його треба чекати. Забутий зворотний перехід у NATIVE_APP — часта причина «елемент не знайдено» в наступному кроці.

Pitfalls. The WebView must be debuggable: on Android WebView.setWebContentsDebuggingEnabled(true) in the debug build, on iOS 16.4+ isInspectable = true for WKWebView; without that the context simply does not exist. The chromedriver version must match the Chrome/Android System WebView version on the device, otherwise the session fails with "No Chromedriver found that can automate Chrome"; appium:chromedriverExecutableDir with a mapping file or automatic driver download helps. The context does not appear instantly, so wait for it. Forgetting to switch back to NATIVE_APP is a common cause of "element not found" in the next step.

Уточнююче питання. «Як стартувати одразу у WebView?» — capability appium:autoWebview=true, корисна для застосунків на Cordova/Ionic, де майже весь UI — веб.

Follow-up question. "How do you start straight in the WebView?" The appium:autoWebview=true capability, handy for Cordova/Ionic apps where almost the whole UI is web.

Перемикання контексту з очікуванням
Switching context with a wait
driver.findElement(AppiumBy.accessibilityId("pay")).click();

String webview = new WebDriverWait(driver, Duration.ofSeconds(20)).until(d ->
    driver.getContextHandles().stream()
        .filter(c -> c.startsWith("WEBVIEW"))
        .findFirst().orElse(null));

driver.context(webview);
driver.findElement(By.cssSelector("input[name=cardNumber]")).sendKeys("4111111111111111");
driver.findElement(By.cssSelector("button[type=submit]")).click();

driver.context("NATIVE_APP");
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("paymentSuccess")));
WebdriverIO
WebdriverIO
await driver.waitUntil(async () => (await driver.getContexts()).some((c) => String(c).startsWith("WEBVIEW")), { timeout: 20000 });
const contexts = await driver.getContexts();
await driver.switchContext(contexts.find((c) => String(c).startsWith("WEBVIEW")));
await $("input[name=cardNumber]").setValue("4111111111111111");
await driver.switchContext("NATIVE_APP");
🔧 Розбір синтаксису: getContextHandles() повертає набір рядків на кшталт NATIVE_APP і WEBVIEW_com.example.shop; лямбда в WebDriverWait повертає null, доки WebView не з'явився, і тому очікування повторюється. context(...) перемикає всі наступні команди. У WebdriverIO те саме роблять getContexts і switchContext.
🔧 Syntax breakdown: getContextHandles() returns a set of strings like NATIVE_APP and WEBVIEW_com.example.shop; the lambda in WebDriverWait returns null until the WebView appears, so the wait keeps retrying. context(...) switches every following command. In WebdriverIO getContexts and switchContext do the same.
💡 Простими словами: Контекст — як перемикач мови на клавіатурі: поки не перемкнув, пишеш не тими літерами.
💡 In plain words: A context is like the keyboard language switch: until you flip it, you are typing the wrong letters.
🗣 Як читати вголос У гібридному застосунку Appium бачить два світи: NATIVE_APP і WEBVIEW. У нативному контексті вміст WebView — просто безіменні View, тому для HTML я чекаю, поки WEBVIEW-контекст з'явиться, перемикаюсь і працюю CSS-селекторами. Потім обов'язково повертаюсь у NATIVE_APP. Щоб контекст узагалі з'явився, WebView у debug-збірці має бути налагоджуваним, а на Android ще й chromedriver має збігатися з версією WebView на пристрої — це найчастіша причина падінь.
🗣 How to say it out loud In a hybrid app Appium sees two worlds: NATIVE_APP and WEBVIEW. In the native context the WebView content is just anonymous Views, so for HTML I wait for the WEBVIEW context to appear, switch, and work with CSS selectors. Then I always switch back to NATIVE_APP. For the context to show up at all, the WebView in the debug build must be debuggable, and on Android chromedriver also has to match the WebView version on the device — that's the most common cause of failures.
36 Чому нативні локатори не працюють як CSS усередині WebView і як не заплутатися в двох світах? Why don't native locators work like CSS inside a WebView, and how do you avoid mixing up the two worlds?
Hybrid і WebView Hybrid and WebView Middle ▶

Що це. Локатор завжди виконується в активному контексті. У NATIVE_APP драйвер шукає в дереві доступності платформи, де немає DOM, класів CSS чи атрибутів data-test — лише resource-id, content-desc, text, class нативного віджета. У WEBVIEW усе навпаки: працюють By.cssSelector, By.id за HTML id і XPath по DOM, але нативна кнопка «Назад» чи тулбар поза WebView уже не видно.

What it is. A locator always runs in the active context. In NATIVE_APP the driver searches the platform's accessibility tree, where there is no DOM, no CSS classes and no data-test attributes — only the native widget's resource-id, content-desc, text and class. In WEBVIEW it is the other way round: By.cssSelector, By.id by HTML id and XPath over the DOM work, but the native back button or toolbar outside the WebView is no longer visible.

Навіщо розуміти. Селектор .btn-primary з вебкоду, переданий у нативному контексті, або відхиляється, або драйвер інтерпретує CSS-подібний синтаксис по нативних атрибутах (UiAutomator2 перекладає його в UiSelector) — у будь-якому разі DOM-класу він не знайде. І навпаки, AppiumBy.accessibilityId у WebView шукає не за content-desc, а за ARIA-міткою DOM-елемента, що часто дає неочікуваний результат.

Why it matters. A .btn-primary selector from the web code, used in the native context, is either rejected or the driver interprets the CSS-like syntax over native attributes (UiAutomator2 translates it into a UiSelector) — either way it will not find a DOM class. Conversely, AppiumBy.accessibilityId in a WebView matches not content-desc but the DOM element's ARIA label, which often gives surprising results.

Приклад. Page object для гібридного екрана ділять на дві частини: нативну (тулбар, кнопка «Закрити») і веб (поля форми). Кожен метод веб-частини сам перемикається в WEBVIEW і повертається назад, щоб тест не залежав від того, в якому контексті його залишив попередній крок.

Example. A page object for a hybrid screen is split into two parts: native (the toolbar, the "Close" button) and web (the form fields). Every method of the web part switches to WEBVIEW itself and back again, so the test does not depend on which context the previous step left it in.

Підводні камені. У нативному контексті текст із WebView іноді видно як android.view.View з атрибутом text, і спокусливо клікати по ньому — але це крихко і залежить від версії WebView. Координати в нативному і веб-контексті різні: у вебі — CSS-пікселі відносно сторінки, у нативному — пікселі екрана. driver.getPageSource() у WEBVIEW повертає HTML, а не XML дерева UI.

Pitfalls. In the native context WebView text is sometimes visible as an android.view.View with a text attribute, and tapping it is tempting — but that is brittle and depends on the WebView version. Coordinates differ between contexts: in the web they are CSS pixels relative to the page, in native they are screen pixels. driver.getPageSource() in WEBVIEW returns HTML, not the UI tree XML.

Уточнююче питання. «Як дізнатися активний контекст?» — driver.getContext(); варто логувати його в кожному кроці, де перемикаєтесь.

Follow-up question. "How do you tell which context is active?" driver.getContext(); log it at every step that switches.

Page object із двома світами
A page object with two worlds
public class CheckoutWebPage {
    private final AndroidDriver driver;
    private final By cardNumber = By.cssSelector("input[name=cardNumber]");   // DOM
    private final By closeButton = AppiumBy.accessibilityId("closeCheckout"); // нативний тулбар

    public void enterCard(String number) {
        inWebView(() -> driver.findElement(cardNumber).sendKeys(number));
    }

    public void close() {
        driver.context("NATIVE_APP");
        driver.findElement(closeButton).click();
    }

    private void inWebView(Runnable action) {
        String previous = driver.getContext();
        driver.context(Contexts.firstWebView(driver));
        try { action.run(); } finally { driver.context(previous); }
    }
}
public class CheckoutWebPage {
    private final AndroidDriver driver;
    private final By cardNumber = By.cssSelector("input[name=cardNumber]");   // DOM
    private final By closeButton = AppiumBy.accessibilityId("closeCheckout"); // native toolbar

    public void enterCard(String number) {
        inWebView(() -> driver.findElement(cardNumber).sendKeys(number));
    }

    public void close() {
        driver.context("NATIVE_APP");
        driver.findElement(closeButton).click();
    }

    private void inWebView(Runnable action) {
        String previous = driver.getContext();
        driver.context(Contexts.firstWebView(driver));
        try { action.run(); } finally { driver.context(previous); }
    }
}
🔧 Розбір синтаксису: Поля By явно розділені: CSS для DOM, accessibility id для нативного тулбара. inWebView запам'ятовує поточний контекст, виконує дію у WebView і в finally повертає попередній, навіть якщо дія впала. Contexts.firstWebView — хелпер проєкту з очікуванням WEBVIEW-контексту.
🔧 Syntax breakdown: The By fields are clearly split: CSS for the DOM, accessibility id for the native toolbar. inWebView remembers the current context, runs the action in the WebView and restores the previous context in finally, even if the action failed. Contexts.firstWebView is a project helper that waits for the WEBVIEW context.
💡 Простими словами: Нативний контекст і WebView — дві різні мапи. Адреса з однієї мапи на іншій просто не існує.
💡 In plain words: The native context and the WebView are two different maps. An address from one map simply does not exist on the other.
🗣 Як читати вголос Я пояснюю це так: локатор працює лише в активному контексті. У нативному немає DOM, тож CSS-клас із вебу там не знайдеться, а в WebView не видно нативного тулбара. Тому в page object я явно розділяю нативні і веб-локатори, а веб-методи самі перемикаються в WEBVIEW і повертаються назад у finally. Так тест не залежить від того, де його залишив попередній крок. Клацати по тексту WebView з нативного контексту не люблю — це крихко.
🗣 How to say it out loud I explain it like this: a locator only works in the active context. The native context has no DOM, so a CSS class from the web won't be found there, and in the WebView you can't see the native toolbar. So in the page object I clearly separate native and web locators, and web methods switch into WEBVIEW themselves and switch back in finally. That way a test doesn't depend on where the previous step left it. I don't like tapping WebView text from the native context — it's brittle.
37 Як тестувати мобільний веб через Appium і чим це відрізняється від емуляції мобільного в десктопному браузері? How do you test mobile web through Appium and how is it different from mobile emulation in a desktop browser?
Hybrid і WebView Hybrid and WebView Middle ▶

Що це. Мобільний веб в Appium — сесія з browserName: Chrome на Android (драйвер UiAutomator2 керує ним через chromedriver) або Safari на iOS (XCUITest працює через Web Inspector). Тест пише звичайний WebDriver-код, але сторінка відкривається у справжньому мобільному браузері на справжньому рушії.

What it is. Mobile web in Appium is a session with browserName: Chrome on Android (UiAutomator2 drives it through chromedriver) or Safari on iOS (XCUITest works through Web Inspector). The test uses ordinary WebDriver code, but the page opens in a real mobile browser on a real engine.

Навіщо. Емуляція пристрою в DevTools чи Playwright змінює лише viewport, user agent і тач-події в десктопному Chromium. Вона не відтворює: WebKit на iOS (там усі браузери до недавнього часу використовували WebKit), поведінку адресного рядка, що змінює висоту 100vh, safe area під вирізом, справжню екранну клавіатуру, яка перекриває поле, автозаповнення паролів, продуктивність слабкого процесора. Баги верстки Safari на iPhone ловляться лише на iPhone.

Why. Device emulation in DevTools or Playwright only changes the viewport, user agent and touch events in desktop Chromium. It does not reproduce: WebKit on iOS (where until recently every browser used WebKit), the address bar changing the height of 100vh, the safe area under the notch, the real on-screen keyboard covering a field, password autofill, or a weak CPU's performance. Safari layout bugs on iPhone are caught only on iPhone.

Приклад. Кнопка «Оформити» внизу сторінки з position: fixed на iPhone ховається під панеллю Safari. Тест відкриває сторінку в Safari на симуляторі, скролить, перевіряє, що кнопка видима й клікабельна, і робить скриншот для візуального порівняння.

Example. A "Checkout" button at the bottom of the page with position: fixed hides under Safari's toolbar on iPhone. The test opens the page in Safari on a simulator, scrolls, checks the button is visible and clickable, and takes a screenshot for visual comparison.

Підводні камені. Для Chrome на Android потрібна відповідна версія chromedriver, для Safari на реальному iPhone — увімкнений Web Inspector у налаштуваннях Safari. Команди через Web Inspector на iOS повільніші, ніж у десктопному WebDriver. Нативні елементи браузера (адресний рядок, діалоги дозволу геолокації) недоступні з веб-контексту — для них треба перемикатися в NATIVE_APP. Більшість перевірок адаптивної верстки дешевше робити в Playwright, а Appium лишити для специфіки реальних браузерів.

Pitfalls. Chrome on Android needs a matching chromedriver version; Safari on a real iPhone needs Web Inspector enabled in Safari settings. Commands through Web Inspector on iOS are slower than desktop WebDriver. Native browser elements (the address bar, the location permission dialog) cannot be reached from the web context — you have to switch to NATIVE_APP for them. Most responsive-layout checks are cheaper in Playwright; keep Appium for real-browser specifics.

Уточнююче питання. «Чи вистачить WebKit у Playwright з пресетом iPhone?» — для більшості перевірок так, але це десктопна збірка WebKit, а не Safari на iOS; для критичних флоу перед релізом варто мати невеликий набір на справжньому Safari.

Follow-up question. "Is Playwright's WebKit with an iPhone preset enough?" For most checks, yes, but it is a desktop WebKit build, not Safari on iOS; for release-critical flows keep a small suite on real Safari.

Safari на iOS-симуляторі
Safari on an iOS simulator
XCUITestOptions safari = new XCUITestOptions()
    .setDeviceName("iPhone 15")
    .setPlatformVersion("17.5")
    .withBrowserName("Safari");

IOSDriver driver = new IOSDriver(serverUrl, safari);
driver.get("https://shop.example.com/cart");

WebElement checkout = driver.findElement(By.cssSelector("[data-test=checkout]"));
((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView({block: 'end'})", checkout);
assertTrue(checkout.isDisplayed());
File shot = driver.getScreenshotAs(OutputType.FILE);
🔧 Розбір синтаксису: withBrowserName("Safari") запускає веб-сесію замість застосунку. executeScript у веб-контексті виконується в самій сторінці, а arguments[0] — переданий елемент. getScreenshotAs знімає весь екран пристрою разом із панеллю браузера, тому видно, що саме перекриває кнопку.
🔧 Syntax breakdown: withBrowserName("Safari") starts a web session instead of an app. executeScript in the web context runs inside the page itself, and arguments[0] is the element passed in. getScreenshotAs captures the whole device screen including the browser toolbar, so you can see exactly what covers the button.
💡 Простими словами: Емуляція в DevTools — примірка одягу на манекені. Appium із реальним Safari — примірка на живій людині, яка ще й рухається.
💡 In plain words: Emulation in DevTools is trying clothes on a mannequin. Appium with real Safari is a fitting on a live person who also moves.
🗣 Як читати вголос Для мобільного вебу я запускаю сесію з browserName, і сторінка відкривається в справжньому Chrome на Android або Safari на iOS. Код такий самий, як у Selenium. Цінність у тому, що емуляція в десктопному браузері змінює лише розмір вікна, а не рушій: WebKit на iOS, адресний рядок, що з'їдає висоту, safe area, справжня клавіатура. Але основну адаптивну верстку я перевіряю дешевше в Playwright, а на Appium лишаю специфіку реальних браузерів, особливо Safari.
🗣 How to say it out loud For mobile web I start a session with browserName, and the page opens in real Chrome on Android or Safari on iOS. The code is the same as Selenium. The value is that emulation in a desktop browser only changes the window size, not the engine: WebKit on iOS, the address bar eating the height, the safe area, the real keyboard. But I check the bulk of responsive layout more cheaply in Playwright, and keep Appium for real-browser specifics, especially Safari.
▶ 6 9
38 Які артефакти збирати, коли мобільний тест падає: скриншот, page source, відео, логи? What artifacts should you collect when a mobile test fails: screenshot, page source, video, logs?
Стабільність і CI Stability and CI Junior ▶

Що це. Мобільне падіння рідко відтворюється на машині розробника: інша модель, стан пам'яті, мережа, системний діалог, що вискочив саме тоді. Тому тест має сам зібрати докази в момент падіння: скриншот, page source (XML дерева UI), відео прогону, лог Appium-сервера, лог пристрою (logcat чи syslog) і метадані — модель, версію ОС, збірку застосунку, udid.

What it is. A mobile failure rarely reproduces on a developer's machine: another model, memory state, network, a system dialog that popped up at exactly that moment. So the test must collect evidence itself at the moment of failure: a screenshot, the page source (the UI tree XML), a video of the run, the Appium server log, the device log (logcat or syslog) and metadata — model, OS version, app build, udid.

Навіщо кожен. Скриншот показує, що бачив користувач. Page source пояснює, чому локатор не спрацював: елемент є, але з іншим id, або його перекрив діалог. Відео показує рух — анімацію, свайп, що пролетів, діалог, який з'явився й зник. Лог Appium — які команди йшли і що відповів драйвер. Лог пристрою — креші, ANR, помилки мережі в самому застосунку.

Why each one. The screenshot shows what the user saw. The page source explains why a locator failed: the element is there but with a different id, or a dialog covers it. The video shows motion — an animation, a swipe that overshot, a dialog that appeared and vanished. The Appium log shows which commands were sent and what the driver answered. The device log shows crashes, ANRs and network errors inside the app itself.

Приклад. TestNG-слухач у onTestFailure знімає скриншот і page source і зупиняє запис відео, а в onTestSuccess запис просто відкидає, щоб не зберігати гігабайти. Усе кладеться в каталог з іменем тесту, і CI завантажує його як артефакт збірки.

Example. A TestNG listener takes a screenshot and page source and stops the video recording in onTestFailure, and in onTestSuccess simply discards the recording so gigabytes are not stored. Everything goes into a folder named after the test, and CI uploads it as a build artifact.

Підводні камені. Знімати артефакти треба до quit() — після нього сесії вже немає. Запис екрана на Android за замовчуванням обмежений трьома хвилинами, а на iOS вимагає встановленого ffmpeg на хості Appium. Page source на iOS може займати секунди, тож не варто знімати його на кожному кроці. І без метаданих пристрою навіть ідеальне відео не пояснить, чому на одній моделі падає, а на іншій ні.

Pitfalls. Artifacts must be captured before quit() — after it the session is gone. Screen recording on Android is capped at three minutes by default, and on iOS it needs ffmpeg installed on the Appium host. The page source on iOS can take seconds, so do not capture it on every step. And without device metadata, even a perfect video will not explain why a test fails on one model and passes on another.

Уточнююче питання. «Що найцінніше, якщо можна зберегти лише одне?» — зазвичай page source разом зі скриншотом: вони пояснюють більшість падінь локаторів, а відео — вже для нестабільних жестів і таймінгів.

Follow-up question. "What is most valuable if you can keep only one?" Usually the page source together with the screenshot: they explain most locator failures, while video is for flaky gestures and timing.

TestNG-слухач для артефактів
A TestNG listener for artifacts
public class MobileArtifacts implements ITestListener {
    @Override
    public void onTestStart(ITestResult r) {
        driver().startRecordingScreen(AndroidStartScreenRecordingOptions
            .startScreenRecordingOptions().withTimeLimit(Duration.ofMinutes(10)));
    }

    @Override
    public void onTestFailure(ITestResult r) {
        Path dir = Path.of("build/artifacts", r.getName());
        try {
            Files.createDirectories(dir);
            Files.write(dir.resolve("screen.png"), driver().getScreenshotAs(OutputType.BYTES));
            Files.writeString(dir.resolve("source.xml"), driver().getPageSource());
            Files.write(dir.resolve("video.mp4"),
                Base64.getMimeDecoder().decode(driver().stopRecordingScreen()));
            Files.writeString(dir.resolve("device.txt"), String.valueOf(driver().getCapabilities().asMap()));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public void onTestSuccess(ITestResult r) {
        driver().stopRecordingScreen(); // відео успішних тестів не зберігаємо
    }
}
public class MobileArtifacts implements ITestListener {
    @Override
    public void onTestStart(ITestResult r) {
        driver().startRecordingScreen(AndroidStartScreenRecordingOptions
            .startScreenRecordingOptions().withTimeLimit(Duration.ofMinutes(10)));
    }

    @Override
    public void onTestFailure(ITestResult r) {
        Path dir = Path.of("build/artifacts", r.getName());
        try {
            Files.createDirectories(dir);
            Files.write(dir.resolve("screen.png"), driver().getScreenshotAs(OutputType.BYTES));
            Files.writeString(dir.resolve("source.xml"), driver().getPageSource());
            Files.write(dir.resolve("video.mp4"),
                Base64.getMimeDecoder().decode(driver().stopRecordingScreen()));
            Files.writeString(dir.resolve("device.txt"), String.valueOf(driver().getCapabilities().asMap()));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public void onTestSuccess(ITestResult r) {
        driver().stopRecordingScreen(); // videos of passing tests are not kept
    }
}
🔧 Розбір синтаксису: startRecordingScreen і stopRecordingScreen — інтерфейс CanRecordScreen; стоп повертає відео як Base64-рядок, тому його декодують. withTimeLimit піднімає межу запису. getScreenshotAs(OutputType.BYTES) дає PNG у пам'яті. getCapabilities().asMap() містить модель, версію ОС і udid, які сервер повернув для сесії. driver() — доступ до драйвера поточного потоку.
🔧 Syntax breakdown: startRecordingScreen and stopRecordingScreen come from the CanRecordScreen interface; stop returns the video as a Base64 string, so it is decoded. withTimeLimit raises the recording cap. getScreenshotAs(OutputType.BYTES) returns a PNG in memory. getCapabilities().asMap() holds the model, OS version and udid the server returned for the session. driver() gives access to the current thread's driver.
💡 Простими словами: Падіння мобільного тесту — місце події. Збери фото, план кімнати і запис камери, поки нічого не прибрали.
💡 In plain words: A failed mobile test is a scene of the incident. Take the photo, the floor plan and the camera footage before anything is cleaned up.
🗣 Як читати вголос На мобільних падіння рідко відтворюються локально, тому мій тест збирає докази сам. Скриншот показує, що бачив користувач, page source пояснює, чому не знайшовся локатор, відео показує анімації й жести, лог Appium — команди, а logcat чи syslog — креші самого застосунку. Плюс модель пристрою, ОС і збірка. Роблю це в слухачі на падіння, обов'язково до quit. Відео пишу завжди, але зберігаю лише для падінь, щоб не забивати сховище.
🗣 How to say it out loud On mobile, failures rarely reproduce locally, so my test collects evidence itself. The screenshot shows what the user saw, the page source explains why the locator wasn't found, the video shows animations and gestures, the Appium log shows commands, and logcat or syslog show crashes in the app itself. Plus the device model, OS and build. I do it in a failure listener, always before quit. I record video every time but keep it only for failures so storage doesn't fill up.
39 Як дістати логи пристрою (logcat, syslog, crash log) з Appium і що в них шукати? How do you get device logs (logcat, syslog, crash logs) from Appium and what do you look for in them?
Стабільність і CI Stability and CI Middle ▶

Що це. Лог пристрою — те, що пишуть ОС і застосунок: на Android logcat, на iOS системний лог (syslog) і звіти про креші (crashlog). Appium дає до них доступ через стандартний інтерфейс логів сесії: driver.manage().logs().get("logcat"). Список типів, доступних у конкретній сесії, повертає getAvailableLogTypes().

What it is. The device log is what the OS and the app write: logcat on Android, the system log (syslog) and crash reports (crashlog) on iOS. Appium exposes them through the session's standard logs interface: driver.manage().logs().get("logcat"). getAvailableLogTypes() returns the log types available in a given session.

Навіщо. Лог Appium пояснює, що робив тест, а лог пристрою — що відбувалося в застосунку. Креш із FATAL EXCEPTION, ANR, помилки мережі SocketTimeoutException, попередження StrictMode, відмова в дозволі — все це видно лише тут. Без логу пристрою баг-репорт «застосунок закрився» майже марний для розробника.

Why. The Appium log explains what the test did; the device log explains what happened in the app. A crash with FATAL EXCEPTION, an ANR, SocketTimeoutException network errors, StrictMode warnings, a permission denial — all of that is visible only here. Without a device log, a bug report saying "the app closed" is nearly useless to a developer.

Приклад. Перед тестом очищаємо буфер, щоб у лог не потрапило минуле, а після падіння забираємо записи і фільтруємо за PID застосунку та рівнем помилок. Корисний трюк: у кожному тесті писати в logcat власний маркер початку тесту, щоб потім легко знайти потрібний відрізок.

Example. Before the test we clear the buffer so the past does not leak into the log, and after a failure we fetch the entries and filter them by the app's PID and error level. A useful trick: in every test write your own start marker into logcat, so the right fragment is easy to find later.

Підводні камені. Кожен виклик logs().get("logcat") повертає нові записи з моменту попереднього виклику, тому якщо забрати лог у @AfterMethod, а потім ще раз у слухачі, другий виклик буде майже порожнім. Буфер logcat має обмежений розмір, і на «балакучому» пристрої ранні записи витісняються. На iOS креш-звіт з'являється із затримкою в кілька секунд після падіння. Логи можуть містити токени й персональні дані — перед публікацією як артефакт їх варто маскувати.

Pitfalls. Each logs().get("logcat") call returns entries since the previous call, so if you fetch the log in @AfterMethod and again in a listener, the second call is almost empty. The logcat buffer is size-limited, and on a "chatty" device early entries get pushed out. On iOS the crash report appears a few seconds after the crash. Logs may contain tokens and personal data — mask them before publishing as an artifact.

Уточнююче питання. «Як отримати лог без Appium?» — adb logcat -d --pid=$(adb shell pidof -s com.example.shop) на Android, Console.app або xcrun simctl spawn booted log stream на iOS.

Follow-up question. "How do you get the log without Appium?" adb logcat -d --pid=$(adb shell pidof -s com.example.shop) on Android; Console.app or xcrun simctl spawn booted log stream on iOS.

Збір logcat з фільтром
Collecting logcat with a filter
Set<String> types = driver.manage().logs().getAvailableLogTypes();   // logcat, bugreport, server...

List<String> errors = driver.manage().logs().get("logcat").getAll().stream()
    .map(LogEntry::getMessage)
    .filter(m -> m.contains("FATAL EXCEPTION") || m.contains(" E ") || m.contains("ANR in"))
    .toList();
Files.write(Path.of("build/artifacts/logcat-errors.txt"), errors);
Set<String> types = driver.manage().logs().getAvailableLogTypes();   // logcat, bugreport, server...

List<String> errors = driver.manage().logs().get("logcat").getAll().stream()
    .map(LogEntry::getMessage)
    .filter(m -> m.contains("FATAL EXCEPTION") || m.contains(" E ") || m.contains("ANR in"))
    .toList();
Files.write(Path.of("build/artifacts/logcat-errors.txt"), errors);
Те саме з командного рядка
The same from the command line
adb logcat -c                                        # очистити буфер перед тестом
adb shell log -t E2E "START checkout_with_card"      # маркер початку тесту
adb logcat -d -v time --pid=$(adb shell pidof -s com.example.shop) "*:W"
xcrun simctl spawn booted log stream --predicate 'process == "Shop"' --level error
adb logcat -c                                        # clear the buffer before the test
adb shell log -t E2E "START checkout_with_card"      # test start marker
adb logcat -d -v time --pid=$(adb shell pidof -s com.example.shop) "*:W"
xcrun simctl spawn booted log stream --predicate 'process == "Shop"' --level error
🔧 Розбір синтаксису: getAll() повертає список LogEntry, а фільтр лишає креші, рядки рівня Error і ANR. adb shell log -t E2E пише власний рядок із тегом E2E. -d вивантажує буфер і завершується, --pid залишає лише процес застосунку, *:W — рівень Warning і вище. На iOS --predicate фільтрує потік логів за іменем процесу.
🔧 Syntax breakdown: getAll() returns a list of LogEntry, and the filter keeps crashes, Error-level lines and ANRs. adb shell log -t E2E writes your own line with the E2E tag. -d dumps the buffer and exits, --pid keeps only the app's process, and *:W means Warning and above. On iOS --predicate filters the log stream by process name.
💡 Простими словами: Лог Appium — щоденник тесту, лог пристрою — щоденник застосунку. Щоб зрозуміти сварку, читають обидва.
💡 In plain words: The Appium log is the test's diary; the device log is the app's diary. To understand an argument, you read both.
🗣 Як читати вголос Лог Appium каже, що робив тест, а лог пристрою — що відбувалося в застосунку, тому я збираю обидва. З Appium беру logcat або syslog через logs().get, фільтрую за процесом і рівнем помилок і прикладаю до звіту. Шукаю FATAL EXCEPTION, ANR, мережеві таймаути. Перед тестом чищу буфер і пишу свій маркер початку, щоб легко знайти відрізок. Пам'ятаю, що кожне читання забирає нові записи, тож читаю лог в одному місці. І маскую токени, перш ніж публікувати артефакт.
🗣 How to say it out loud The Appium log tells what the test did, the device log tells what happened in the app, so I collect both. From Appium I take logcat or syslog through logs().get, filter by process and error level and attach it to the report. I look for FATAL EXCEPTION, ANRs, network timeouts. Before the test I clear the buffer and write my own start marker so the fragment is easy to find. I remember each read takes only new entries, so I read the log in one place. And I mask tokens before publishing the artifact.
40 Як анімації роблять мобільні тести нестабільними і як їх приборкати? How do animations make mobile tests flaky and how do you tame them?
Стабільність і CI Stability and CI Middle ▶

Що це. Анімація переходу, ripple-ефект, шиммер-скелетон чи Lottie-лоадер означають, що елемент уже є в дереві, але ще рухається або прозорий. Клік у цей момент потрапляє в стару позицію або ігнорується. Крім того, UiAutomator перед кожною дією чекає, доки UI «заспокоїться» (waitForIdleTimeout, 10 секунд за замовчуванням), і нескінченна анімація робить кожну команду повільною.

What it is. A transition animation, a ripple effect, a shimmer skeleton or a Lottie loader mean the element is already in the tree but still moving or transparent. A tap at that moment hits the old position or gets ignored. On top of that, UiAutomator waits before every action for the UI to "settle" (waitForIdleTimeout, 10 seconds by default), and an endless animation makes every command slow.

Навіщо розуміти. Це джерело двох різних симптомів: випадкові промахи кліків і тести, які раптом стали в рази повільнішими після додавання анімованого банера. Лікуються вони по-різному.

Why it matters. This produces two different symptoms: random missed taps, and tests that suddenly get several times slower after an animated banner is added. They are cured differently.

Приклад. На тестових пристроях вимикають системні анімації — три налаштування animator_duration_scale, transition_animation_scale, window_animation_scale в нуль, або capability appium:disableWindowAnimation. На iOS-симуляторі допомагає appium:reduceMotion. Для екрана з нескінченним лоадером зменшують waitForIdleTimeout через Settings API саме на час роботи з ним і чекають явної умови — появи контенту.

Example. On test devices you turn off system animations — set the three settings animator_duration_scale, transition_animation_scale and window_animation_scale to zero, or use the appium:disableWindowAnimation capability. On the iOS simulator appium:reduceMotion helps. For a screen with an endless loader you lower waitForIdleTimeout through the Settings API only while working with it, and wait for an explicit condition — the content appearing.

Підводні камені. Системні налаштування не вимикають анімації, які застосунок малює сам (Lottie, власні ValueAnimator у Compose) — розробники можуть додати прапорець для тестової збірки. Вимкнені анімації приховують баги, які бачить користувач (наприклад, кнопка, клікабельна посеред переходу), тому хоча б частину набору ганяють з увімкненими. Нульовий waitForIdleTimeout для всієї сесії повертає промахи кліків — його змінюють точково.

Pitfalls. System settings do not disable animations the app draws itself (Lottie, custom ValueAnimators in Compose) — developers can add a flag for the test build. Disabled animations hide bugs users see (for example, a button that is tappable mid-transition), so at least part of the suite runs with them on. A zero waitForIdleTimeout for the whole session brings missed taps back — change it locally.

Уточнююче питання. «Як зрозуміти, що повільність саме через idle?» — у лозі Appium між командами видно «Waiting up to 10000ms for the device to idle».

Follow-up question. "How do you know the slowness comes from idle waiting?" The Appium log shows "Waiting up to 10000ms for the device to idle" between commands.

Вимкнути системні анімації
Turning off system animations
adb shell settings put global animator_duration_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global window_animation_scale 0
Точкове зменшення waitForIdleTimeout
Lowering waitForIdleTimeout locally
options.setCapability("appium:disableWindowAnimation", true);

// екран з нескінченним лоадером: не чекати idle 10 с на кожну команду
driver.setSetting("waitForIdleTimeout", 100);
try {
    wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("feedFirstItem")));
} finally {
    driver.setSetting("waitForIdleTimeout", 10000);
}
options.setCapability("appium:disableWindowAnimation", true);

// screen with an endless loader: don't wait 10 s for idle on every command
driver.setSetting("waitForIdleTimeout", 100);
try {
    wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("feedFirstItem")));
} finally {
    driver.setSetting("waitForIdleTimeout", 10000);
}
🔧 Розбір синтаксису: Три settings put global — ті самі перемикачі, що в меню розробника «Масштаб анімації». setSetting змінює налаштування драйвера посеред сесії без перезапуску; значення в мілісекундах. Блок finally повертає стандартні 10 секунд, щоб інші екрани й далі чекали стабільного UI.
🔧 Syntax breakdown: The three settings put global commands are the same switches as the "Animation scale" items in developer options. setSetting changes a driver setting mid-session without a restart; the value is in milliseconds. The finally block restores the default 10 seconds so other screens still wait for a stable UI.
💡 Простими словами: Не намагайся вхопити м'яч, поки він летить. Або зупини гру, або дочекайся, поки м'яч упаде.
💡 In plain words: Don't try to grab the ball while it is flying. Either pause the game or wait for the ball to land.
🗣 Як читати вголос Анімації дають два симптоми. Перший — промахи: елемент уже в дереві, але ще їде, і клік летить не туди. Другий — повільність: UiAutomator перед кожною командою чекає, поки UI заспокоїться, і нескінченний лоадер додає секунди до кожного кроку. На тестових пристроях я вимикаю системні анімації, на iOS-симуляторі вмикаю reduceMotion, а для екранів з вічним лоадером точково зменшую waitForIdleTimeout. Але частину прогонів лишаю з анімаціями, бо користувачі бачать саме їх.
🗣 How to say it out loud Animations cause two symptoms. The first is misses: the element is in the tree but still moving, and the tap goes astray. The second is slowness: UiAutomator waits for the UI to settle before every command, and an endless loader adds seconds to each step. On test devices I turn off system animations, on the iOS simulator I enable reduceMotion, and for screens with an eternal loader I lower waitForIdleTimeout locally. But I keep some runs with animations on, because that's what users see.
41 Як спроєктувати тестування поганої мережі й офлайну для мобільного застосунку з Appium? How do you design testing of poor network and offline behaviour for a mobile app with Appium?
Стабільність і CI Stability and CI Senior ▶

Що це. Мобільний застосунок живе в мережі, яка постійно змінюється: метро, перемикання Wi-Fi на LTE, режим польоту, повільний EDGE, капчур-портал у кафе. Треба перевірити, що застосунок показує зрозумілий стан офлайну, не губить введені дані, повторює запити з розумною затримкою, не створює дублікатів (ідемпотентність оплати) і відновлюється, коли мережа повертається.

What it is. A mobile app lives on a network that keeps changing: the metro, switching from Wi-Fi to LTE, airplane mode, slow EDGE, a captive portal in a café. You need to check that the app shows a clear offline state, does not lose typed data, retries requests with a sensible backoff, creates no duplicates (payment idempotency) and recovers when the network returns.

Навіщо / трейдофи. Є три рівні інструментів. Перший — радіо пристрою: mobile: setConnectivity вмикає й вимикає Wi-Fi, дані, режим польоту (Android), mobile: networkSpeed задає профіль швидкості на емуляторі. Це реалістично, але грубо і лише для Android. Другий — проксі з ін'єкцією збоїв (mitmproxy, Toxiproxy, WireMock з fault injection): затримка 5 секунд, обрив з'єднання, 503 на конкретний ендпоінт — детерміновано і для обох платформ. Третій — моки на рівні застосунку в тестовій збірці. Для регресії найкраще працює проксі: той самий сценарій повторюється щоразу однаково.

Why / trade-offs. There are three levels of tools. The first is the device radio: mobile: setConnectivity toggles Wi-Fi, data and airplane mode (Android), and mobile: networkSpeed sets a speed profile on the emulator. Realistic, but coarse and Android-only. The second is a proxy with fault injection (mitmproxy, Toxiproxy, WireMock with fault injection): a 5-second delay, a dropped connection, a 503 on a specific endpoint — deterministic and cross-platform. The third is app-level mocks in a test build. For regression the proxy works best: the same scenario plays out identically every time.

Приклад. Тест оплати: користувач натискає «Оплатити», проксі затримує відповідь /payments на 15 с і повертає обрив; застосунок має показати «Перевіряємо статус», не дати натиснути вдруге, а після повторного запиту — один успішний платіж. Окремий тест — режим польоту посеред заповнення форми: дані мають пережити відновлення.

Example. A payment test: the user taps "Pay", the proxy delays the /payments response by 15 s and drops the connection; the app must show "Checking status", must not allow a second tap, and after the retry there must be exactly one successful payment. A separate test: airplane mode in the middle of filling a form — the data must survive recovery.

Підводні камені. Вимкнення Wi-Fi на реальному пристрої, підключеному по мережевому adb чи в хмарній фермі, обриває саму сесію Appium. Проксі на iOS вимагає встановленого й довіреного CA-сертифіката, а certificate pinning у застосунку ламає перехоплення — потрібна тестова збірка без пінінгу. Результати «повільної мережі» на емуляторі не переносяться на реальні пристрої один до одного.

Pitfalls. Turning off Wi-Fi on a real device connected over network adb or on a cloud farm kills the Appium session itself. A proxy on iOS needs an installed and trusted CA certificate, and certificate pinning in the app breaks interception — you need a test build without pinning. "Slow network" results on an emulator do not carry over one-to-one to real devices.

Уточнююче питання. «Як перевірити відновлення після офлайну?» — повернути мережу і чекати не таймаут, а конкретний стан: зникнення банера офлайну і появу синхронізованих даних.

Follow-up question. "How do you check recovery after offline?" Restore the network and wait not for a timeout but for a concrete state: the offline banner disappearing and synced data appearing.

Радіо на Android-емуляторі
Radio on an Android emulator
driver.executeScript("mobile: setConnectivity", Map.of("wifi", false, "data", false));
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("offlineBanner")));

driver.executeScript("mobile: networkSpeed", Map.of("speed", "edge"));   // лише емулятор
driver.executeScript("mobile: setConnectivity", Map.of("wifi", true, "data", true));
wait.until(ExpectedConditions.invisibilityOfElementLocated(AppiumBy.accessibilityId("offlineBanner")));
driver.executeScript("mobile: setConnectivity", Map.of("wifi", false, "data", false));
wait.until(ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("offlineBanner")));

driver.executeScript("mobile: networkSpeed", Map.of("speed", "edge"));   // emulator only
driver.executeScript("mobile: setConnectivity", Map.of("wifi", true, "data", true));
wait.until(ExpectedConditions.invisibilityOfElementLocated(AppiumBy.accessibilityId("offlineBanner")));
Детермінований збій через проксі (Toxiproxy)
A deterministic failure through a proxy (Toxiproxy)
toxiproxy-cli toxic add -t latency -a latency=15000 payments_api
toxiproxy-cli toxic add -t reset_peer -a timeout=15500 payments_api
# ... тест натискає Pay і перевіряє стан «Перевіряємо статус» ...
toxiproxy-cli toxic remove -n latency_downstream payments_api
toxiproxy-cli toxic remove -n reset_peer_downstream payments_api
toxiproxy-cli toxic add -t latency -a latency=15000 payments_api
toxiproxy-cli toxic add -t reset_peer -a timeout=15500 payments_api
# ... the test taps Pay and checks the "Checking status" state ...
toxiproxy-cli toxic remove -n latency_downstream payments_api
toxiproxy-cli toxic remove -n reset_peer_downstream payments_api
🔧 Розбір синтаксису: mobile: setConnectivity приймає wifi, data і airplaneMode; змінюються лише передані ключі. networkSpeed бере профілі емулятора (gsm, edge, lte, full тощо). У Toxiproxy «токсик» latency додає затримку в мілісекундах, reset_peer обриває з'єднання після таймауту; імена токсиків за замовчуванням мають суфікс _downstream.
🔧 Syntax breakdown: mobile: setConnectivity takes wifi, data and airplaneMode; only the keys you pass change. networkSpeed uses emulator profiles (gsm, edge, lte, full and so on). In Toxiproxy the latency toxic adds a delay in milliseconds, and reset_peer drops the connection after a timeout; default toxic names get a _downstream suffix.
💡 Простими словами: Вимкнути радіо — як вимкнути світло в усьому будинку. Проксі — як вимкнути одну лампочку в потрібний момент.
💡 In plain words: Turning off the radio is like cutting power to the whole house. A proxy is like switching off one bulb at exactly the right moment.
🗣 Як читати вголос Мережеві сценарії я будую на трьох рівнях. Радіо пристрою — mobile setConnectivity і networkSpeed на Android-емуляторі — реалістично, але грубо і лише для Android. Для регресії беру проксі з ін'єкцією збоїв на кшталт Toxiproxy: затримка чи обрив на конкретному ендпоінті, однаково щоразу й на обох платформах. Перевіряю не лише банер офлайну, а збереження даних, повтори і відсутність дубля оплати. І пам'ятаю, що вимкнений Wi-Fi на хмарному пристрої обриває саму сесію.
🗣 How to say it out loud I build network scenarios on three levels. The device radio — mobile setConnectivity and networkSpeed on an Android emulator — is realistic but coarse and Android-only. For regression I use a fault-injection proxy like Toxiproxy: a delay or a drop on a specific endpoint, identical every time and on both platforms. I check not just the offline banner but data preservation, retries and no duplicate payment. And I remember that turning off Wi-Fi on a cloud device kills the session itself.
42 Як збирати дані продуктивності (CPU, пам'ять, час старту) в Appium-тестах і наскільки їм можна довіряти? How do you collect performance data (CPU, memory, start-up time) in Appium tests and how far can you trust it?
Стабільність і CI Stability and CI Senior ▶

Що це. На Android AndroidDriver має getPerformanceData(package, type, timeout) з типами cpuinfo, memoryinfo, batteryinfo, networkinfo — під капотом це dumpsys. На iOS драйвер XCUITest уміє записати трасу Instruments через mobile: startPerfRecord / mobile: stopPerfRecord (потрібен дозвіл сервера perf_record). Час холодного старту на Android дає adb shell am start -W.

What it is. On Android AndroidDriver has getPerformanceData(package, type, timeout) with the types cpuinfo, memoryinfo, batteryinfo and networkinfo — under the hood it is dumpsys. On iOS the XCUITest driver can record an Instruments trace with mobile: startPerfRecord / mobile: stopPerfRecord (the server must allow perf_record). Cold start time on Android comes from adb shell am start -W.

Навіщо / трейдофи. Функціональні тести вже проходять реальні сценарії, тож дешево додати до них знімки метрик і ловити регресії: пам'ять, що росте після 50 відкриттів екрана (витік), чи холодний старт, що після релізу став на 40% довшим. Але Appium — не інструмент бенчмарку: сам драйвер і його сервер на пристрої споживають CPU, команди йдуть із затримкою, а емулятор не показує реальну продуктивність. Для точних вимірювань розробники використовують Android Macrobenchmark, Perfetto і Xcode Instruments.

Why / trade-offs. Functional tests already walk real scenarios, so adding metric snapshots is cheap and catches regressions: memory that grows after opening a screen 50 times (a leak), or a cold start that got 40% longer after a release. But Appium is not a benchmarking tool: the driver and its on-device server consume CPU themselves, commands arrive with latency, and an emulator does not show real performance. For precise measurements developers use Android Macrobenchmark, Perfetto and Xcode Instruments.

Приклад. Тест 30 разів відкриває і закриває картку товару, знімаючи memoryinfo до і після. Якщо totalPss зріс більше ніж на 20% і не повертається після System.gc-подібної паузи, збірка отримує попередження, а не падіння, і розробники дивляться heap dump.

Example. A test opens and closes a product card 30 times, taking memoryinfo before and after. If totalPss grew by more than 20% and does not come back after a settling pause, the build gets a warning rather than a failure, and developers look at a heap dump.

Підводні камені. Одиничне вимірювання нічого не означає — потрібні медіана кількох прогонів на тому самому фізичному пристрої з однаковим зарядом і температурою. Пороги «впав тест, бо старт 1,21 с замість 1,2 с» породжують флейки; краще тренд на дашборді й алерт на стабільне погіршення. На iOS запис Instruments сильно сповільнює прогін, тож його вмикають лише в окремій перф-збірці.

Pitfalls. A single measurement means nothing — you need the median of several runs on the same physical device with the same charge level and temperature. Thresholds like "failed because start-up was 1.21 s instead of 1.2 s" breed flakiness; a trend on a dashboard with an alert on sustained degradation works better. On iOS Instruments recording slows the run a lot, so it is enabled only in a separate perf build.

Уточнююче питання. «Як виміряти холодний старт?» — am start -W після am force-stop і повторити 10 разів, беручи медіану TotalTime.

Follow-up question. "How do you measure cold start?" am start -W after am force-stop, repeated 10 times, taking the median TotalTime.

Пам'ять до і після серії відкриттів
Memory before and after a series of openings
AndroidDriver android = (AndroidDriver) driver;
List<String> types = android.getSupportedPerformanceDataTypes();   // cpuinfo, memoryinfo, ...

List<List<Object>> before = android.getPerformanceData("com.example.shop", "memoryinfo", 5);
for (int i = 0; i < 30; i++) {
    openAndCloseProductCard();
}
List<List<Object>> after = android.getPerformanceData("com.example.shop", "memoryinfo", 5);
// рядок 0 — назви полів (totalPss, nativePss...), рядок 1 — значення
report.metric("totalPss.before", before.get(1).get(before.get(0).indexOf("totalPss")));
report.metric("totalPss.after", after.get(1).get(after.get(0).indexOf("totalPss")));
AndroidDriver android = (AndroidDriver) driver;
List<String> types = android.getSupportedPerformanceDataTypes();   // cpuinfo, memoryinfo, ...

List<List<Object>> before = android.getPerformanceData("com.example.shop", "memoryinfo", 5);
for (int i = 0; i < 30; i++) {
    openAndCloseProductCard();
}
List<List<Object>> after = android.getPerformanceData("com.example.shop", "memoryinfo", 5);
// row 0 holds field names (totalPss, nativePss...), row 1 holds values
report.metric("totalPss.before", before.get(1).get(before.get(0).indexOf("totalPss")));
report.metric("totalPss.after", after.get(1).get(after.get(0).indexOf("totalPss")));
Холодний старт з adb
Cold start with adb
adb shell am force-stop com.example.shop
adb shell am start -W -n com.example.shop/.ui.MainActivity | grep TotalTime
# TotalTime: 812  (мс; повторити 10 разів і взяти медіану)
adb shell am force-stop com.example.shop
adb shell am start -W -n com.example.shop/.ui.MainActivity | grep TotalTime
# TotalTime: 812  (ms; repeat 10 times and take the median)
🔧 Розбір синтаксису: getPerformanceData повертає таблицю: перший рядок — назви метрик, другий — значення; 5 — скільки секунд чекати на дані. indexOf("totalPss") знаходить колонку за назвою, а не за позицією, бо набір полів залежить від версії Android. report.metric — власний хелпер, що пише метрику у звіт для тренду. am start -W чекає завершення запуску і друкує TotalTime у мілісекундах.
🔧 Syntax breakdown: getPerformanceData returns a table: the first row holds metric names, the second holds values; 5 is how many seconds to wait for data. indexOf("totalPss") finds the column by name rather than position, because the set of fields depends on the Android version. report.metric is a project helper that writes the metric to the report for trending. am start -W waits for the launch to finish and prints TotalTime in milliseconds.
💡 Простими словами: Appium-метрики — ваги у ванній: показують, що ти поправився, але не підходять для аптечного зважування.
💡 In plain words: Appium metrics are bathroom scales: they tell you that you gained weight, but they are no good for pharmacy-grade weighing.
🗣 Як читати вголос Функціональні тести й так проходять реальні сценарії, тому я додаю до них знімки метрик: getPerformanceData на Android для пам'яті й CPU, am start мінус W для холодного старту, на iOS — запис Instruments у спеціальній перф-збірці. Але я чесно кажу, що Appium не бенчмарк: драйвер сам споживає ресурси, а емулятор бреше про швидкість. Тому я дивлюсь на тренди й медіани на одному фізичному пристрої, а не валю тест через різницю в соту частку секунди. Точні вимірювання — це Macrobenchmark і Instruments.
🗣 How to say it out loud Functional tests already walk real scenarios, so I add metric snapshots to them: getPerformanceData on Android for memory and CPU, am start dash W for cold start, and on iOS an Instruments recording in a dedicated perf build. But I'm honest that Appium isn't a benchmark: the driver consumes resources itself, and an emulator lies about speed. So I look at trends and medians on one physical device rather than failing a test over a hundredth of a second. Precise measurement is Macrobenchmark and Instruments territory.
43 Як організувати паралельний запуск Appium-тестів на кількох пристроях без конфліктів портів? How do you organise parallel Appium runs on several devices without port conflicts?
Стабільність і CI Stability and CI Senior ▶

Що це. Кожна Appium-сесія, крім udid пристрою, займає порти на хості: UiAutomator2 — systemPort (за замовчуванням 8200) для зв'язку з сервером на телефоні, chromedriverPort для WebView і mjpegServerPort для відеопотоку; XCUITest — wdaLocalPort (8100), mjpegServerPort і для реальних iPhone з WebView webkitDebugProxyPort. Дві сесії з однаковим портом конфліктують: друга або не стартує, або, гірше, перехоплює команди першої.

What it is. Besides the device udid, each Appium session occupies ports on the host: UiAutomator2 uses systemPort (8200 by default) to talk to its server on the phone, chromedriverPort for WebViews and mjpegServerPort for the video stream; XCUITest uses wdaLocalPort (8100), mjpegServerPort and, for WebViews on real iPhones, webkitDebugProxyPort. Two sessions with the same port clash: the second either fails to start or, worse, hijacks the first one's commands.

Навіщо / трейдофи. Паралель — головний спосіб скоротити мобільний прогін з години до десяти хвилин. Архітектурне рішення — звідки потік бере «свій» пристрій і порти. Варіант А: статична таблиця пристроїв у конфігу і індекс потоку — просто, але ламається, коли пристрій зник. Варіант Б: пул пристроїв із видачею й поверненням (lease), який перевіряє здоров'я пристрою — складніше, але стійко. Варіант В: Selenium Grid 4 або хмара, яка сама розподіляє сесії, — тоді порти задає вузол.

Why / trade-offs. Parallelism is the main way to cut a mobile run from an hour to ten minutes. The architectural decision is where a thread gets "its" device and ports. Option A: a static device table in config plus the thread index — simple, but breaks when a device disappears. Option B: a device pool with lease and return that health-checks each device — more complex but resilient. Option C: Selenium Grid 4 or a cloud that distributes sessions itself — then the node sets the ports.

Приклад. Для потоку з індексом i: udid з пулу, systemPort = 8200 + i, chromedriverPort = 9515 + i, mjpegServerPort = 7810 + i; для iOS — wdaLocalPort = 8100 + i і окремий derivedDataPath, щоб паралельні збірки WDA не затирали одна одну. TestNG запускається з parallel="tests" і кількістю потоків, що дорівнює кількості пристроїв.

Example. For thread index i: udid from the pool, systemPort = 8200 + i, chromedriverPort = 9515 + i, mjpegServerPort = 7810 + i; for iOS wdaLocalPort = 8100 + i and a separate derivedDataPath, so parallel WDA builds do not overwrite each other. TestNG runs with parallel="tests" and a thread count equal to the number of devices.

Підводні камені. Потоків більше, ніж пристроїв, — сесії стоять у черзі і падають за таймаутом. Тести зі спільним тестовим користувачем конфліктують на бекенді навіть при ідеальних портах — дані теж мають бути на потік. Один Appium-сервер на всі потоки робить лог нечитабельним. Порт, не звільнений після краху, займає наступний прогін — тому quit у finally і перевірка вільних портів при старті.

Pitfalls. More threads than devices means sessions queue and fail on timeout. Tests sharing one test user clash on the backend even with perfect ports — test data must be per thread too. One Appium server for all threads makes the log unreadable. A port not released after a crash blocks the next run — hence quit in finally and a free-port check at start-up.

Уточнююче питання. «Чи можна паралелити на одному пристрої?» — ні, один пристрій — одна активна сесія; паралель масштабується лише кількістю пристроїв.

Follow-up question. "Can you parallelise on one device?" No: one device, one active session; parallelism scales only with the number of devices.

Опції для потоку з індексом i
Options for thread index i
public static UiAutomator2Options android(int i, String udid) {
    UiAutomator2Options o = new UiAutomator2Options()
        .setUdid(udid)
        .setSystemPort(8200 + i)
        .setChromedriverPort(9515 + i)
        .setMjpegServerPort(7810 + i);
    return o;
}

public static XCUITestOptions ios(int i, String udid) {
    XCUITestOptions o = new XCUITestOptions()
        .setUdid(udid)
        .setWdaLocalPort(8100 + i)
        .setMjpegServerPort(9100 + i)
        .setDerivedDataPath("/tmp/wda-" + udid);
    return o;
}
WebdriverIO: воркер на пристрій
WebdriverIO: one worker per device
exports.config = {
  maxInstances: 3,
  capabilities: [
    { platformName: "Android", "appium:udid": "emulator-5554", "appium:systemPort": 8200 },
    { platformName: "Android", "appium:udid": "emulator-5556", "appium:systemPort": 8201 },
    { platformName: "iOS", "appium:udid": "00008110-001A2B3C4D5E801E", "appium:wdaLocalPort": 8101 },
  ],
};
🔧 Розбір синтаксису: Кожен сетер записує відповідну capability з префіксом appium:. Зсув на індекс потоку гарантує, що діапазони не перетинаються; базові значення беруть подалі від портів, які вже займає інфраструктура. setDerivedDataPath дає кожному iOS-пристрою власний каталог збірки WDA. У WebdriverIO maxInstances обмежує кількість одночасних воркерів.
🔧 Syntax breakdown: Each setter writes the matching capability with the appium: prefix. Offsetting by the thread index guarantees the ranges never overlap; the base values are chosen away from ports the infrastructure already uses. setDerivedDataPath gives each iOS device its own WDA build folder. In WebdriverIO maxInstances caps the number of concurrent workers.
💡 Простими словами: Паралельні сесії — як таксі на стоянці: у кожного своє місце й свій номер. Два авто на одному місці — аварія.
💡 In plain words: Parallel sessions are like taxis at a rank: each has its own bay and its own number. Two cars in one bay means a crash.
🗣 Як читати вголос Для паралелі кожен потік отримує свій пристрій і свій набір портів. На Android це udid, systemPort, chromedriverPort і mjpegServerPort, на iOS — udid, wdaLocalPort і окремий derivedDataPath для WDA. Я рахую порти від індексу потоку, а пристрої беру з пулу, який перевіряє, чи пристрій живий. Кількість потоків дорівнює кількості пристроїв, інакше черги й таймаути. І не забуваю про дані: у кожного потоку свій тестовий користувач, бо конфлікт на бекенді ніякі порти не вилікують.
🗣 How to say it out loud For parallel runs each thread gets its own device and its own set of ports. On Android that's udid, systemPort, chromedriverPort and mjpegServerPort; on iOS udid, wdaLocalPort and a separate derivedDataPath for WDA. I compute ports from the thread index and take devices from a pool that checks each device is alive. The thread count equals the device count, otherwise you get queues and timeouts. And I don't forget data: each thread has its own test user, because no ports will fix a clash on the backend.
44 Власна лабораторія пристроїв чи хмарна ферма (BrowserStack, Sauce Labs, AWS Device Farm): як обрати? Your own device lab or a cloud farm (BrowserStack, Sauce Labs, AWS Device Farm): how do you choose?
Стабільність і CI Stability and CI Senior ▶

Що це. Device farm — набір реальних чи віртуальних пристроїв, доступних для автоматичних прогонів. Хмарні ферми дають Appium-ендпоінт у інтернеті: тест підключається до hub-cloud.browserstack.com чи ondemand.*.saucelabs.com, а застосунок заздалегідь завантажується через REST API. AWS Device Farm працює інакше: ви завантажуєте застосунок і пакет тестів, і сервіс сам запускає їх на своєму хості поруч із пристроєм.

What it is. A device farm is a set of real or virtual devices available for automated runs. Cloud farms expose an Appium endpoint on the internet: the test connects to hub-cloud.browserstack.com or ondemand.*.saucelabs.com, and the app is uploaded beforehand through a REST API. AWS Device Farm works differently: you upload the app and a test package, and the service runs them itself on a host next to the device.

Навіщо / трейдофи. Хмара дає сотні моделей і версій ОС без закупівлі й обслуговування, відео, логи й мережеві логи з коробки, і масштаб «на вимогу». Ціна — гроші за паралельні слоти чи хвилини, черги в пікові години, мережева затримка кожної команди (100–300 мс замість кількох мілісекунд локально, тож «балакучі» тести сповільнюються в рази), бінарник і тестові акаунти в третьої сторони, доступ до staging через тунель (BrowserStack Local, Sauce Connect). Власна лабораторія — швидкі команди, повний контроль, дешевше на великому обсязі, але це окремий продукт: USB-хаби, батареї, що здуваються, оновлення ОС, моніторинг.

Why / trade-offs. A cloud gives hundreds of models and OS versions with no purchasing or maintenance, video, logs and network logs out of the box, and scale on demand. The price: money for parallel slots or minutes, queues at peak hours, network latency on every command (100–300 ms instead of a few locally, so "chatty" tests slow down several times), the binary and test accounts sitting with a third party, and staging access through a tunnel (BrowserStack Local, Sauce Connect). An in-house lab gives fast commands, full control and lower cost at high volume, but it is a product of its own: USB hubs, swelling batteries, OS updates, monitoring.

Приклад. Типова гібридна схема: PR-прогони на емуляторах у CI, нічний прогін на 8–10 популярних моделях у хмарі, а для специфічного заліза (платіжний термінал, NFC) — кілька власних пристроїв.

Example. A typical hybrid setup: PR runs on emulators in CI, a nightly run on 8–10 popular models in the cloud, and a few in-house devices for special hardware (a payment terminal, NFC).

Підводні камені. Перенесення тестів у хмару часто розкриває приховану залежність від швидкості: тест проходив локально лише тому, що команди були миттєві. Пристрої в хмарі спільні — з попередніх сесій можуть лишатися налаштування; приватні пристрої дорожчі. Capabilities провайдера (bstack:options, sauce:options) різні, тож їх тримають у профілях конфігурації, а не в коді тестів.

Pitfalls. Moving tests to the cloud often exposes a hidden dependency on speed: a test only passed locally because commands were instant. Cloud devices are shared — settings from earlier sessions can linger; private devices cost more. Provider capabilities (bstack:options, sauce:options) differ, so they live in configuration profiles, not in test code.

Уточнююче питання. «Як порахувати вартість?» — потрібна кількість паралельних слотів = тривалість набору ÷ бажаний час прогону; порівняти з вартістю пристроїв, хостів і годин інженера на обслуговування.

Follow-up question. "How do you estimate cost?" Required parallel slots = suite duration ÷ target run time; compare that with the cost of devices, hosts and the engineer hours spent on upkeep.

BrowserStack: завантаження і capabilities
BrowserStack: upload and capabilities
curl -u "$BS_USER:$BS_KEY" -X POST "https://api-cloud.browserstack.com/app-automate/upload" \
  -F "file=@build/app-release.apk" -F "custom_id=shop-android"
# {"app_url":"bs://c700ce60cf13ae8ed97705a55b8e022f13c5827c", ...}
curl -u "$BS_USER:$BS_KEY" -X POST "https://api-cloud.browserstack.com/app-automate/upload" \
  -F "file=@build/app-release.apk" -F "custom_id=shop-android"
# {"app_url":"bs://c700ce60cf13ae8ed97705a55b8e022f13c5827c", ...}
Профіль хмари в Java
A cloud profile in Java
UiAutomator2Options o = new UiAutomator2Options()
    .setApp("shop-android");                // custom_id останнього завантаження
o.setCapability("bstack:options", Map.of(
    "deviceName", "Samsung Galaxy S23",
    "osVersion", "14.0",
    "projectName", "shop",
    "buildName", System.getenv("BUILD_ID"),
    "sessionName", testName,
    "networkLogs", true));

AndroidDriver driver = new AndroidDriver(
    URI.create("https://" + user + ":" + key + "@hub-cloud.browserstack.com/wd/hub").toURL(), o);
UiAutomator2Options o = new UiAutomator2Options()
    .setApp("shop-android");                // custom_id of the latest upload
o.setCapability("bstack:options", Map.of(
    "deviceName", "Samsung Galaxy S23",
    "osVersion", "14.0",
    "projectName", "shop",
    "buildName", System.getenv("BUILD_ID"),
    "sessionName", testName,
    "networkLogs", true));

AndroidDriver driver = new AndroidDriver(
    URI.create("https://" + user + ":" + key + "@hub-cloud.browserstack.com/wd/hub").toURL(), o);
🔧 Розбір синтаксису: Завантаження повертає app_url виду bs://...; custom_id дозволяє посилатися на останню збірку за стабільним ім'ям. Налаштування провайдера лежать в об'єкті bstack:options, а не під appium:. URL хмари містить облікові дані, тому їх беруть зі змінних середовища, а шлях /wd/hub — вимога цього провайдера, не Appium 2.
🔧 Syntax breakdown: The upload returns an app_url like bs://...; custom_id lets you reference the latest build by a stable name. Provider settings live in the bstack:options object, not under appium:. The cloud URL carries credentials, so they come from environment variables, and the /wd/hub path is this provider's requirement, not Appium 2's.
💡 Простими словами: Хмара — оренда авто: будь-яка модель завтра, але платиш за кожну годину і стоїш у черзі на видачу. Своя лабораторія — власний гараж.
💡 In plain words: A cloud is car rental: any model by tomorrow, but you pay per hour and queue at the counter. Your own lab is your own garage.
🗣 Як читати вголос Я порівнюю хмару і власну лабораторію за кількома осями. Хмара — сотні моделей без закупівлі, відео й логи з коробки, масштаб на вимогу. Мінуси — гроші за слоти, черги, затримка кожної команди через інтернет і те, що збірка з тестовими акаунтами у третьої сторони. Своя лабораторія — швидкі команди й контроль, але це окремий продукт з обслуговуванням. Зазвичай пропоную гібрид: емулятори на PR, хмара вночі на топових моделях, кілька своїх пристроїв для специфічного заліза.
🗣 How to say it out loud I compare a cloud and an in-house lab along several axes. The cloud gives hundreds of models with no purchasing, video and logs out of the box, scale on demand. The downsides are money for slots, queues, latency on every command over the internet, and the build with test accounts sitting with a third party. An in-house lab gives fast commands and control, but it's a product of its own that needs upkeep. Usually I propose a hybrid: emulators on PRs, the cloud nightly on top models, a few own devices for special hardware.
45 Як налаштувати Appium-тести в CI: емулятор, сервер, артефакти, і що робити з iOS? How do you set up Appium tests in CI: emulator, server, artifacts, and what about iOS?
Стабільність і CI Stability and CI Senior ▶

Що це. CI-прогін мобільних тестів складається з кроків: підготувати середовище (JDK, Android SDK, Node, Appium і драйвери фіксованих версій), підняти пристрій (емулятор з апаратним прискоренням або симулятор), запустити Appium-сервер і дочекатися його готовності, запустити тести, зібрати артефакти, прибрати за собою. Android-емулятору потрібна KVM-віртуалізація на Linux, iOS-симулятору — лише macOS-агенти.

What it is. A CI run of mobile tests consists of steps: prepare the environment (JDK, Android SDK, Node, Appium and drivers at pinned versions), bring up a device (a hardware-accelerated emulator or a simulator), start the Appium server and wait until it is ready, run the tests, collect artifacts, clean up. The Android emulator needs KVM virtualisation on Linux; the iOS simulator needs macOS agents only.

Навіщо / трейдофи. Відтворюваність важливіша за швидкість: різні версії драйвера, образу емулятора чи Xcode на агентах — головне джерело «у мене проходить». Тому версії фіксують, а образ AVD кешують. На хмарних раннерах без KVM емулятор повільний у рази — або вмикають KVM, або виносять пристрої на окрему ферму. macOS-хвилини дорогі, тож iOS-набір часто менший і запускається рідше, а WebDriverAgent збирають один раз і перевикористовують.

Why / trade-offs. Reproducibility matters more than speed: different driver, emulator image or Xcode versions across agents are the main source of "it passes for me". So versions are pinned and the AVD image is cached. On cloud runners without KVM the emulator is several times slower — either enable KVM or move devices to a separate farm. macOS minutes are expensive, so the iOS suite is often smaller and runs less often, and WebDriverAgent is built once and reused.

Приклад. GitHub Actions: Linux-раннер, крок увімкнення KVM, reactivecircus/android-emulator-runner з вимкненими анімаціями, скрипт, що запускає Appium у фоні з логом у файл, чекає /status, запускає Maven і завжди вивантажує артефакти.

Example. GitHub Actions: a Linux runner, a step that enables KVM, reactivecircus/android-emulator-runner with animations disabled, and a script that starts Appium in the background with a log file, waits for /status, runs Maven, and always uploads artifacts.

Підводні камені. Тести, що стартують до повного завантаження емулятора, падають на першій команді — треба чекати sys.boot_completed. Процес Appium у фоні без явного завершення може тримати job. Артефакти вивантажують з умовою always(), інакше саме при падінні їх не буде. Секрети (ключі хмари, тестові паролі) — лише через захищені змінні, не в capabilities у репозиторії.

Pitfalls. Tests that start before the emulator has fully booted fail on the first command — wait for sys.boot_completed. A background Appium process without explicit termination can keep the job hanging. Artifacts are uploaded under an always() condition, otherwise they are missing exactly when a test fails. Secrets (cloud keys, test passwords) go only through protected variables, never into capabilities committed to the repo.

Уточнююче питання. «Як скоротити час старту емулятора?» — кешувати AVD зі знімком після першого завантаження і стартувати з нього з -no-snapshot-save.

Follow-up question. "How do you cut emulator start time?" Cache the AVD with a snapshot after the first boot and start from it with -no-snapshot-save.

GitHub Actions для Android
GitHub Actions for Android
jobs:
  android-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm
      - run: npm i -g appium@2 && appium driver install uiautomator2@3
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          disable-animations: true
          script: ./ci/run-e2e.sh
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-artifacts
          path: |
            build/artifacts
            appium.log
ci/run-e2e.sh
ci/run-e2e.sh
#!/usr/bin/env bash
set -euo pipefail
appium --log appium.log --log-timestamp &
APPIUM_PID=$!
trap 'kill $APPIUM_PID' EXIT
until curl -sf http://127.0.0.1:4723/status > /dev/null; do sleep 1; done
adb wait-for-device shell 'while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 1; done'
mvn -B test -Dudid=emulator-5554
🔧 Розбір синтаксису: Крок KVM дає поточному користувачу доступ до /dev/kvm, без чого емулятор працює без прискорення. disable-animations: true виставляє три масштаби анімацій у нуль. if: always() вивантажує артефакти навіть після падіння. У скрипті trap ... EXIT гарантовано зупиняє Appium, цикл until curl чекає готовності сервера, а sys.boot_completed — повного завантаження Android.
🔧 Syntax breakdown: The KVM step gives the current user access to /dev/kvm, without which the emulator runs unaccelerated. disable-animations: true sets the three animation scales to zero. if: always() uploads artifacts even after a failure. In the script trap ... EXIT guarantees Appium is stopped, the until curl loop waits for the server to be ready, and sys.boot_completed waits for Android to finish booting.
💡 Простими словами: CI для мобільних — кухня ресторану: все розкладено заздалегідь, плита розігріта до замовлення, а посуд миють навіть тоді, коли страва не вдалася.
💡 In plain words: Mobile CI is a restaurant kitchen: everything is laid out in advance, the stove is hot before the order arrives, and the dishes get washed even when the meal fails.
🗣 Як читати вголос У CI я йду за простою послідовністю: фіксую версії Appium, драйверів і образу емулятора, піднімаю емулятор з апаратним прискоренням і вимкненими анімаціями, запускаю сервер у фоні з логом у файл, чекаю status і завершення завантаження Android, і лише тоді запускаю тести. Артефакти вивантажую завжди, навіть при падінні. iOS потребує macOS-агентів, вони дорогі, тож там менший набір і заздалегідь зібраний WebDriverAgent. Секрети — лише через захищені змінні.
🗣 How to say it out loud In CI I follow a simple sequence: pin versions of Appium, drivers and the emulator image, bring up the emulator with hardware acceleration and animations off, start the server in the background with a log file, wait for status and for Android to finish booting, and only then run the tests. I always upload artifacts, even on failure. iOS needs macOS agents, which are expensive, so there's a smaller suite and a prebuilt WebDriverAgent. Secrets go only through protected variables.
46 Нічний Appium-набір із 300 тестів падає випадково у 15–30% прогонів. Як ти його стабілізуєш? A nightly Appium suite of 300 tests fails randomly in 15–30% of runs. How would you stabilise it?
Стабільність і CI Stability and CI Senior ▶

Що це. Це задача не про окремий тест, а про систему: мобільна нестабільність має кілька незалежних джерел — тести (очікування, анімації, спільний стан), пристрої (пам'ять заповнена, низький заряд, системні діалоги, оновлення ОС), інфраструктура (порти, незакриті сесії, збірка WDA, мережа до ферми) і середовище (тестові дані, стабільність staging). Без класифікації падінь зусилля йдуть не туди.

What it is. This is a system problem, not a single-test one: mobile flakiness has several independent sources — tests (waits, animations, shared state), devices (full storage, low battery, system dialogs, OS updates), infrastructure (ports, sessions left open, WDA builds, the network to the farm) and environment (test data, staging stability). Without classifying failures, effort goes to the wrong place.

Навіщо підхід / план. Перший тиждень — вимірювання: зберігати результат кожного тесту в кожному прогоні, рахувати flaky rate на тест і категорію помилки за текстом винятку. Зазвичай 10% тестів дають 80% падінь. Далі — карантин найгірших (прогін продовжує їх ганяти, але не блокує), паралельно з виправленням причин. Інфраструктурні падіння (сесія не створилась, xcodebuild, обрив сокета) можна повторити один раз автоматично — це не баг продукту. Падіння в логіці тесту не повторюють, а виправляють.

Why this approach / the plan. Week one is measurement: store every test's result in every run, compute a flaky rate per test and a failure category from the exception text. Usually 10% of tests produce 80% of failures. Next comes quarantine for the worst ones (the run keeps executing them without blocking), while fixing causes in parallel. Infrastructure failures (session not created, xcodebuild, a dropped socket) may be retried once automatically — they are not product bugs. Failures in test logic are not retried; they are fixed.

Приклад. Аналіз показав: 40% падінь — «session not created» на двох конкретних пристроях (зношені батареї, перегрів), 30% — промахи після анімацій на трьох екранах, 20% — конфлікт тестового користувача між потоками. Рішення: health-check пристрою перед lease, вимкнення анімацій і явні очікування, користувач на потік. Через три тижні flaky rate впав до 2%.

Example. The analysis showed: 40% of failures were "session not created" on two specific devices (worn batteries, overheating), 30% were missed taps after animations on three screens, 20% were a test user shared between threads. The fixes: a device health check before lease, animations off plus explicit waits, one user per thread. Three weeks later the flaky rate was down to 2%.

Підводні камені. Глобальний retry на всі тести ховає справжні баги (race condition у застосунку виглядає як «флейк») і подвоює час прогону. Карантин без власника і терміну перетворюється на кладовище тестів. Збільшення всіх таймаутів маскує проблему, але робить кожне справжнє падіння повільним.

Pitfalls. A global retry on every test hides real bugs (a race condition in the app looks like "flakiness") and doubles run time. Quarantine without an owner and a deadline turns into a test graveyard. Raising every timeout masks the problem but makes each genuine failure slow.

Уточнююче питання. «Як переконати команду, що стало краще?» — показувати тренд flaky rate і середній час прогону на дашборді, а не окремі зелені запуски.

Follow-up question. "How do you convince the team it got better?" Show the flaky rate trend and average run time on a dashboard, not individual green runs.

Повтор лише інфраструктурних падінь
Retrying infrastructure failures only
public class InfraRetry implements IRetryAnalyzer {
    private static final List<String> INFRA = List.of(
        "SessionNotCreatedException", "xcodebuild failed", "socket hang up",
        "Could not proxy command to the remote server");
    private int attempts = 0;

    @Override
    public boolean retry(ITestResult result) {
        String error = String.valueOf(result.getThrowable());
        boolean infra = INFRA.stream().anyMatch(error::contains);
        result.setAttribute("failureCategory", infra ? "infra" : "test");
        return infra && attempts++ < 1;     // один повтор і лише для інфраструктури
    }
}
public class InfraRetry implements IRetryAnalyzer {
    private static final List<String> INFRA = List.of(
        "SessionNotCreatedException", "xcodebuild failed", "socket hang up",
        "Could not proxy command to the remote server");
    private int attempts = 0;

    @Override
    public boolean retry(ITestResult result) {
        String error = String.valueOf(result.getThrowable());
        boolean infra = INFRA.stream().anyMatch(error::contains);
        result.setAttribute("failureCategory", infra ? "infra" : "test");
        return infra && attempts++ < 1;     // one retry and only for infrastructure
    }
}
🔧 Розбір синтаксису: IRetryAnalyzer TestNG вирішує, чи перезапустити впалий тест. Список INFRA — фрагменти повідомлень, які означають проблему середовища, а не продукту. Атрибут failureCategory потрапляє у звіт і дає змогу рахувати частку кожної категорії. attempts++ < 1 обмежує повтор одним разом.
🔧 Syntax breakdown: TestNG's IRetryAnalyzer decides whether to re-run a failed test. The INFRA list holds message fragments that signal an environment problem, not a product one. The failureCategory attribute goes into the report and lets you count each category's share. attempts++ < 1 limits the retry to a single attempt.
💡 Простими словами: Не лікуй усю лікарню жарознижувальним. Спочатку поміряй температуру кожному, потім лікуй причину.
💡 In plain words: Don't treat the whole hospital with fever pills. First take everyone's temperature, then treat the cause.
🗣 Як читати вголос Я почав би з вимірювання, а не з ретраїв. Тиждень збираю результати кожного тесту в кожному прогоні, рахую flaky rate і категорію помилки. Зазвичай десять відсотків тестів дають більшість падінь. Найгірші — в карантин з власником і терміном, паралельно лагоджу причини: здоров'я пристроїв, анімації й очікування, спільні тестові дані. Автоматичний повтор дозволяю лише для інфраструктурних помилок і лише один раз, бо глобальний retry ховає справжні баги. Прогрес показую трендом на дашборді.
🗣 How to say it out loud I'd start with measurement, not retries. For a week I collect every test's result in every run and compute the flaky rate and failure category. Usually ten percent of tests cause most failures. The worst go into quarantine with an owner and a deadline, and in parallel I fix causes: device health, animations and waits, shared test data. I only allow an automatic retry for infrastructure errors, and only once, because a global retry hides real bugs. I show progress as a trend on a dashboard.