Що це. 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.
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
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 /
appium:automationName, а драйвер тримає власний агент на пристрої (серверний APK або WebDriverAgent). appium driver install кладе драйвер у каталог APPIUM_HOME (за замовчуванням ~/.appium), --installed показує, що вже є.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.Що це. В 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.
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 приймає список через кому, і лише ці плагіни будуть підключені до кожної сесії.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.Що це. 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.
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 гарантує, що сесія не зависне на сервері після падіння.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.Що це. 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 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);
setCapability з повною назвою і префіксом appium:. xcodeOrgId — 10-символьний Team ID з Apple Developer, xcodeSigningId — тип сертифіката, updatedWDABundleId — bundle id, під який перепідписується WDA.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.Що це. 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.
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-збірці, тому це домовленість із розробниками.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.Що це. 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.
# пристрій 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.--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.Що це. 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.
// 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" }
WebDriverWait з elementToBeClickable обов'язковий. В Espresso той самий клік не потребує очікування: perform виконується після того, як головний потік простоює. У Maestro tapOn сам повторює пошук кілька секунд.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.Що це. 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.
{
"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": [{}]
}
}
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 міняв пристрій і збірку без правки коду.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.Що це. Протокол 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.
{
"platformName": "iOS",
"appium:automationName": "XCUITest",
"appium:platformVersion": "17.5",
"appium:deviceName": "iPhone 15",
"bstack:options": { "projectName": "shop", "buildName": "nightly-512" }
}
POST /session { "platformName": "Android", "automationName": "UiAutomator2" }
ERROR: invalid argument: Illegal key values seen in w3c capabilities: [automationName]
appium:platformVersion і appium:deviceName — розширення Appium, bstack:options — окремий простір BrowserStack, куди кладуть назву проєкту і збірки. Текст помилки може трохи відрізнятися між версіями, але суть однакова: нестандартний ключ без префікса.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.Що це. 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.
// нативний застосунок
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(), що й для нативних тестів.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 — «відкрий браузер і зайди на сайт».app means "open this program", browserName means "open the browser and go to the site".Що це. 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-....
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
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.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 — його ім'я. Однакових імен багато, паспорт один.udid is the device passport number, deviceName is its first name. Many devices share a name, only one has that passport.Що це. 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.
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
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.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.Що це. Дві 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.
| 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
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");
mobile: clearApp виконує аналог pm clear для одного пакета і залишає застосунок встановленим; після нього застосунок треба знову активувати.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 — купити нову приставку. Зазвичай досить почати новий рівень.noReset is continuing yesterday's game, fullReset is buying a new console. Usually starting a fresh level is enough.Що це. Сесія живе від 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.
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() прибирає посилання, щоб потік із пулу не отримав мертвий драйвер.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() — телефон простоїть, доки не спрацює таймер.quit(), the phone sits idle until the timer runs out.Що це. 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.
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
// 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 — окремий воркер зі своїм пристроєм.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.Що це. 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.
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"
const checkout = await $("~checkout");
await checkout.waitForDisplayed({ timeout: 10000 });
await checkout.click();
AppiumBy.accessibilityId — стратегія accessibility id протоколу Appium. У WebdriverIO тильда ~ на початку селектора означає ту саму стратегію. waitForDisplayed чекає, доки елемент стане видимим, з таймаутом у мілісекундах.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.Що це. Мобільні платформи будують дерево доступності для 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.
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 проблемних кнопок, щоб у звіті було видно, які саме елементи без мітки.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.Що це. 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.
// погано: абсолютний шлях, ламається від будь-якої обгортки
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-рядку.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.Що це. 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.
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");
textContains і textStartsWith порівнюють видимий текст, enabled(true) відсікає неактивні кнопки. Зворотна коса риска перед лапками — екранування в Java-рядку, сам UiSelector бачить звичайні лапки.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.Що це. Обидві стратегії — нативні для 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.
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]");
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 стратегію пишуть префіксом із двокрапкою прямо в селекторі.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.Що це. Явне очікування (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.
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")));
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.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.Що це. 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.
// погано: елемент знайдено до скролу
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() тут — хелпер проєкту зі скролом.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.Що це. 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.
<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]" />
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 повертає порожній список замість винятку, тому зручний для перевірки наявності без очікування.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.Що це. 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.
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 повертає наступну сторінку, щоб тест читався як ланцюжок кроків.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.Що це. 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.
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));
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 задає швидкість жесту.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.Що це. Є три нативні способи замість ручних свайпів. На 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.
// 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.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.Що це. Для складних жестів є два шляхи. 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.
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
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();
elementId, але одиниці duration різні. percent у pinchOpenGesture — наскільки розвести пальці відносно розміру елемента, scale у iOS pinch більше 1 — збільшення, менше 1 — зменшення. У JS pause(1500) між down і up і робить натискання довгим; isAndroid() — хелпер проєкту.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.Що це. Тап за координатами — дотик у точку (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.
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 тапає за абсолютними координатами.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.Що це. Після фокусу в полі введення система показує програмну клавіатуру, яка займає до половини екрана. Вона може перекрити кнопку «Далі», зсунути верстку (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.
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();
((AndroidDriver) driver).pressKey(new KeyEvent(AndroidKey.ENTER));
isKeyboardShown() оголошено в інтерфейсі HasOnScreenKeyboard, який реалізують і AndroidDriver, і IOSDriver. mobile: hideKeyboard на iOS шукає на клавіатурі клавішу з однією з назв у keys і натискає її. pressKey(new KeyEvent(AndroidKey.ENTER)) надсилає системну клавішу Enter, яка виконує imeOptions поля, наприклад перехід до наступного.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.Що це. Поворот пристрою — не просто інша геометрія. На 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.
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");
adb shell settings put system accelerometer_rotation 1
SupportsRotation — інтерфейс java-client, який реалізують AndroidDriver і IOSDriver: rotate задає орієнтацію, getOrientation читає поточну. Очікування через лямбду чекає фактичного завершення повороту. Елемент після повороту шукається заново через By, бо старе посилання може бути вже недійсним.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 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.
// 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 натискає кнопку за назвою, що корисно, коли їх більше двох.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.Що це. 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".
// 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")));
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 сам повторює перевірку до таймауту.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.Що це. 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.
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")));
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) читає застосунок.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.Що це. Біометрія — системний діалог ОС, результат якого застосунок отримує через 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.
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")));
# один раз: PIN-блокування і реєстрація відбитка в Settings, потім
adb -e emu finger touch 1
# once: PIN lock and fingerprint enrolment in Settings, then
adb -e emu finger touch 1
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) робить те саме з тесту.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.Що це. Гібридний застосунок поєднує нативні екрани і 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.
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")));
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.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.Що це. Локатор завжди виконується в активному контексті. У 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.
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-контексту.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.Що це. Мобільний веб в 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.
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 знімає весь екран пристрою разом із панеллю браузера, тому видно, що саме перекриває кнопку.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.Що це. Мобільне падіння рідко відтворюється на машині розробника: інша модель, стан пам'яті, мережа, системний діалог, що вискочив саме тоді. Тому тест має сам зібрати докази в момент падіння: скриншот, 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.
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() — доступ до драйвера поточного потоку.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.Що це. Лог пристрою — те, що пишуть ОС і застосунок: на 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.
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);
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 фільтрує потік логів за іменем процесу.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.Що це. Анімація переходу, 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.
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
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.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.Що це. Мобільний застосунок живе в мережі, яка постійно змінюється: метро, перемикання 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.
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-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.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.Що це. На 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.
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 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 у мілісекундах.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-сесія, крім 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.
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;
}
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 },
],
};
appium:. Зсув на індекс потоку гарантує, що діапазони не перетинаються; базові значення беруть подалі від портів, які вже займає інфраструктура. setDerivedDataPath дає кожному iOS-пристрою власний каталог збірки WDA. У WebdriverIO maxInstances обмежує кількість одночасних воркерів.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.Що це. 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.
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", ...}
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.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.Що це. 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.
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
#!/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
/dev/kvm, без чого емулятор працює без прискорення. disable-animations: true виставляє три масштаби анімацій у нуль. if: always() вивантажує артефакти навіть після падіння. У скрипті trap ... EXIT гарантовано зупиняє Appium, цикл until curl чекає готовності сервера, а sys.boot_completed — повного завантаження Android./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.Що це. Це задача не про окремий тест, а про систему: мобільна нестабільність має кілька незалежних джерел — тести (очікування, анімації, спільний стан), пристрої (пам'ять заповнена, низький заряд, системні діалоги, оновлення ОС), інфраструктура (порти, незакриті сесії, збірка 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.
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 обмежує повтор одним разом.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.